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:
@@ -278,3 +278,47 @@ class TwoFactorPageTests(TestCase):
|
|||||||
# The id lives on the form element — without it the button submits nothing.
|
# The id lives on the form element — without it the button submits nothing.
|
||||||
self.assertContains(self.response, 'id="webauthn_form"')
|
self.assertContains(self.response, 'id="webauthn_form"')
|
||||||
self.assertContains(self.response, "allauth.webauthn.forms.authenticateForm")
|
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")
|
||||||
|
|||||||
@@ -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
|
@register.filter
|
||||||
def field_icon(field):
|
def field_icon(field):
|
||||||
return FIELD_ICONS.get(field.name, "")
|
return FIELD_ICONS.get(field.name, "")
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
# Required by allauth's security-key list template ({% load humanize %}); without it
|
||||||
|
# that page raises TemplateSyntaxError.
|
||||||
|
"django.contrib.humanize",
|
||||||
"phonenumber_field",
|
"phonenumber_field",
|
||||||
"lucide",
|
"lucide",
|
||||||
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
|
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
--color-sky-500: oklch(68.5% 0.169 237.323);
|
--color-sky-500: oklch(68.5% 0.169 237.323);
|
||||||
--color-black: #000;
|
--color-black: #000;
|
||||||
|
--color-white: #fff;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--container-xs: 20rem;
|
--container-xs: 20rem;
|
||||||
--container-xl: 36rem;
|
--container-xl: 36rem;
|
||||||
@@ -3089,6 +3090,9 @@
|
|||||||
.bg-primary {
|
.bg-primary {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
.bg-white {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
}
|
||||||
.btn-link {
|
.btn-link {
|
||||||
@layer daisyui {
|
@layer daisyui {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
@@ -3119,6 +3123,17 @@
|
|||||||
background-image: none;
|
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 {
|
.btn-ghost {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
--btn-bg: #0000;
|
--btn-bg: #0000;
|
||||||
@@ -3135,6 +3150,9 @@
|
|||||||
.p-2 {
|
.p-2 {
|
||||||
padding: calc(var(--spacing) * 2);
|
padding: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.p-3 {
|
||||||
|
padding: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: calc(var(--spacing) * 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 {
|
.btn-accent {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--btn-color: var(--color-accent);
|
--btn-color: var(--color-accent);
|
||||||
|
|||||||
@@ -122,6 +122,29 @@
|
|||||||
applyMode(currentMode());
|
applyMode(currentMode());
|
||||||
</script>
|
</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 %}
|
{% comment %}
|
||||||
allauth puts page-level scripts and out-of-form markup here — notably the
|
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
|
hidden `mfa_login` form the passkey button submits. Without this block that
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
{% load allauth %}
|
{% 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 %}
|
{% comment %} djlint:off {% endcomment %}
|
||||||
<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %}
|
<{% 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.form %}form="{{ attrs.form }}"{% endif %}
|
||||||
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
|
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
|
||||||
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
|
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
|
||||||
|
|||||||
42
templates/allauth/elements/field.html
Normal file
42
templates/allauth/elements/field.html
Normal 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>
|
||||||
@@ -41,7 +41,25 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% with icon=field|field_icon %}
|
{% 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 }}">
|
<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>
|
<span class="opacity-50">{% lucide icon size=16 %}</span>
|
||||||
{% if attrs.unlabeled %}<span class="sr-only">{{ field.label }}</span>{% endif %}
|
{% if attrs.unlabeled %}<span class="sr-only">{{ field.label }}</span>{% endif %}
|
||||||
|
|||||||
10
templates/allauth/elements/img.html
Normal file
10
templates/allauth/elements/img.html
Normal 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>
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
{% extends "mfa/base_entrance.html" %}
|
{% extends "mfa/base_entrance.html" %}
|
||||||
{% load allauth i18n lucide ui %}
|
{% load allauth i18n lucide %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
Overridden for layout: the code is a daisyUI otp field, Cancel sits beside Sign In,
|
Overridden for layout only: Cancel sits beside Sign In, and "Use a security key" is an
|
||||||
and "Use a security key" is an accent button under Alternative options.
|
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`,
|
Cancel and the security key submit *other* forms (`logout-from-stage`, `webauthn_form`)
|
||||||
`webauthn_form`) through the HTML `form` attribute — forms cannot nest, so those live
|
through the HTML `form` attribute — forms cannot nest, so those sit at the end of the
|
||||||
at the end of the page and the buttons point at them by id.
|
page and the buttons point at them by id.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block head_title %}
|
{% block head_title %}
|
||||||
@@ -25,24 +27,9 @@
|
|||||||
<form class="mt-8" method="post" action="{% url 'mfa_authenticate' %}">
|
<form class="mt-8" method="post" action="{% url 'mfa_authenticate' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="mb-10 flex flex-col items-center gap-2">
|
<div class="mb-10">
|
||||||
{% comment %}
|
{% element fields form=form unlabeled=True %}
|
||||||
daisyUI's otp is one input overlaid on the boxes. Six of them, for a TOTP
|
{% endelement %}
|
||||||
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>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
@@ -83,27 +70,4 @@
|
|||||||
<input type="hidden" name="next" value="{% url 'account_login' %}">
|
<input type="hidden" name="next" value="{% url 'account_login' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
</form>
|
</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 %}
|
{% endblock content %}
|
||||||
|
|||||||
Reference in New Issue
Block a user