From addfc61a2c0e529f0882222a3f4cfb3dd659d846 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 13 Jul 2026 23:07:44 +0200 Subject: [PATCH] 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 --- authentication/tests.py | 44 +++++++++++++++++++ controlpanel/templatetags/ui.py | 9 ++++ rosterchief/settings.py | 3 ++ static/css/app.css | 26 ++++++++++++ templates/_base.html | 23 ++++++++++ templates/allauth/elements/button.html | 10 ++++- templates/allauth/elements/field.html | 42 +++++++++++++++++++ templates/allauth/elements/fields.html | 20 ++++++++- templates/allauth/elements/img.html | 10 +++++ templates/mfa/authenticate.html | 58 +++++--------------------- 10 files changed, 196 insertions(+), 49 deletions(-) create mode 100644 templates/allauth/elements/field.html create mode 100644 templates/allauth/elements/img.html diff --git a/authentication/tests.py b/authentication/tests.py index 01dfdd5..b0c0270 100644 --- a/authentication/tests.py +++ b/authentication/tests.py @@ -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") diff --git a/controlpanel/templatetags/ui.py b/controlpanel/templatetags/ui.py index ab581cb..199a249 100644 --- a/controlpanel/templatetags/ui.py +++ b/controlpanel/templatetags/ui.py @@ -59,6 +59,15 @@ FIELD_ICONS = { } +#: Fields rendered as a boxed one-time-code input rather than a plain text field. +OTP_FIELDS = {"code"} + + +@register.filter +def is_otp(field): + return field.name in OTP_FIELDS + + @register.filter def field_icon(field): return FIELD_ICONS.get(field.name, "") diff --git a/rosterchief/settings.py b/rosterchief/settings.py index 110057e..c13f0e1 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -43,6 +43,9 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + # Required by allauth's security-key list template ({% load humanize %}); without it + # that page raises TemplateSyntaxError. + "django.contrib.humanize", "phonenumber_field", "lucide", # Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in diff --git a/static/css/app.css b/static/css/app.css index 74cc999..0d852fc 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -7,6 +7,7 @@ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; --color-sky-500: oklch(68.5% 0.169 237.323); --color-black: #000; + --color-white: #fff; --spacing: 0.25rem; --container-xs: 20rem; --container-xl: 36rem; @@ -3089,6 +3090,9 @@ .bg-primary { background-color: var(--color-primary); } + .bg-white { + background-color: var(--color-white); + } .btn-link { @layer daisyui { text-decoration-line: underline; @@ -3119,6 +3123,17 @@ background-image: none; } } + .btn-outline { + @layer daisyui.l1.l2.l3 { + --btn-bg: #0000; + color: var(--btn-rest-fg, var(--btn-color, var(--color-base-content))); + --btn-border: var(--btn-color, var(--color-base-content)); + --btn-border-style: solid; + background-image: none; + --btn-inset: 0 0 0 0 oklch(0% 0 0/0); + --btn-shadow: 0 0 0 0 oklch(0% 0 0/0); + } + } .btn-ghost { @layer daisyui.l1.l2.l3 { --btn-bg: #0000; @@ -3135,6 +3150,9 @@ .p-2 { padding: calc(var(--spacing) * 2); } + .p-3 { + padding: calc(var(--spacing) * 3); + } .p-4 { padding: calc(var(--spacing) * 4); } @@ -3352,6 +3370,14 @@ } } } + .btn-neutral { + @layer daisyui.l1.l2 { + --btn-color: var(--color-neutral); + --btn-fg: var(--color-neutral-content); + --btn-soft-bg: var(--color-neutral-content) 80%; + --btn-rest-fg: initial; + } + } .btn-accent { @layer daisyui.l1.l2 { --btn-color: var(--color-accent); diff --git a/templates/_base.html b/templates/_base.html index fc10ef6..a2e337d 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -122,6 +122,29 @@ applyMode(currentMode()); + + {% comment %} allauth puts page-level scripts and out-of-form markup here — notably the hidden `mfa_login` form the passkey button submits. Without this block that diff --git a/templates/allauth/elements/button.html b/templates/allauth/elements/button.html index e4a04e9..8e24b3f 100644 --- a/templates/allauth/elements/button.html +++ b/templates/allauth/elements/button.html @@ -1,7 +1,15 @@ {% load allauth %} + +{% comment %} + allauth tags every button it renders, and mapping those tags is what styles the MFA + and account pages without overriding each one. The ranking matters: those pages put + three buttons in a row (Deactivate / View / Download) and rely on the tags to say + which is destructive and which is secondary. Flattening them all to btn-primary makes + "Deactivate" look exactly as safe as "View". +{% endcomment %} {% comment %} djlint:off {% endcomment %} <{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %} -class="btn {% if attrs.tags and 'danger' in attrs.tags %}btn-error{% elif attrs.tags and 'secondary' in attrs.tags %}btn-ghost{% elif attrs.tags and 'link' in attrs.tags %}btn-link{% else %}btn-primary{% endif %}" +class="btn {% if attrs.tags and 'danger' in attrs.tags %}btn-error{% elif attrs.tags and 'link' in attrs.tags %}btn-link{% elif attrs.tags and 'secondary' in attrs.tags %}btn-outline btn-neutral{% elif attrs.tags and 'outline' in attrs.tags %}btn-outline btn-primary{% else %}btn-primary{% endif %}" {% if attrs.form %}form="{{ attrs.form }}"{% endif %} {% if attrs.id %}id="{{ attrs.id }}"{% endif %} {% if attrs.name %}name="{{ attrs.name }}"{% endif %} diff --git a/templates/allauth/elements/field.html b/templates/allauth/elements/field.html new file mode 100644 index 0000000..19aa365 --- /dev/null +++ b/templates/allauth/elements/field.html @@ -0,0 +1,42 @@ +{% load allauth %} + +{% comment %} + A standalone field that isn't part of a Django form — allauth uses it for the TOTP + secret and the recovery-code list. Mono, because both are strings people copy + character by character and must not misread. +{% endcomment %} +{{ attrs.errors }} +
+ {% if slots.label %} + + {% endif %} + + {% if attrs.type == "textarea" %} + + {% else %} + + {% endif %} + + {% if slots.help_text %} + {% slot help_text %}{% endslot %} + {% endif %} +
diff --git a/templates/allauth/elements/fields.html b/templates/allauth/elements/fields.html index b76f58d..372d767 100644 --- a/templates/allauth/elements/fields.html +++ b/templates/allauth/elements/fields.html @@ -41,7 +41,25 @@ {% endif %} {% with icon=field|field_icon %} - {% if icon %} + {% if field|is_otp %} + {% comment %} + daisyUI's otp is one input overlaid on the boxes, and it sizes itself from + its `span` children — so the sr-only label goes OUTSIDE the container, or it + would count as a seventh box and throw the width off. + {% endcomment %} + {% if attrs.unlabeled %}{% endif %} +
+
+ {{ field|daisy:"" }} + + + + + + +
+
+ {% elif icon %}