From b3f153a2dc181d68bd86cbace656a1e0cd42f8bd Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 13 Jul 2026 22:47:26 +0200 Subject: [PATCH] Lay out the login card and wire up the passkey button Sign In and "Sign in with a passkey" (btn-accent) now sit side by side with "Remember Me" on the same row, and the email/password block is given room above and below. The passkey button was dead. It submits a *different* form -- the hidden `mfa_login` that allauth renders from its `extra_body` block -- and our layout base never defined that block, so neither the form nor the webauthn script was ever emitted and clicking the button did nothing. _base.html now has the block, and there is a test asserting the form and script are on the page. The `fields` element grows an `exclude`, so a page can lay a field out itself (here: "remember", moved onto the button row). It splits on commas rather than testing for a substring -- "password" is a substring of "password2", and a page excluding one would otherwise silently drop the other. Co-Authored-By: Claude Opus 4.8 --- controlpanel/templatetags/ui.py | 11 +++++ controlpanel/tests.py | 42 ++++++++++++++++- static/css/app.css | 13 ++++++ templates/_base.html | 7 +++ templates/account/login.html | 65 ++++++++++++++++++++++++++ templates/allauth/elements/fields.html | 3 ++ 6 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 templates/account/login.html diff --git a/controlpanel/templatetags/ui.py b/controlpanel/templatetags/ui.py index 49cfafa..ab581cb 100644 --- a/controlpanel/templatetags/ui.py +++ b/controlpanel/templatetags/ui.py @@ -64,6 +64,17 @@ def field_icon(field): return FIELD_ICONS.get(field.name, "") +@register.filter +def excluded(field, names): + """Is this field in a comma-separated exclude list? + + Split rather than a substring test: ``"password" in "password2"`` is true, and the + login page excluding "remember" must not silently drop a field whose name happens + to contain it. + """ + return field.name in (names or "").split(",") + + @register.filter def daisy(field, css=None): """Render a bound form field with the right daisyUI classes. diff --git a/controlpanel/tests.py b/controlpanel/tests.py index 1aaba5e..73faaca 100644 --- a/controlpanel/tests.py +++ b/controlpanel/tests.py @@ -19,7 +19,7 @@ from teams.models import Position, Team, TeamMembership from .services.admins import grant_club_admin from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access from .services.statistics import club_statistics, clubs_with_totals, platform_totals -from .templatetags.ui import as_alert, daisy, field_icon +from .templatetags.ui import as_alert, daisy, excluded, field_icon User = get_user_model() Flag = get_waffle_flag_model() @@ -513,3 +513,43 @@ class LoginFormRenderingTests(TestCase): self.assertContains(self.response, 'id="id_password_helptext"') self.assertContains(self.response, "mt-3") self.assertContains(self.response, "Forgot your password?") + + +class LoginLayoutTests(TestCase): + def setUp(self): + self.response = self.client.get(reverse("account_login")) + + def test_remember_me_is_laid_out_by_the_page_not_the_fields_element(self): + # It sits on the button row, so the fields element must not also render it. + self.assertEqual(self.response.content.count(b'name="remember"'), 1) + self.assertContains(self.response, 'Remember Me') + + def test_the_passkey_button_sits_beside_sign_in(self): + self.assertContains(self.response, "btn btn-accent") + self.assertContains(self.response, "Sign in with a passkey") + + def test_the_passkey_button_has_a_form_to_submit(self): + # The button posts to the hidden `mfa_login` form via its `form` attribute. That + # form comes from allauth's extra_body block — without it the button is dead. + self.assertContains(self.response, 'form="mfa_login"') + self.assertContains(self.response, 'id="mfa_login"') + self.assertContains(self.response, "allauth.webauthn.forms.loginForm") + + +class ExcludedFilterTests(TestCase): + def field(self, name): + class Form(forms.Form): + pass + + Form.base_fields[name] = forms.CharField() + return Form()[name] + + def test_a_field_is_excluded_by_exact_name(self): + self.assertTrue(excluded(self.field("remember"), "remember")) + + def test_a_name_that_merely_contains_another_is_not_excluded(self): + # A substring test would drop "password" when excluding "password2". + self.assertFalse(excluded(self.field("password"), "password2,remember")) + + def test_nothing_is_excluded_without_a_list(self): + self.assertFalse(excluded(self.field("remember"), None)) diff --git a/static/css/app.css b/static/css/app.css index 5f2d35b..672b21b 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2461,12 +2461,18 @@ .mt-4 { margin-top: calc(var(--spacing) * 4); } + .mt-8 { + margin-top: calc(var(--spacing) * 8); + } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } + .mb-10 { + margin-bottom: calc(var(--spacing) * 10); + } .ml-2 { margin-left: calc(var(--spacing) * 2); } @@ -3159,6 +3165,13 @@ --tw-ease: var(--ease-out); transition-timing-function: var(--ease-out); } + .btn-accent { + @layer daisyui.l1.l2 { + --btn-color: var(--color-accent); + --btn-fg: var(--color-accent-content); + --btn-soft-bg: initial; + } + } .btn-error { @layer daisyui.l1.l2 { --btn-color: var(--color-error); diff --git a/templates/_base.html b/templates/_base.html index 69895a1..fc10ef6 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -121,5 +121,12 @@ 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 + form is never rendered and "Sign in with a passkey" is a dead button. + {% endcomment %} + {% block extra_body %}{% endblock extra_body %} diff --git a/templates/account/login.html b/templates/account/login.html new file mode 100644 index 0000000..923bb9f --- /dev/null +++ b/templates/account/login.html @@ -0,0 +1,65 @@ +{% extends "account/base_entrance.html" %} +{% load allauth i18n lucide ui %} + +{% comment %} + Overridden for layout: "Remember Me" sits on the button row rather than floating as + a third field, and the passkey button sits beside Sign In instead of below a rule. + + The passkey button submits a *different* form (the hidden `mfa_login` rendered by the + webauthn snippet in extra_body) via the `form` attribute — forms cannot nest, so it + can live inside the login form and still post elsewhere. +{% endcomment %} + +{% block head_title %} + {% trans "Sign In" %} +{% endblock head_title %} + +{% block content %} + {% element h1 %} + {% trans "Sign In" %} + {% endelement %} + +
+ {% csrf_token %} + +
+ {% element fields form=form unlabeled=True exclude="remember" %} + {% endelement %} +
+ + {{ redirect_field }} + +
+ {% if form.fields.remember %} + + {% else %} + + {% endif %} + +
+ + {% if PASSKEY_LOGIN_ENABLED %} + + {% endif %} +
+
+
+ + {% if LOGIN_BY_CODE_ENABLED %} + {% element hr %} + {% endelement %} + {% element button href=request_login_code_url tags="prominent,login,outline,primary" %} + {% trans "Send me a sign-in code" %} + {% endelement %} + {% endif %} +{% endblock content %} + +{% block extra_body %} + {{ block.super }} + {% if PASSKEY_LOGIN_ENABLED %} + {% include "mfa/webauthn/snippets/login_script.html" with button_id="passkey_login" %} + {% endif %} +{% endblock extra_body %} diff --git a/templates/allauth/elements/fields.html b/templates/allauth/elements/fields.html index faecb41..dfe145b 100644 --- a/templates/allauth/elements/fields.html +++ b/templates/allauth/elements/fields.html @@ -18,6 +18,8 @@ {% endfor %} {% for field in attrs.form.visible_fields %} + {# `exclude` lets a page lay a field out itself — the login page puts "remember" on the button row. #} + {% if not field|excluded:attrs.exclude %}
{% if field.field.widget.input_type == "checkbox" %}
+ {% endif %} {% endfor %}