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:
23
club/context_processors.py
Normal file
23
club/context_processors.py
Normal 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,
|
||||
}
|
||||
25
club/migrations/0012_club_logo_club_primary_color.py
Normal file
25
club/migrations/0012_club_logo_club_primary_color.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
|
||||
18
club/templates/club/home.html
Normal file
18
club/templates/club/home.html
Normal 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 %}
|
||||
110
club/tests.py
110
club/tests.py
@@ -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 & 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
23
club/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user