defaults; every
+ --font-* also generates a utility, so --font-roboto gives us `font-roboto` to
+ opt into Roboto where we want it. */
+@theme {
+ --font-sans: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
+ --font-ubuntu: "Ubuntu", ui-sans-serif, system-ui, sans-serif;
+ --font-roboto: "Roboto", ui-sans-serif, system-ui, sans-serif;
+ --font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
+}
+
+/* The logo is a background image, not `content:` -- content-replacement on a real
+ element (rather than ::before/::after) isn't supported in Firefox.
+
+ Default = dark-ink logo, for a light background. The media query covers "auto",
+ where the toggle deliberately sets no data-theme at all; the attribute selectors
+ are more specific, so an explicit choice always beats the OS. */
+.logo {
+ background-image: var(--logo-dark);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
+}
+
+@media (prefers-color-scheme: dark) {
+ .logo {
+ background-image: var(--logo-light);
+ }
+}
+
+[data-theme="light"] .logo {
+ background-image: var(--logo-dark);
+}
+
+[data-theme="dark"] .logo {
+ background-image: var(--logo-light);
+}
+
+/* Club-uploaded logos are arbitrary raster files (often much smaller than the
+ badge they're displayed in), so the browser is upscaling them -- and the
+ default resampling some engines fall back to for that reads as pixelated.
+ Forcing the element onto its own GPU-composited layer makes Chrome/Safari
+ pick their higher-quality scaler; the image-rendering hints are a no-op
+ where unsupported, degrading gracefully to the browser default. Doesn't
+ apply to SVG logos, which scale losslessly regardless. */
+.club-logo {
+ image-rendering: -webkit-optimize-contrast;
+ image-rendering: smooth;
+ image-rendering: high-quality;
+ backface-visibility: hidden;
+ transform: translateZ(0);
+}
diff --git a/authentication/__init__.py b/authentication/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/authentication/adapters.py b/authentication/adapters.py
new file mode 100644
index 0000000..d9857e9
--- /dev/null
+++ b/authentication/adapters.py
@@ -0,0 +1,37 @@
+"""allauth adapters.
+
+The MFA adapter exists for one important reason: WebAuthn credentials are bound
+to a **Relying Party ID** (a domain). allauth's default RP ID is the request's
+host — which under our subdomain tenancy would be ``ajax-united.rosterchief.app``,
+binding a passkey to *one club*. A member of two clubs would then need two
+passkeys, and a credential registered at one club would silently fail at another.
+
+Pinning the RP ID to the registrable parent domain (``rosterchief.app``) makes a
+single passkey work across every club subdomain.
+"""
+
+from allauth.mfa.adapter import DefaultMFAAdapter
+from django.conf import settings
+
+
+class RosterChiefMFAAdapter(DefaultMFAAdapter):
+ def get_public_key_credential_rp_entity(self) -> dict[str, str]:
+ return {
+ "id": webauthn_rp_id(),
+ "name": settings.MFA_WEBAUTHN_RP_NAME,
+ }
+
+
+def webauthn_rp_id() -> str:
+ """The registrable parent domain that passkeys are bound to.
+
+ Falls back to the request host when no base domain is configured (e.g. a
+ bare ``localhost`` dev server), which keeps WebAuthn usable there.
+ """
+ base_domain = getattr(settings, "ROSTERCHIEF_BASE_DOMAIN", "")
+ if base_domain:
+ return base_domain
+
+ from allauth.core import context
+
+ return context.request.get_host().partition(":")[0]
diff --git a/authentication/admin.py b/authentication/admin.py
new file mode 100644
index 0000000..28b106b
--- /dev/null
+++ b/authentication/admin.py
@@ -0,0 +1,44 @@
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from django.utils.translation import gettext_lazy as _
+
+from members.models import Member
+
+from .forms import UserChangeForm, UserCreationForm
+from .models import User
+
+
+class MemberInline(admin.StackedInline):
+ """Edit the member profile attached to a login from the User page."""
+
+ model = Member
+ can_delete = False
+ extra = 0
+ max_num = 1
+ verbose_name_plural = _("member profile")
+ fields = ("first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone")
+
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin):
+ add_form = UserCreationForm
+ form = UserChangeForm
+ model = User
+ inlines = [MemberInline]
+
+ list_display = ("email", "full_name", "is_staff", "is_active")
+ list_filter = ("is_staff", "is_superuser", "is_active", "groups")
+ search_fields = ("email", "member__first_name", "member__last_name")
+ ordering = ("email",)
+ readonly_fields = ("last_login",)
+
+ fieldsets = (
+ (None, {"fields": ("email", "password")}),
+ (_("Permissions"), {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
+ (_("Important dates"), {"fields": ("last_login",)}),
+ )
+ add_fieldsets = ((None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),)
+
+ @admin.display(description=_("name"))
+ def full_name(self, obj):
+ return obj.get_full_name()
diff --git a/authentication/apps.py b/authentication/apps.py
new file mode 100644
index 0000000..372ba81
--- /dev/null
+++ b/authentication/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class AuthenticationConfig(AppConfig):
+ name = "authentication"
diff --git a/authentication/forms.py b/authentication/forms.py
new file mode 100644
index 0000000..b922d84
--- /dev/null
+++ b/authentication/forms.py
@@ -0,0 +1,20 @@
+from django.contrib.auth.forms import BaseUserCreationForm
+from django.contrib.auth.forms import UserChangeForm as DjangoUserChangeForm
+
+from .models import User
+
+
+class UserCreationForm(BaseUserCreationForm):
+ """Add-user form for the email-based custom User (no ``username`` field)."""
+
+ class Meta:
+ model = User
+ fields = ("email",)
+
+
+class UserChangeForm(DjangoUserChangeForm):
+ """Change-user form; keeps the read-only password hash widget."""
+
+ class Meta:
+ model = User
+ fields = "__all__"
diff --git a/authentication/managers.py b/authentication/managers.py
new file mode 100644
index 0000000..9f49068
--- /dev/null
+++ b/authentication/managers.py
@@ -0,0 +1,32 @@
+from django.contrib.auth.base_user import BaseUserManager
+
+
+class UserManager(BaseUserManager):
+ """Manager for the email-based custom User model."""
+
+ use_in_migrations = True
+
+ def _create_user(self, email, password, **extra_fields):
+ if not email:
+ raise ValueError("Users must have an email address.")
+ email = self.normalize_email(email)
+ user = self.model(email=email, **extra_fields)
+ user.set_password(password)
+ user.save(using=self._db)
+ return user
+
+ def create_user(self, email, password=None, **extra_fields):
+ extra_fields.setdefault("is_staff", False)
+ extra_fields.setdefault("is_superuser", False)
+ return self._create_user(email, password, **extra_fields)
+
+ def create_superuser(self, email, password=None, **extra_fields):
+ extra_fields.setdefault("is_staff", True)
+ extra_fields.setdefault("is_superuser", True)
+
+ if extra_fields.get("is_staff") is not True:
+ raise ValueError("Superuser must have is_staff=True.")
+ if extra_fields.get("is_superuser") is not True:
+ raise ValueError("Superuser must have is_superuser=True.")
+
+ return self._create_user(email, password, **extra_fields)
diff --git a/authentication/middleware.py b/authentication/middleware.py
new file mode 100644
index 0000000..8cbe9ac
--- /dev/null
+++ b/authentication/middleware.py
@@ -0,0 +1,49 @@
+"""Force MFA enrolment for privileged users.
+
+Anyone who can change other people's data must have a second factor: Django
+staff/superusers, and anyone holding an elevated ``ClubRole`` (ADMIN or EDITOR)
+in *any* club. Regular members may enrol, but aren't forced to.
+
+Enrolled users are challenged for their second factor by allauth at login; this
+middleware only handles the other half — a privileged user who has never
+enrolled is redirected to the MFA setup page until they do.
+"""
+
+from allauth.mfa.utils import is_mfa_enabled
+from django.conf import settings
+from django.shortcuts import redirect
+from django.urls import reverse
+
+from club.models import ClubRole
+
+#: Paths a not-yet-enrolled user must still reach (to enrol, or to log out).
+#: ``/__reload__/`` is django-browser-reload's event stream, which only exists
+#: under DEBUG — without it, live reload dies on the enrolment page itself.
+EXEMPT_PREFIXES = ("/accounts/", "/static/", "/media/", "/__reload__/")
+
+ELEVATED_ROLES = (ClubRole.Roles.ADMIN, ClubRole.Roles.EDITOR)
+
+
+def mfa_required_for(user) -> bool:
+ """Privileged users must hold a second factor."""
+ if user.is_staff or user.is_superuser:
+ return True
+ return ClubRole.objects.filter(member__user=user, role__in=ELEVATED_ROLES).exists()
+
+
+class RequireMFAMiddleware:
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ if self.needs_enrolment(request):
+ return redirect(reverse(settings.MFA_ENROLMENT_URL_NAME))
+ return self.get_response(request)
+
+ def needs_enrolment(self, request) -> bool:
+ user = getattr(request, "user", None)
+ if user is None or not user.is_authenticated:
+ return False
+ if request.path.startswith(EXEMPT_PREFIXES):
+ return False
+ return mfa_required_for(user) and not is_mfa_enabled(user)
diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py
new file mode 100644
index 0000000..5e98af2
--- /dev/null
+++ b/authentication/migrations/0001_initial.py
@@ -0,0 +1,94 @@
+# Generated by Django 6.0.6 on 2026-07-02 07:33
+
+import authentication.managers
+import django.db.models.deletion
+import phonenumber_field.modelfields
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Family',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=255)),
+ ],
+ options={
+ 'verbose_name': 'family',
+ 'verbose_name_plural': 'families',
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('email', models.EmailField(db_index=True, max_length=254, unique=True)),
+ ('is_staff', models.BooleanField(default=False)),
+ ('is_active', models.BooleanField(default=True)),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': 'user',
+ 'verbose_name_plural': 'users',
+ 'ordering': ['email'],
+ },
+ managers=[
+ ('objects', authentication.managers.UserManager()),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Member',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('first_name', models.CharField(max_length=150)),
+ ('last_name', models.CharField(max_length=150)),
+ ('date_of_birth', models.DateField(blank=True, null=True)),
+ ('email', models.EmailField(blank=True, max_length=254)),
+ ('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
+ ('emergency_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None)),
+ ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'member',
+ 'verbose_name_plural': 'members',
+ 'ordering': ['last_name', 'first_name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='FamilyMembership',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('role', models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255)),
+ ('family', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='authentication.family')),
+ ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='authentication.member')),
+ ],
+ options={
+ 'verbose_name': 'family membership',
+ 'verbose_name_plural': 'family memberships',
+ 'ordering': ['family', 'role', 'member__last_name', 'member__first_name'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='member',
+ index=models.Index(fields=['last_name', 'first_name'], name='authenticat_last_na_0a0eca_idx'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='familymembership',
+ unique_together={('family', 'member')},
+ ),
+ ]
diff --git a/authentication/migrations/0002_alter_family_name.py b/authentication/migrations/0002_alter_family_name.py
new file mode 100644
index 0000000..ea2c70a
--- /dev/null
+++ b/authentication/migrations/0002_alter_family_name.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.6 on 2026-07-02 14:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authentication', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='family',
+ name='name',
+ field=models.CharField(blank=True, max_length=255),
+ ),
+ ]
diff --git a/authentication/migrations/0003_alter_family_name_alter_familymembership_family_and_more.py b/authentication/migrations/0003_alter_family_name_alter_familymembership_family_and_more.py
new file mode 100644
index 0000000..cbf8e1d
--- /dev/null
+++ b/authentication/migrations/0003_alter_family_name_alter_familymembership_family_and_more.py
@@ -0,0 +1,86 @@
+# Generated by Django 6.0.6 on 2026-07-05 13:50
+
+import django.db.models.deletion
+import phonenumber_field.modelfields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authentication', '0002_alter_family_name'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='family',
+ name='name',
+ field=models.CharField(blank=True, max_length=255, verbose_name='name'),
+ ),
+ migrations.AlterField(
+ model_name='familymembership',
+ name='family',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='authentication.family', verbose_name='family'),
+ ),
+ migrations.AlterField(
+ model_name='familymembership',
+ name='member',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='family_memberships', to='authentication.member', verbose_name='member'),
+ ),
+ migrations.AlterField(
+ model_name='familymembership',
+ name='role',
+ field=models.CharField(choices=[('parent', 'parent'), ('child', 'child'), ('guardian', 'guardian'), ('other', 'other')], default='parent', max_length=255, verbose_name='role'),
+ ),
+ migrations.AlterField(
+ model_name='member',
+ name='date_of_birth',
+ field=models.DateField(blank=True, null=True, verbose_name='date of birth'),
+ ),
+ migrations.AlterField(
+ model_name='member',
+ name='email',
+ field=models.EmailField(blank=True, max_length=254, verbose_name='email'),
+ ),
+ migrations.AlterField(
+ model_name='member',
+ name='emergency_phone',
+ field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='emergency phone number'),
+ ),
+ migrations.AlterField(
+ model_name='member',
+ name='first_name',
+ field=models.CharField(max_length=150, verbose_name='first name'),
+ ),
+ migrations.AlterField(
+ model_name='member',
+ name='last_name',
+ field=models.CharField(max_length=150, verbose_name='last name'),
+ ),
+ migrations.AlterField(
+ model_name='member',
+ name='phone',
+ field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='phone number'),
+ ),
+ migrations.AlterField(
+ model_name='member',
+ name='user',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member', to=settings.AUTH_USER_MODEL, verbose_name='user'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='email',
+ field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='is_active',
+ field=models.BooleanField(default=True, verbose_name='is active?'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='is_staff',
+ field=models.BooleanField(default=False, verbose_name='is staff?'),
+ ),
+ ]
diff --git a/authentication/migrations/0004_remove_familymembership_family_and_more.py b/authentication/migrations/0004_remove_familymembership_family_and_more.py
new file mode 100644
index 0000000..886aa84
--- /dev/null
+++ b/authentication/migrations/0004_remove_familymembership_family_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.6 on 2026-07-11 22:09
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authentication', '0003_alter_family_name_alter_familymembership_family_and_more'),
+ ('club', '0005_alter_clubmembership_member'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='FamilyMembership',
+ ),
+ migrations.DeleteModel(
+ name='Family',
+ ),
+ migrations.DeleteModel(
+ name='Member',
+ ),
+ ]
diff --git a/authentication/migrations/__init__.py b/authentication/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/authentication/models.py b/authentication/models.py
new file mode 100644
index 0000000..f4d8c83
--- /dev/null
+++ b/authentication/models.py
@@ -0,0 +1,41 @@
+import uuid
+
+from django.contrib.auth.base_user import AbstractBaseUser
+from django.contrib.auth.models import PermissionsMixin
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from .managers import UserManager
+
+
+class User(AbstractBaseUser, PermissionsMixin):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ email = models.EmailField(_("email"), unique=True, db_index=True)
+
+ is_staff = models.BooleanField(_("is staff?"), default=False)
+ is_active = models.BooleanField(_("is active?"), default=True)
+
+ objects = UserManager()
+
+ USERNAME_FIELD = "email"
+ REQUIRED_FIELDS = []
+
+ class Meta:
+ verbose_name = _("user")
+ verbose_name_plural = _("users")
+ ordering = ["email"]
+
+ def __str__(self):
+ return self.get_full_name()
+
+ def get_full_name(self):
+ member = getattr(self, "member", None)
+ if member is not None:
+ return member.get_full_name()
+ return self.email
+
+ def get_short_name(self):
+ member = getattr(self, "member", None)
+ if member is not None:
+ return member.get_short_name()
+ return self.email
diff --git a/authentication/tests.py b/authentication/tests.py
new file mode 100644
index 0000000..a3b62d7
--- /dev/null
+++ b/authentication/tests.py
@@ -0,0 +1,484 @@
+import re
+import uuid
+from urllib.parse import parse_qs, urlparse
+
+from allauth.core import context
+from allauth.mfa.models import Authenticator
+from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import AnonymousUser
+from django.db import IntegrityError
+from django.http import HttpResponse
+from django.test import RequestFactory, TestCase, override_settings
+from django.urls import reverse
+
+from club.models import Club, ClubRole
+from members.models import Member
+
+from .adapters import RosterChiefMFAAdapter, webauthn_rp_id
+from .middleware import RequireMFAMiddleware, mfa_required_for
+
+User = get_user_model()
+
+
+def enrol_mfa(user):
+ """Give ``user`` a second factor (enough for is_mfa_enabled)."""
+ return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
+
+
+class UserManagerTests(TestCase):
+ def test_create_user_defaults(self):
+ user = User.objects.create_user(email="alice@example.com", password="secret123")
+
+ self.assertEqual(user.email, "alice@example.com")
+ self.assertTrue(user.check_password("secret123"))
+ self.assertFalse(user.is_staff)
+ self.assertFalse(user.is_superuser)
+ self.assertTrue(user.is_active)
+
+ def test_create_user_requires_email(self):
+ with self.assertRaises(ValueError):
+ User.objects.create_user(email="", password="secret123")
+
+ def test_create_user_normalizes_email_domain(self):
+ # BaseUserManager lowercases the domain part of the address.
+ user = User.objects.create_user(email="Bob@Example.COM", password="secret123")
+
+ self.assertEqual(user.email, "Bob@example.com")
+
+ def test_create_user_password_is_hashed(self):
+ user = User.objects.create_user(email="carol@example.com", password="secret123")
+
+ self.assertNotEqual(user.password, "secret123")
+
+ def test_create_user_without_password_is_unusable(self):
+ user = User.objects.create_user(email="dave@example.com")
+
+ self.assertFalse(user.has_usable_password())
+
+ def test_create_superuser_defaults(self):
+ admin = User.objects.create_superuser(email="admin@example.com", password="secret123")
+
+ self.assertTrue(admin.is_staff)
+ self.assertTrue(admin.is_superuser)
+ self.assertTrue(admin.is_active)
+
+ def test_create_superuser_rejects_non_staff(self):
+ with self.assertRaises(ValueError):
+ User.objects.create_superuser(email="admin@example.com", password="x", is_staff=False)
+
+ def test_create_superuser_rejects_non_superuser(self):
+ with self.assertRaises(ValueError):
+ User.objects.create_superuser(email="admin@example.com", password="x", is_superuser=False)
+
+
+class UserModelTests(TestCase):
+ def test_email_is_username_field(self):
+ self.assertEqual(User.USERNAME_FIELD, "email")
+ self.assertEqual(User.REQUIRED_FIELDS, [])
+
+ def test_email_is_unique(self):
+ User.objects.create_user(email="dup@example.com", password="x")
+ with self.assertRaises(IntegrityError):
+ User.objects.create_user(email="dup@example.com", password="y")
+
+ def test_pk_is_uuid(self):
+ user = User.objects.create_user(email="uuid@example.com", password="x")
+ self.assertIsInstance(user.pk, uuid.UUID)
+
+ def test_str_and_names_fall_back_to_email_without_member(self):
+ user = User.objects.create_user(email="lonely@example.com", password="x")
+
+ self.assertEqual(str(user), "lonely@example.com")
+ self.assertEqual(user.get_full_name(), "lonely@example.com")
+ self.assertEqual(user.get_short_name(), "lonely@example.com")
+
+ def test_str_and_names_use_linked_member(self):
+ user = User.objects.create_user(email="linked@example.com", password="x")
+ Member.objects.create(user=user, first_name="Jane", last_name="Doe")
+
+ # Re-fetch so the reverse OneToOne relation is resolved from the DB.
+ user = User.objects.get(pk=user.pk)
+
+ self.assertEqual(str(user), "Jane Doe")
+ self.assertEqual(user.get_full_name(), "Jane Doe")
+ self.assertEqual(user.get_short_name(), "Jane")
+
+
+@override_settings(
+ ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
+ MFA_WEBAUTHN_RP_NAME="RosterChief",
+ ALLOWED_HOSTS=[".rosterchief.app", "example.test"],
+)
+class WebAuthnRelyingPartyTests(TestCase):
+ """A passkey is bound to a Relying Party ID (a domain).
+
+ allauth's default RP ID is the request host, which under our subdomain
+ tenancy would bind a passkey to a single club. We pin it to the registrable
+ parent domain so ONE passkey works across every club.
+ """
+
+ def rp_entity(self, host):
+ request = RequestFactory().get("/", HTTP_HOST=host)
+ with context.request_context(request):
+ return RosterChiefMFAAdapter().get_public_key_credential_rp_entity()
+
+ def test_rp_id_is_the_parent_domain_not_the_club_subdomain(self):
+ self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["id"], "rosterchief.app")
+
+ def test_rp_id_is_identical_across_clubs(self):
+ # The whole point: a passkey registered at one club works at the others.
+ here = self.rp_entity("ajax-united.rosterchief.app")
+ there = self.rp_entity("rival-fc.rosterchief.app")
+
+ self.assertEqual(here["id"], there["id"])
+
+ def test_rp_name_comes_from_settings(self):
+ self.assertEqual(self.rp_entity("ajax-united.rosterchief.app")["name"], "RosterChief")
+
+ @override_settings(ROSTERCHIEF_BASE_DOMAIN="")
+ def test_falls_back_to_the_request_host_without_a_base_domain(self):
+ request = RequestFactory().get("/", HTTP_HOST="example.test:8000")
+
+ with context.request_context(request):
+ self.assertEqual(webauthn_rp_id(), "example.test")
+
+
+class MFARequirementTests(TestCase):
+ def setUp(self):
+ self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
+
+ def make_user(self, email, **kwargs):
+ return User.objects.create_user(email=email, password="pw-secret-123", **kwargs)
+
+ def with_role(self, user, role):
+ member = Member.objects.create(user=user, first_name="Ada", last_name="Min")
+ ClubRole.objects.create(club=self.club, member=member, role=role)
+ return user
+
+ def test_staff_must_have_mfa(self):
+ self.assertTrue(mfa_required_for(self.make_user("staff@example.com", is_staff=True)))
+
+ def test_superuser_must_have_mfa(self):
+ self.assertTrue(mfa_required_for(User.objects.create_superuser(email="root@example.com", password="pw-secret-123")))
+
+ def test_club_admin_must_have_mfa(self):
+ user = self.with_role(self.make_user("admin@example.com"), ClubRole.Roles.ADMIN)
+
+ self.assertTrue(mfa_required_for(user))
+
+ def test_editor_must_have_mfa(self):
+ user = self.with_role(self.make_user("editor@example.com"), ClubRole.Roles.EDITOR)
+
+ self.assertTrue(mfa_required_for(user))
+
+ def test_plain_member_does_not_need_mfa(self):
+ user = self.with_role(self.make_user("member@example.com"), ClubRole.Roles.MEMBER)
+
+ self.assertFalse(mfa_required_for(user))
+
+ def test_user_without_any_role_does_not_need_mfa(self):
+ self.assertFalse(mfa_required_for(self.make_user("nobody@example.com")))
+
+
+class RequireMFAMiddlewareTests(TestCase):
+ def setUp(self):
+ self.factory = RequestFactory()
+ self.middleware = RequireMFAMiddleware(lambda request: HttpResponse("ok"))
+
+ def dispatch(self, user, path="/"):
+ request = self.factory.get(path)
+ request.user = user
+ return self.middleware(request)
+
+ def make_staff(self):
+ return User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
+
+ def test_anonymous_passes_through(self):
+ self.assertEqual(self.dispatch(AnonymousUser()).content, b"ok")
+
+ def test_unprivileged_user_passes_through(self):
+ user = User.objects.create_user(email="plain@example.com", password="pw-secret-123")
+
+ self.assertEqual(self.dispatch(user).content, b"ok")
+
+ def test_privileged_user_without_mfa_is_sent_to_enrolment(self):
+ response = self.dispatch(self.make_staff())
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("mfa_index"))
+
+ def test_privileged_user_can_still_reach_the_enrolment_pages(self):
+ # Otherwise they'd be redirected in a loop and could never enrol.
+ response = self.dispatch(self.make_staff(), path="/accounts/2fa/totp/activate/")
+
+ self.assertEqual(response.content, b"ok")
+
+ def test_enrolled_privileged_user_passes_through(self):
+ staff = self.make_staff()
+ enrol_mfa(staff)
+
+ self.assertEqual(self.dispatch(staff).content, b"ok")
+
+
+class AdminLoginRoutingTests(TestCase):
+ def test_admin_login_is_routed_through_allauth(self):
+ # Django's own admin login knows nothing about second factors.
+ response = self.client.get("/admin/login/", {"next": "/admin/"})
+
+ self.assertEqual(response.status_code, 302)
+ redirect = urlparse(response.url)
+ self.assertEqual(redirect.path, reverse("account_login"))
+ # The original destination survives the hop (percent-encoded).
+ self.assertEqual(parse_qs(redirect.query)["next"], ["/admin/"])
+
+ 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_the_input_comes_after_the_boxes(self):
+ # daisyUI places each box with nth-child, which counts every child. With the input
+ # first, all six boxes shift a stride right, the container grows to seven strides
+ # and the ::after focus marker appears as a phantom seventh box.
+ html = self.response.content.decode()
+ otp = html[html.index('class="otp otp-lg"') : html.index('name="code"')]
+
+ self.assertEqual(otp.count(""), 6)
+
+ def test_the_boxes_are_wrapped_in_a_label_so_tapping_focuses_the_input(self):
+ # daisyUI's overlaid otp input carries `pointer-events: none` (so clicks land on the
+ # boxes, not a naked input) — which also means a tap on the boxes never reaches the
+ # input directly. A