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)