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:
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
109
templates/mfa/authenticate.html
Normal file
109
templates/mfa/authenticate.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user