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