Brand the auth screens per tenant

A club member signing in at ajax-united.rosterchief.app now sees their club's
logo, name and colours; the base domain keeps the RosterChief skin for the
control panel and Django admin.

The mechanism is `{% extends base_template %}` -- Django lets the parent be a
context variable, so the `branding` context processor picks the skin from
request.club and *every* auth screen allauth ships (login, password reset, MFA,
passkeys, and whatever it adds next) follows the tenant without a single one of
them knowing that clubs exist.

Templates split three ways: _base.html is the skeleton with no branding, and
_platform_base.html / _club_base.html dress it. The control panel extends the
platform base *explicitly* rather than through the variable, so a bug in
branding resolution can never dress the panel up as a club.

Club gains an optional logo and primary_color. Notes on both:

- No logo falls back to the club's initials, never the RosterChief mark, which
  would pass our branding off as theirs.
- Club colours land in an inline :root. daisyUI declares its theme variables
  inside `@layer base`, and unlayered styles beat every layered rule regardless
  of specificity, so this needs no !important. --color-primary-content is derived
  from WCAG relative luminance, so a club that picks pale yellow gets black text
  instead of invisible white.
- primary_color is a text input, not <input type="color">: a colour picker cannot
  express "no colour", so every club that never touched it would submit #000000
  and silently get a black theme.

"/" now resolves per tenant (club home, or hand off to the control panel), which
is why LOGIN_REDIRECT_URL can stay "/" and allauth needs no redirect adapter.

Also folds in the theme toggle gaining a third "auto" state and the logo
switching from `content:` to background-image (content-replacement on a real
element is not supported in Firefox), both of which lived in the base template
this commit replaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 22:30:38 +02:00
parent fc51e1903c
commit 1a2bf257da
20 changed files with 487 additions and 161 deletions

View File

@@ -0,0 +1,23 @@
"""Tenant-aware page branding.
Every page inherits its chrome from ``base_template``. On a club subdomain that
resolves to the club-branded skin, on the base domain to the RosterChief one, so
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
now or later) follow the tenant without a single template of their own knowing
that clubs exist.
The control panel deliberately does *not* use this: it hardcodes the platform
base, so no branding bug can ever dress the platform panel up as a club.
"""
PLATFORM_BASE_TEMPLATE = "_platform_base.html"
CLUB_BASE_TEMPLATE = "_club_base.html"
def branding(request):
club = getattr(request, "club", None) # set by ClubTenantMiddleware
return {
"club": club,
"base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
}

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.6 on 2026-07-13 17:33
import club.models
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0011_alter_club_slug'),
]
operations = [
migrations.AddField(
model_name='club',
name='logo',
field=models.ImageField(blank=True, help_text="Shown on the club's own pages. Without one, the club's initials are used.", upload_to=club.models.club_logo_path, verbose_name='logo'),
),
migrations.AddField(
model_name='club',
name='primary_color',
field=models.CharField(blank=True, help_text="Hex colour for buttons and links on the club's pages, e.g. #1e40af.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #1e40af.')], verbose_name='primary colour'),
),
]

View File

@@ -1,5 +1,6 @@
import datetime
from django.core.validators import RegexValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -22,10 +23,23 @@ class ClubManager(models.Manager):
return self.filter(archived_at__isnull=False)
def club_logo_path(instance: Club, filename: str) -> str:
return f"clubs/{instance.slug}/{filename}"
class Club(UUIDModel):
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
logo = models.ImageField(_("logo"), upload_to=club_logo_path, blank=True, help_text=_("Shown on the club's own pages. Without one, the club's initials are used."))
primary_color = models.CharField(
_("primary colour"),
max_length=7,
blank=True,
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #1e40af."))],
help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."),
)
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
objects = ClubManager()
@@ -47,6 +61,31 @@ class Club(UUIDModel):
def is_archived(self) -> bool:
return self.archived_at is not None
@property
def initials(self) -> str:
"""Stand-in for a missing logo. Never the RosterChief mark — that would
pass our branding off as the club's own."""
return "".join(word[0] for word in self.name.split()[:2]).upper()
@property
def primary_content_color(self) -> str:
"""Readable text colour to sit *on* ``primary_color``.
A club picking a pale yellow would otherwise get white-on-yellow buttons.
Relative luminance per WCAG, with its 0.179 threshold for black vs white.
"""
if not self.primary_color:
return ""
def channel(value: int) -> float:
fraction = value / 255
return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4
red, green, blue = (channel(int(self.primary_color[index : index + 2], 16)) for index in (1, 3, 5))
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return "#000000" if luminance > 0.179 else "#ffffff"
def archive(self):
"""Soft-delete: the club stops resolving, but nothing is destroyed.

View File

@@ -0,0 +1,18 @@
{% extends "_club_base.html" %}
{% load lucide %}
{% block head_title %}Home{% endblock head_title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body">
<h1 class="card-title">{% lucide "party-popper" size=20 %} Welcome to {{ club.name }}</h1>
<p>
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
</p>
<p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p>
</div>
</div>
</div>
{% endblock main %}

View File

@@ -16,7 +16,7 @@ from events.models import Event
from members.models import Family, FamilyMembership, Member
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .models import Club, ClubMembership, ClubRole, Season
from .models import Club, ClubMembership, ClubRole, Season, club_logo_path
from .services.access import (
COACH_MANAGER,
can_edit_event,
@@ -960,3 +960,111 @@ class ClubRoleStatusSyncTests(TestCase):
self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club))
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
self.assertTrue(can_manage_shop(user, self.club))
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
)
class BrandingTests(TestCase):
"""The auth screens are shared; only the skin they inherit differs per tenant."""
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def login_page(self, host):
return self.client.get(reverse("account_login"), HTTP_HOST=host)
def test_the_base_domain_gets_the_platform_skin(self):
response = self.login_page("rosterchief.app")
self.assertTemplateUsed(response, "_platform_base.html")
self.assertTemplateNotUsed(response, "_club_base.html")
self.assertContains(response, "Club &amp; Team Management")
self.assertIsNone(response.context["club"])
def test_a_club_subdomain_gets_the_club_skin(self):
response = self.login_page("ajax-united.rosterchief.app")
self.assertTemplateUsed(response, "_club_base.html")
self.assertTemplateNotUsed(response, "_platform_base.html")
self.assertContains(response, "Ajax United")
self.assertEqual(response.context["club"], self.club)
def test_an_archived_club_falls_back_to_the_platform_skin(self):
# The subdomain stops resolving, so there is no club to brand with.
self.club.archive()
self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "_platform_base.html")
def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self):
response = self.login_page("ajax-united.rosterchief.app")
self.assertContains(response, "AU")
self.assertNotContains(response, "rosterchief-dark.svg")
def test_a_club_logo_is_rendered_when_set(self):
self.club.logo = "clubs/ajax-united/crest.png"
self.club.save()
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "clubs/ajax-united/crest.png")
def test_a_club_colour_overrides_the_theme(self):
self.club.primary_color = "#1e40af"
self.club.save()
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary: #1e40af")
def test_no_colour_means_no_override(self):
self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary")
class ClubBrandingModelTests(TestCase):
def test_initials_use_the_first_two_words(self):
self.assertEqual(Club(name="Ajax United Football Club").initials, "AU")
self.assertEqual(Club(name="Ajax").initials, "A")
def test_text_on_a_pale_colour_is_black_and_on_a_dark_one_white(self):
# A club picking pale yellow must not get white-on-yellow buttons.
self.assertEqual(Club(primary_color="#fef08a").primary_content_color, "#000000")
self.assertEqual(Club(primary_color="#1e40af").primary_content_color, "#ffffff")
def test_no_colour_means_no_contrast_colour(self):
self.assertEqual(Club(primary_color="").primary_content_color, "")
def test_a_colour_must_be_a_hex_value(self):
club = Club(name="Ajax United", primary_color="blue")
with self.assertRaises(ValidationError):
club.full_clean()
def test_logos_are_stored_per_club(self):
self.assertEqual(club_logo_path(Club(slug="ajax-united"), "crest.png"), "clubs/ajax-united/crest.png")
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
)
class RootViewTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.user = get_user_model().objects.create_user(email="member@example.com", password="pw-secret-123")
def test_the_base_domain_hands_off_to_the_control_panel(self):
response = self.client.get("/", HTTP_HOST="rosterchief.app")
self.assertRedirects(response, reverse("controlpanel:dashboard"), fetch_redirect_response=False)
def test_a_club_subdomain_lands_on_the_club_home(self):
self.client.force_login(self.user)
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertTemplateUsed(response, "club/home.html")
self.assertContains(response, "Ajax United")
def test_the_club_home_requires_a_login(self):
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False)

23
club/views.py Normal file
View File

@@ -0,0 +1,23 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.views.generic import TemplateView
class ClubHomeView(LoginRequiredMixin, TemplateView):
"""Placeholder landing page for a club subdomain — where members land after
signing in, until the club-facing site is built."""
template_name = "club/home.html"
def root(request):
"""``/`` means different things per tenant.
This is why allauth needs no login-redirect adapter: LOGIN_REDIRECT_URL is "/",
and "/" resolves itself — a club subdomain lands on the club, the base domain
hands off to the platform control panel.
"""
if request.club is None:
return redirect("controlpanel:dashboard")
return ClubHomeView.as_view()(request)

View File

@@ -10,8 +10,12 @@ from .services.admins import find_member_by_email
class ClubForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name", "slug"]
fields = ["name", "slug", "logo", "primary_color"]
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
# Deliberately a text input, not <input type="color">: a colour picker cannot
# express "no colour" -- it would submit #000000 for every club that never
# touched it, and every club would silently get a black theme.
widgets = {"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"})}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -1,4 +1,6 @@
{% extends "base.html" %}
{# Hardcoded, not `base_template`: the panel is platform-only, and a branding bug
must never be able to dress it up as a club. #}
{% extends "_platform_base.html" %}
{% load lucide %}
{% block title %}

View File

@@ -6,7 +6,7 @@
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<form method="post">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">

View File

@@ -8,6 +8,7 @@ register = template.Library()
#: what makes every allauth and control-panel form field look right.
WIDGET_CLASSES = (
(forms.CheckboxInput, "checkbox"),
(forms.FileInput, "file-input file-input-bordered w-full"),
(forms.RadioSelect, "radio"),
(forms.Select, "select select-bordered w-full"),
(forms.Textarea, "textarea textarea-bordered w-full"),

View File

@@ -156,6 +156,7 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"club.context_processors.branding",
],
},
},

View File

@@ -8,16 +8,24 @@ enrolled.
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
from club.views import root
urlpatterns = [
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("controlpanel/", include("controlpanel.urls")),
# "/" resolves per tenant: a club subdomain lands on the club, the base domain
# hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/".
path("", root, name="root"),
]
if settings.DEBUG:
urlpatterns += [path("__reload__/", include("django_browser_reload.urls"))]
# Club logos are uploads: runserver has to serve MEDIA_ROOT itself.
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

File diff suppressed because one or more lines are too long

View File

@@ -166,10 +166,29 @@
--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 {
content: var(--logo-dark);
background-image: var(--logo-dark);
}
[data-theme="dark"] .logo {
content: var(--logo-light);
background-image: var(--logo-light);
}

119
templates/_base.html Normal file
View File

@@ -0,0 +1,119 @@
{% load lucide static %}
{% comment %}
The page skeleton, with no branding of its own. `_platform_base.html` dresses it
as RosterChief, `_club_base.html` as a club; the `branding` context processor
picks between them per tenant.
{% endcomment %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block title %}RosterChief{% endblock title %}
</title>
{% comment %}
Apply the stored theme before first paint, otherwise the page flashes the wrong
colours. Nothing stored means "auto": we set no attribute at all, so daisyUI's
`dark --prefersdark` follows the OS.
{% endcomment %}
<script>
(() => {
const stored = localStorage.getItem("theme");
if (stored) document.documentElement.setAttribute("data-theme", stored);
})();
</script>
<link rel="stylesheet" href="{% static 'css/app.css' %}"/>
{# After the stylesheet: brand overrides (logo urls, club colours) must win. #}
{% block extra %}{% endblock extra %}
</head>
<body class="min-h-screen bg-base-200">
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm">
<div class="my-4 flex-1">
{% block brand %}{% endblock brand %}
</div>
<button class="btn btn-ghost w-24" type="button" data-theme-toggle aria-label="Theme">
<span data-theme-icon="light" class="hidden items-center gap-4">{% lucide "sun" size=20 %} light</span>
<span data-theme-icon="dark" class="hidden items-center gap-4">{% lucide "moon" size=20 %} dark</span>
<span data-theme-icon="auto" class="hidden items-center gap-4">{% lucide "sun-moon" size=20 %} auto</span>
</button>
{% if user.is_authenticated %}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost gap-4">{% lucide "circle-user" %}{{ user.get_full_name }}</div>
<ul tabindex="0" class="menu dropdown-content z-10 mt-2 w-60 rounded-box bg-base-100 p-2 shadow">
<li>
<a href="{% url 'mfa_index' %}">{% lucide "shield-check" size=16 %} Two-factor authentication</a>
</li>
<li>
<a href="{% url 'account_change_password' %}">{% lucide "key-round" size=16 %} Change password</a>
</li>
<li>
<a href="{% url 'account_logout' %}">{% lucide "log-out" size=16 %} Sign out</a>
</li>
</ul>
</div>
{% else %}
<a class="btn btn-ghost gap-4" href="{% url 'account_login' %}">{% lucide "log-in" size=16 %} Sign in</a>
{% endif %}
</div>
{% if messages %}
<div class="mx-auto mt-4 w-full space-y-2 px-4">
{% for message in messages %}
<div class="alert {% if message.tags == 'error' %}alert-error{% elif message.tags == 'warning' %}alert-warning{% elif message.tags == 'success' %}alert-success{% else %}alert-info{% endif %}">
<span>{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<main class="mx-auto w-full p-4">
{% block main %}{% endblock main %}
</main>
<script>
// The button cycles light -> dark -> auto. "auto" removes the attribute and the
// stored key rather than writing the OS's current choice: that keeps daisyUI's
// `dark --prefersdark` following the OS *live*, so the page flips when the OS
// does. Storing a snapshot would freeze it at whatever the OS was on click.
const MODES = ["light", "dark", "auto"];
const currentMode = () => localStorage.getItem("theme") || "auto";
const applyMode = (mode) => {
if (mode === "auto") {
localStorage.removeItem("theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("theme", mode);
document.documentElement.setAttribute("data-theme", mode);
}
// Show the label for the *chosen* mode, not the resulting colours -- otherwise
// "auto" would be indistinguishable from whichever theme it resolved to.
// `hidden` and `inline-flex` are both display utilities, so the visible one
// must carry exactly one of them: leaving both on would let stylesheet order,
// not class order, decide who wins.
document.querySelectorAll("[data-theme-icon]").forEach((label) => {
const active = label.dataset.themeIcon === mode;
label.classList.toggle("hidden", !active);
label.classList.toggle("inline-flex", active);
});
document.querySelectorAll("[data-theme-toggle]").forEach((button) => button.setAttribute("aria-label", `Theme: ${mode}`));
};
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => applyMode(MODES[(MODES.indexOf(currentMode()) + 1) % MODES.length]));
});
applyMode(currentMode());
</script>
</body>
</html>

46
templates/_club_base.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "_base.html" %}
{% comment %}
A club's skin, used on its subdomain. `club` comes from the branding context
processor, which reads the tenant resolved by ClubTenantMiddleware.
{% endcomment %}
{% block title %}
{% block head_title %}{% endblock head_title %} · {{ club.name }}
{% endblock title %}
{% block extra %}
{% if club.primary_color %}
{% comment %}
daisyUI declares its theme variables inside `@layer base`, and unlayered styles
beat every layered rule regardless of specificity -- so this plain :root wins
without any !important or selector games. primary_content_color is computed from
the club's colour so a pale brand doesn't end up with white-on-yellow buttons.
{% endcomment %}
<style>
:root {
--color-primary: {{ club.primary_color }};
--color-primary-content: {{ club.primary_content_color }};
}
</style>
{% endif %}
{% endblock extra %}
{% block brand %}
<a class="flex flex-row items-center gap-3" href="/">
{% if club.logo %}
<img class="h-16 w-16 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
{% else %}
{# Never the RosterChief mark: that would pass our branding off as the club's own. #}
<div class="avatar avatar-placeholder">
<div class="w-16 rounded-full bg-primary text-primary-content">
<span class="font-roboto text-xl font-bold">{{ club.initials }}</span>
</div>
</div>
{% endif %}
<div class="flex flex-col gap-1">
<div class="font-roboto text-2xl font-bold tracking-wider">{{ club.name }}</div>
<div class="font-mono text-xs font-semibold text-base-content/50">Powered by RosterChief</div>
</div>
</a>
{% endblock brand %}

View File

@@ -1,33 +0,0 @@
{% load static lucide %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RosterChief - Control Panel</title>
<script>
(() => {
const stored = localStorage.getItem('theme');
if (stored) document.documentElement.setAttribute("data-theme", stored);
})();
</script>
<style>
:root {
--logo-light: url("{% static "images/rosterchief-white.svg" %}");
--logo-dark: url("{% static "images/rosterchief-black.svg" %}");
}
</style>
<link rel="stylesheet" href="{% static 'css/app.css' %}"/>
{% block extra %}{% endblock extra %}
</head>
<body class="min-h-screen bg-base-200">
{% block main %}{% endblock main %}
</body>
</html>

View File

@@ -0,0 +1,35 @@
{% extends "_base.html" %}
{% load static %}
{% comment %}
RosterChief's own skin: the platform control panel, Django-admin-adjacent pages,
and every auth screen served on the base domain.
{% endcomment %}
{% block title %}
{% block head_title %}{% endblock head_title %} · RosterChief
{% endblock title %}
{% block extra %}
{% comment %}
Named after the logo's own ink, not the theme it is used on: the white logo goes
on dark backgrounds and vice versa. They live here rather than in app.css because
only Django knows the {% static %} URL.
{% endcomment %}
<style>
:root {
--logo-light: url("{% static 'images/rosterchief-white.svg' %}");
--logo-dark: url("{% static 'images/rosterchief-dark.svg' %}");
}
</style>
{% endblock extra %}
{% block brand %}
<a class="flex flex-row items-center gap-2" href="/">
<span class="logo inline-block h-16 w-16" role="img" aria-label="RosterChief"></span>
<div class="flex flex-col gap-1">
<div class="font-roboto text-2xl font-bold tracking-wider">Roster<span class="text-sky-500">Chief</span></div>
<div class="font-mono text-xs font-semibold text-base-content/50">Club &amp; Team Management</div>
</div>
</a>
{% endblock brand %}

View File

@@ -1,8 +1,13 @@
{% extends "_controlpanel_base.html" %}
{% extends base_template %}
{% block title %}
{% block head_title %}{% endblock head_title %} · RosterChief
{% endblock title %}
{% comment %}
Not a fixed parent: `base_template` is resolved per request by the branding context
processor, so every auth screen allauth ships -- login, password reset, MFA, passkeys,
and whatever it adds next -- picks up the club's skin on a club subdomain and the
RosterChief one on the base domain, with no template of its own knowing clubs exist.
The title is left to the chosen base, which suffixes the club or the platform name.
{% endcomment %}
{% block main %}
<div class="flex justify-center">

View File

@@ -1,117 +0,0 @@
{% load lucide static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block title %}RosterChief{% endblock title %}
</title>
{% comment %}
Apply the stored theme before first paint, otherwise the page flashes the wrong
colours. With no stored preference we set nothing, so daisyUI's `dark --prefersdark`
follows the OS.
{% endcomment %}
<script>
(() => {
const stored = localStorage.getItem("theme");
if (stored) document.documentElement.setAttribute("data-theme", stored);
})();
</script>
<link rel="stylesheet" href="{% static 'css/app.css' %}">
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="min-h-screen bg-base-200">
<div class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a class="btn btn-ghost gap-2 text-xl" href="/">
{% lucide "clipboard-list" size=22 %}
RosterChief
</a>
{% if user.is_authenticated and user.is_staff %}
<a class="btn btn-ghost btn-sm gap-2" href="{% url 'controlpanel:dashboard' %}">
{% lucide "sliders-horizontal" size=16 %}
Control panel
</a>
{% endif %}
</div>
<div class="flex-none gap-2">
{# Both icons are rendered; JS shows the one matching the effective theme. #}
<button class="btn btn-ghost btn-circle"
aria-label="Toggle theme"
data-theme-toggle
type="button">
<span data-theme-icon="light">{% lucide "sun" size=20 %}</span>
<span data-theme-icon="dark" class="hidden">{% lucide "moon" size=20 %}</span>
</button>
{% if user.is_authenticated %}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">{{ user }}</div>
<ul tabindex="0"
class="menu dropdown-content z-10 mt-2 w-56 rounded-box bg-base-100 p-2 shadow">
<li>
<a href="{% url 'mfa_index' %}">
{% lucide "shield-check" size=16 %}
Two-factor authentication
</a>
</li>
<li>
<a href="{% url 'account_change_password' %}">
{% lucide "key-round" size=16 %}
Change password
</a>
</li>
<li>
<a href="{% url 'account_logout' %}">
{% lucide "log-out" size=16 %}
Sign out
</a>
</li>
</ul>
</div>
{% else %}
<a class="btn btn-primary btn-sm gap-2" href="{% url 'account_login' %}">
{% lucide "log-in" size=16 %}
Sign in
</a>
{% endif %}
</div>
</div>
{% if messages %}
<div class="mx-auto mt-4 w-full max-w-5xl space-y-2 px-4">
{% for message in messages %}
<div class="alert {% if message.tags == 'error' %}alert-error{% elif message.tags == 'warning' %}alert-warning{% elif message.tags == 'success' %}alert-success{% else %}alert-info{% endif %}">
<span>{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<main class="mx-auto w-full max-w-5xl p-4">
{% block main %}{% endblock main %}
</main>
<script>
// No stored preference means "follow the OS", so the effective theme has to
// be read from the OS whenever nothing is set.
const effectiveTheme = () =>
document.documentElement.getAttribute("data-theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const showThemeIcon = () => {
const dark = effectiveTheme() === "dark";
document.querySelectorAll('[data-theme-icon="dark"]').forEach((i) => i.classList.toggle("hidden", !dark));
document.querySelectorAll('[data-theme-icon="light"]').forEach((i) => i.classList.toggle("hidden", dark));
};
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const next = effectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
showThemeIcon();
});
});
showThemeIcon();
</script>
</body>
</html>