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 %}