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 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:07:44 +02:00
parent 2b7b2b64db
commit addfc61a2c
10 changed files with 196 additions and 49 deletions

View File

@@ -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")

View File

@@ -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, "")

View File

@@ -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

View File

@@ -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);

View File

@@ -122,6 +122,29 @@
applyMode(currentMode());
</script>
<script>
// A TOTP code is 6 characters, a recovery code 8, and allauth accepts either in
// the same field. The boxed otp layout only fits six, so past that we fall back
// to a plain input rather than letting the text spill out of the boxes. Boxing
// the field to six and calling it done would lock recovery codes out entirely.
document.querySelectorAll("[data-otp]").forEach((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>
{% 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

View File

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

View File

@@ -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 }}
<div class="form-control my-3 w-full">
{% if slots.label %}
<label class="label" for="{{ attrs.id }}">
<span class="label-text">{% slot label %}{% endslot %}</span>
</label>
{% endif %}
{% if attrs.type == "textarea" %}
<textarea class="textarea textarea-bordered w-full font-mono"
{% if attrs.required %}required{% endif %}
{% if attrs.rows %}rows="{{ attrs.rows }}"{% endif %}
{% if attrs.disabled %}disabled{% endif %}
{% if attrs.readonly %}readonly{% endif %}
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %}>{% slot value %}{% endslot %}</textarea>
{% else %}
<input class="input input-bordered w-full font-mono"
{% if attrs.required %}required{% endif %}
{% if attrs.disabled %}disabled{% endif %}
{% if attrs.readonly %}readonly{% endif %}
{% if attrs.checked %}checked{% endif %}
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.placeholder %}placeholder="{{ attrs.placeholder }}"{% endif %}
{% if attrs.autocomplete %}autocomplete="{{ attrs.autocomplete }}"{% endif %}
{% if attrs.value is not None %}value="{{ attrs.value }}"{% endif %}
type="{{ attrs.type }}">
{% endif %}
{% if slots.help_text %}
<span class="label-text-alt mt-1 block text-base-content/70">{% slot help_text %}{% endslot %}</span>
{% endif %}
</div>

View File

@@ -41,7 +41,25 @@
</label>
{% 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 %}<label class="sr-only" for="{{ field.id_for_label }}">{{ field.label }}</label>{% endif %}
<div class="flex justify-center">
<div class="otp otp-lg" data-otp>
{{ field|daisy:"" }}
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
{% elif icon %}
<label class="input flex w-full items-center gap-2 {% if field.errors %}input-error{% endif %}" for="{{ field.id_for_label }}">
<span class="opacity-50">{% lucide icon size=16 %}</span>
{% if attrs.unlabeled %}<span class="sr-only">{{ field.label }}</span>{% endif %}

View File

@@ -0,0 +1,10 @@
{% load allauth %}
{% comment %}
Only used for the TOTP QR code. The white plate is not decoration: the QR is dark
modules on a transparent ground, so on the dark theme it would be dark-on-dark and
phones could not scan it.
{% endcomment %}
<div class="my-4 flex justify-center">
<img class="rounded-box bg-white p-3" src="{{ attrs.src }}" {% if attrs.alt %}alt="{{ attrs.alt }}"{% endif %}>
</div>

View File

@@ -1,13 +1,15 @@
{% extends "mfa/base_entrance.html" %}
{% load allauth i18n lucide ui %}
{% load allauth i18n lucide %}
{% 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.
Overridden for layout only: Cancel sits beside Sign In, and "Use a security key" is an
accent button under Alternative options. The code field itself is styled by the `fields`
element (it renders a `code` field as a daisyUI otp), so the reauthenticate and TOTP
activate pages get the same input without a template of their own.
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.
Cancel and the security key submit *other* forms (`logout-from-stage`, `webauthn_form`)
through the HTML `form` attribute — forms cannot nest, so those sit at the end of the
page and the buttons point at them by id.
{% endcomment %}
{% block head_title %}
@@ -25,24 +27,9 @@
<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 class="mb-10">
{% element fields form=form unlabeled=True %}
{% endelement %}
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
@@ -83,27 +70,4 @@
<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 %}