Fix empty allauth forms, and lay out the 2FA page

The 2FA code input was not invisible -- it was absent, along with the fields of
every other allauth form except login.

Cause: the `fields` element passed `attrs.exclude` straight into a filter. On a
page that never sets it, resolving a filter *argument* raises
VariableDoesNotExist; Django rescues that for the main variable of an expression
but not for a filter argument, and {% if %} then swallows it and reads the
condition as false. So every field was skipped. Login was the one page that
passes `exclude`, which is exactly why it kept working and hid the damage.
`exclude` is now pinned to a real variable first, with tests that render the
login, signup and password-reset forms and assert their inputs exist.

Two dangling buttons fixed while in here: `elements/form.html` dropped the `id`
attribute, so the out-of-band forms allauth generates (webauthn_form,
logout-from-stage) had no id for a button's `form` attribute to point at. "Use a
security key" submitted nothing.

Layout: the code is a daisyUI otp field, Cancel sits beside Sign In as a plain
button, both gain icons, and "Use a security key" becomes an accent button.

The otp boxes yield once more than six characters are typed. allauth accepts a
TOTP code (6) *or* a recovery code (8) in this one field, so hard-boxing it to
six would have locked out every recovery code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 22:59:00 +02:00
parent b3f153a2dc
commit 2b7b2b64db
6 changed files with 366 additions and 6 deletions

View File

@@ -232,3 +232,49 @@ class AdminLoginRoutingTests(TestCase):
def test_allauth_login_page_loads(self):
self.assertEqual(self.client.get(reverse("account_login")).status_code, 200)
class AuthFormRenderingTests(TestCase):
"""Every allauth form must actually render its fields.
Regression: the `fields` element passed `attrs.exclude` straight into a filter.
On a page that never sets it, resolving a filter *argument* raises
VariableDoesNotExist — which Django swallows inside {% if %} and reads as false —
so every field was silently dropped from every form except the login page (the one
page that does pass `exclude`).
"""
def test_the_login_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_login")), 'name="login"')
def test_the_password_reset_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_reset_password")), 'name="email"')
def test_the_signup_form_renders_its_fields(self):
self.assertContains(self.client.get(reverse("account_signup")), 'name="password1"')
class TwoFactorPageTests(TestCase):
def setUp(self):
user = User.objects.create_user(email="mfa@example.com", password="pw-secret-123")
enrol_mfa(user)
# Password accepted, second factor still owed: this is the 2FA challenge page.
self.response = self.client.post(reverse("account_login"), {"login": "mfa@example.com", "password": "pw-secret-123"}, follow=True)
def test_the_code_field_renders_as_an_otp_input(self):
self.assertContains(self.response, 'name="code"')
self.assertContains(self.response, "otp otp-lg")
def test_cancel_sits_beside_sign_in_and_is_not_primary(self):
self.assertContains(self.response, '<button class="btn gap-2" type="submit" form="logout-from-stage">')
self.assertContains(self.response, '<button class="btn btn-primary gap-2" type="submit">')
def test_cancel_has_a_form_to_submit(self):
self.assertContains(self.response, 'id="logout-from-stage"')
def test_the_security_key_button_is_an_accent_button_with_a_working_form(self):
self.assertContains(self.response, "btn btn-accent")
self.assertContains(self.response, 'form="webauthn_form"')
# 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")

View File

@@ -446,6 +446,170 @@
}
}
}
.otp {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
font-family: var(--font-mono);
clip-path: inset(-3.5px 3.5px -3.5px -3.5px);
border-radius: var(--radius-field);
font-size: 1.75rem;
gap: var(--otp-gap);
--input-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
}
--otp-ch: 1ch;
@supports (font: -apple-system-body) {
--otp-ch: 0.618164em;
}
--otp-gap: calc(var(--otp-ch) * 0.5);
--otp-w: calc(var(--otp-ch) * 2);
--otp-size: calc(var(--size-field, 0.25rem) * 10);
--stride: calc(var(--otp-w) + var(--otp-gap));
> input {
pointer-events: none;
inset-inline-start: calc(0.25rem * 0);
z-index: 1;
margin: 0;
appearance: none;
border-style: var(--tw-border-style);
border-width: 0px;
background-color: transparent;
padding: 0;
outline-style: var(--tw-outline-style);
outline-width: 0px;
field-sizing: content;
padding-inline-start: calc(var(--otp-ch) * 0.5 + var(--border));
line-height: 1;
letter-spacing: calc(var(--stride) - var(--otp-ch));
font-variant-numeric: tabular-nums;
&::selection {
background-color: transparent;
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
}
}
&:valid {
caret-color: transparent;
}
}
&:has( > span:nth-child(1)) {
width: calc(var(--stride) * 1);
}
&:has( > span:nth-child(2)) {
width: calc(var(--stride) * 2);
}
&:has( > span:nth-child(3)) {
width: calc(var(--stride) * 3);
}
&:has( > span:nth-child(4)) {
width: calc(var(--stride) * 4);
}
&:has( > span:nth-child(5)) {
width: calc(var(--stride) * 5);
}
&:has( > span:nth-child(6)) {
width: calc(var(--stride) * 6);
}
&:has( > span:nth-child(7)) {
width: calc(var(--stride) * 7);
}
&:has( > span:nth-child(8)) {
width: calc(var(--stride) * 8);
}
> span {
position: absolute;
display: flex;
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
transition: border-color 0.2s;
inline-size: var(--otp-w);
block-size: var(--otp-size);
background-color: var(--color-base-100);
border: var(--border) solid var(--input-color);
border-radius: inherit;
outline: 2px solid #0000;
outline-offset: 1px;
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
}
&:nth-child(1) {
left: 1px;
@supports (font: -apple-system-body) {
left: 2px;
}
@supports (-moz-appearance: none) {
left: 0px;
}
}
&:nth-child(2) {
left: calc(var(--stride) * 1);
transition-delay: 0.02s;
}
&:nth-child(3) {
left: calc(var(--stride) * 2);
transition-delay: 0.04s;
}
&:nth-child(4) {
left: calc(var(--stride) * 3);
transition-delay: 0.06s;
}
&:nth-child(5) {
left: calc(var(--stride) * 4);
transition-delay: 0.08s;
}
&:nth-child(6) {
left: calc(var(--stride) * 5);
transition-delay: 0.1s;
}
&:nth-child(7) {
left: calc(var(--stride) * 6);
transition-delay: 0.12s;
}
&:nth-child(8) {
left: calc(var(--stride) * 7);
transition-delay: 0.14s;
}
}
&:has(input:valid:focus) {
> span {
outline: 2px solid var(--input-color);
outline-offset: 1px;
}
&:after {
opacity: 0;
}
}
&:after {
flex-shrink: 0;
content: "";
width: var(--otp-w);
height: var(--otp-size);
border-radius: var(--radius-field);
outline: 2px solid #0000;
outline-offset: 1px;
z-index: 10;
margin-inline-start: calc(-1 * (var(--otp-gap) + var(--otp-ch) * 0.5 + var(--border)));
}
&:focus-within {
isolation: isolate;
--input-color: var(--color-base-content);
> span {
box-shadow: 0 1px var(--input-color);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
}
}
&:after {
outline-color: var(--input-color);
}
}
}
}
.collapse-arrow {
@layer daisyui.l1.l2 {
> .collapse-title:after {
@@ -2464,6 +2628,9 @@
.mt-8 {
margin-top: calc(var(--spacing) * 8);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
@@ -2986,6 +3153,9 @@
.pt-3 {
padding-top: calc(var(--spacing) * 3);
}
.pb-2 {
padding-bottom: calc(var(--spacing) * 2);
}
.text-center {
text-align: center;
}
@@ -3028,6 +3198,12 @@
font-size: 0.75rem;
}
}
.otp-lg {
@layer daisyui.l1.l2 {
font-size: 2rem;
--otp-size: calc(var(--size-field, 0.25rem) * 12);
}
}
.font-bold {
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
@@ -3165,6 +3341,17 @@
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.input-lg {
@layer daisyui.l1.l2 {
--in-size-mul: 12;
--font-size-min: 1.125rem;
--spin-my: -3;
.floating-label:has(&) {
--top-mul: 6;
--font-size: 1.125rem;
}
}
}
.btn-accent {
@layer daisyui.l1.l2 {
--btn-color: var(--color-accent);

View File

@@ -17,9 +17,17 @@
<span>{{ error }}</span>
</div>
{% endfor %}
{% comment %}
`exclude` lets a page lay a field out itself — the login page puts "remember" on the
button row. It is pinned to a real variable first, and that is not cosmetic: passing
`attrs.exclude` straight into a filter on a page that never set it raises
VariableDoesNotExist, which Django catches for the *main* variable of an expression
but not for a filter *argument*. {% if %} then swallows it and reads the condition as
false — silently dropping every field on every form that doesn't pass `exclude`.
{% endcomment %}
{% with exclude=attrs.exclude|default:"" %}
{% 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 not field|excluded: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 }}">
@@ -55,3 +63,4 @@
</div>
{% endif %}
{% endfor %}
{% endwith %}

View File

@@ -1,9 +1,18 @@
{% load allauth %}
{% comment %}
`id` is not decorative: allauth renders out-of-band forms (webauthn, logout-from-stage)
and points buttons at them with the HTML `form` attribute. Drop the id and those
buttons submit nothing.
{% endcomment %}
<form method="{{ attrs.method }}"
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.action %}action="{{ attrs.action }}"{% endif %}
class="space-y-2">
{% slot body %}{% endslot %}
<div class="card-actions justify-end pt-3">
{% slot actions %}{% endslot %}
</div>
{% if not attrs.no_visible_fields %}
<div class="card-actions justify-end pt-3">
{% slot actions %}{% endslot %}
</div>
{% endif %}
</form>

View File

@@ -1 +1 @@
{% comment %} djlint:off {% endcomment %}{% load allauth %}<h1 class="card-title text-2xl">{% slot %}{% endslot %}</h1>
{% comment %} djlint:off {% endcomment %}{% load allauth %}<h1 class="card-title text-2xl border-b border-base-content pb-2 mb-2">{% slot %}{% endslot %}</h1>

View File

@@ -0,0 +1,109 @@
{% extends "mfa/base_entrance.html" %}
{% load allauth i18n lucide ui %}
{% comment %}
Overridden for layout: the code is a daisyUI otp field, Cancel sits beside Sign In,
and "Use a security key" is an accent button under Alternative options.
Cancel and the security key both submit *other* forms (`logout-from-stage`,
`webauthn_form`) through the HTML `form` attribute — forms cannot nest, so those live
at the end of the page and the buttons point at them by id.
{% endcomment %}
{% block head_title %}
{% trans "Sign In" %}
{% endblock head_title %}
{% block content %}
{% element h1 %}
{% trans "Two-Factor Authentication" %}
{% endelement %}
{% element p %}
{% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
{% endelement %}
<form class="mt-8" method="post" action="{% url 'mfa_authenticate' %}">
{% csrf_token %}
<div class="mb-10 flex flex-col items-center gap-2">
{% comment %}
daisyUI's otp is one input overlaid on the boxes. Six of them, for a TOTP
code — but this same field also accepts an 8-character recovery code, so the
boxes step aside once what's typed no longer fits them (see the script
below). Boxing it to six and calling it done would lock out recovery codes.
{% endcomment %}
<div class="otp otp-lg" data-otp>
{{ form.code|daisy:"" }}
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{% for error in form.code.errors %}<span class="label-text-alt text-error">{{ error }}</span>{% endfor %}
{% for error in form.non_field_errors %}<span class="label-text-alt text-error">{{ error }}</span>{% endfor %}
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<button class="btn gap-2" type="submit" form="logout-from-stage">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
<button class="btn btn-primary gap-2" type="submit">{% lucide "log-in" size=16 %} {% trans "Sign In" %}</button>
</div>
</form>
{% if "webauthn" in MFA_SUPPORTED_TYPES %}
{% element hr %}
{% endelement %}
{% element h2 %}
{% translate "Alternative options" %}
{% endelement %}
<button class="btn btn-accent gap-2" type="button" form="webauthn_form" id="mfa_webauthn_authenticate">
{% lucide "usb" size=16 %} {% trans "Use a security key" %}
</button>
<form id="webauthn_form" method="post" action="{% url 'mfa_authenticate' %}">
{% csrf_token %}
{{ webauthn_form.credential }}
</form>
{{ js_data|json_script:"js_data" }}
{% include "mfa/webauthn/snippets/scripts.html" %}
<script data-allauth-onload="allauth.webauthn.forms.authenticateForm" type="application/json">
{
"ids": {
"authenticate": "mfa_webauthn_authenticate",
"credential": "{{ webauthn_form.credential.auto_id }}",
"data": "js_data"
}
}
</script>
{% endif %}
<form id="logout-from-stage" method="post" action="{% url 'account_logout' %}">
<input type="hidden" name="next" value="{% url 'account_login' %}">
{% csrf_token %}
</form>
<script>
// A TOTP code is 6 characters and a recovery code is 8, and allauth accepts either
// here. The boxed otp layout only fits six, so past that we drop back to a plain
// input instead of letting the text overflow the boxes.
(() => {
const otp = document.querySelector("[data-otp]");
const input = otp?.querySelector("input");
if (!input) return;
const fit = () => {
const boxed = input.value.length <= 6;
otp.classList.toggle("otp", boxed);
otp.classList.toggle("otp-lg", boxed);
otp.querySelectorAll("span").forEach((box) => box.classList.toggle("hidden", !boxed));
input.classList.toggle("input", !boxed);
input.classList.toggle("input-lg", !boxed);
};
input.addEventListener("input", fit);
fit();
})();
</script>
{% endblock content %}