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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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, '<span class="label-text">Remember Me</span>')
|
||||
|
||||
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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -121,5 +121,12 @@
|
||||
|
||||
applyMode(currentMode());
|
||||
</script>
|
||||
|
||||
{% 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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
65
templates/account/login.html
Normal file
65
templates/account/login.html
Normal file
@@ -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 %}
|
||||
|
||||
<form class="mt-8" method="post" action="{% url 'account_login' %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-10">
|
||||
{% element fields form=form unlabeled=True exclude="remember" %}
|
||||
{% endelement %}
|
||||
</div>
|
||||
|
||||
{{ redirect_field }}
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
{% if form.fields.remember %}
|
||||
<label class="label cursor-pointer justify-start gap-3" for="{{ form.remember.id_for_label }}">
|
||||
{{ form.remember|daisy }}
|
||||
<span class="label-text">{{ form.remember.label }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "log-in" size=16 %} {% trans "Sign In" %}</button>
|
||||
{% if PASSKEY_LOGIN_ENABLED %}
|
||||
<button class="btn btn-accent gap-2" type="submit" form="mfa_login" id="passkey_login">{% lucide "key-round" size=16 %} {% trans "Sign in with a passkey" %}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% 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 %}
|
||||
@@ -18,6 +18,8 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="form-control my-3 w-full">
|
||||
{% if field.field.widget.input_type == "checkbox" %}
|
||||
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||
@@ -51,4 +53,5 @@
|
||||
{% if field.help_text %}<span id="{{ field.auto_id }}_helptext" class="label-text-alt mt-3 block text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user