Style every MFA screen through the element system

The MFA pages (manage, TOTP activate/deactivate, recovery codes, security keys,
reauthenticate) are built almost entirely from allauth's `element` primitives, so
they are styled by overriding the elements rather than by rewriting eight page
templates. New allauth pages then inherit the look for free.

- field + img elements were missing entirely, so allauth fell back to bare HTML:
  the TOTP secret and recovery-code list rendered as unstyled inputs. The QR now
  sits on a white plate -- it is dark modules on a transparent ground, so on the
  dark theme it was dark-on-dark and phones could not scan it.
- button now honours the tags allauth sets. They were all flattened to
  btn-primary, which made "Deactivate" look exactly as safe as "View".
- the `code` field renders as a daisyUI otp wherever it appears, so the
  reauthenticate and activate pages get the same input as the login challenge.
  The boxes step aside past six characters: allauth accepts a TOTP code (6) or a
  recovery code (8) in that one field.

Fixes a crash: the security-key list does {% load humanize %}, which raised
TemplateSyntaxError because django.contrib.humanize was not installed. That page
500'd on every request; it is now installed and covered by a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:07:44 +02:00
parent 2b7b2b64db
commit addfc61a2c
10 changed files with 196 additions and 49 deletions

View File

@@ -278,3 +278,47 @@ class TwoFactorPageTests(TestCase):
# The id lives on the form element — without it the button submits nothing.
self.assertContains(self.response, 'id="webauthn_form"')
self.assertContains(self.response, "allauth.webauthn.forms.authenticateForm")
class MfaPageTests(TestCase):
"""Every MFA screen must render. They are built from allauth's `element` primitives,
so styling lives in the element overrides rather than in eight page templates."""
def setUp(self):
self.user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
# A real password login (not force_login) so allauth counts it as a recent
# authentication and doesn't bounce the sensitive pages to reauthenticate.
self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
def test_the_manage_page_renders_a_panel_per_authenticator(self):
response = self.client.get(reverse("mfa_index"))
self.assertContains(response, "Authenticator App")
self.assertContains(response, "card border")
def test_the_security_key_list_renders(self):
# Regression: allauth's template does {% load humanize %}, which raised
# TemplateSyntaxError until django.contrib.humanize was installed.
self.assertEqual(self.client.get(reverse("mfa_list_webauthn")).status_code, 200)
def test_the_totp_activate_page_boxes_the_code_and_plates_the_qr(self):
response = self.client.get(reverse("mfa_activate_totp"))
self.assertContains(response, "otp otp-lg")
# The QR is dark-on-transparent: without a white plate it is unscannable on the
# dark theme.
self.assertContains(response, "bg-white p-3")
self.assertContains(response, "font-mono") # the secret, to be copied by hand
def test_the_deactivate_button_is_destructive(self):
enrol_mfa(self.user)
response = self.client.get(reverse("mfa_index"))
# allauth tags it "danger" — it must not look like the safe action.
self.assertContains(response, "btn-error")
def test_reauthenticating_with_a_code_boxes_the_input(self):
enrol_mfa(self.user)
self.assertContains(self.client.get(reverse("mfa_reauthenticate")), "otp otp-lg")