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

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