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

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>