feat(ui): platform control panel and styled auth screens

Add the `controlpanel` app: a platform-wide (not club-scoped) admin panel for
creating clubs, archiving/restoring them, managing club admins, and per-club
statistics (members, teams & staff, events, shop). Statistics are annotated in
one query so the club list cannot fan out into N+1, and are returned as stat
*groups* so growing the domain means adding one entry.

Two access rules, both enforced by PlatformStaffRequiredMixin:
- staff only (is_staff/is_superuser); anonymous are sent to login, signed-in
  non-staff get a 403. Staff already need a second factor, so the panel is
  2FA-protected for free.
- base domain only: the panel manages *all* clubs, so it 404s if the tenant
  middleware resolved a club from the subdomain.

Granting admin to an unknown email creates the account (unusable password —
they set one via password reset) and the Member behind it, since a ClubRole
hangs off a Member. A member who already holds a role is promoted in place,
because there is only one role per member per club.

UI is Tailwind + daisyUI. allauth ships an element system, so overriding
allauth/layouts/base.html plus ~13 element partials restyles *every* auth and
2FA screen at once — login, signup, password reset, the 2FA challenge, TOTP
enrolment, passkeys and recovery codes — rather than templating 20+ pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:29:24 +02:00
parent 848a8578de
commit 6f66df3ba4
41 changed files with 1130 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}
{% block panel_title %}Control panel{% endblock panel_title %} · ClubManager
{% endblock title %}
{% block main %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">
{% block heading %}Control panel{% endblock heading %}
</h1>
{% block subheading %}{% endblock subheading %}
</div>
<div class="flex gap-2">
{% block actions %}{% endblock actions %}
</div>
</div>
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab {% if nav == 'dashboard' %}tab-active{% endif %}">Dashboard</a>
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab {% if nav == 'clubs' %}tab-active{% endif %}">Clubs</a>
</div>
{% block panel %}{% endblock panel %}
{% endblock main %}

View File

@@ -0,0 +1,36 @@
{% extends "controlpanel/base.html" %}
{% load ui %}
{% block heading %}Add an admin to {{ club }}{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<div class="alert alert-info">
<span>A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in.</span>
</div>
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-ghost" href="{% url 'controlpanel:club_detail' club.pk %}">Cancel</a>
<button class="btn btn-primary" type="submit">Grant admin</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,89 @@
{% extends "controlpanel/base.html" %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}
<p class="text-sm opacity-70">
{{ club.slug }}
{% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %}
</p>
{% endblock subheading %}
{% block actions %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_update' club.pk %}">Edit</a>
{% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %}
<button class="btn btn-success" type="submit">Restore</button>
</form>
{% else %}
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
{% csrf_token %}
<button class="btn btn-warning" type="submit">Archive</button>
</form>
{% endif %}
{% endblock actions %}
{% block panel %}
{% if club.is_archived %}
<div class="alert alert-warning mb-6">
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
</div>
{% endif %}
<div class="mb-6 grid gap-4 md:grid-cols-2">
{% for group in groups %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums">{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
</div>
{% endfor %}
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">Club admins</h2>
<a class="btn btn-primary btn-sm" href="{% url 'controlpanel:club_admin_add' club.pk %}">Add admin</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in admins %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.member.user.email|default:"—" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-xs text-error" type="submit">Remove</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,33 @@
{% extends "controlpanel/base.html" %}
{% load ui %}
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,61 @@
{% extends "controlpanel/base.html" %}
{% block heading %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock heading %}
{% block actions %}
{% if show_archived %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}">Active clubs</a>
{% else %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}?archived=1">Archived</a>
{% endif %}
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">New club</a>
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4 flex gap-2">
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
<input type="search"
name="q"
value="{{ search }}"
placeholder="Search clubs…"
class="input input-bordered w-full max-w-xs">
<button class="btn" type="submit">Search</button>
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th>Members</th>
<th>Teams</th>
<th>Events</th>
<th>Admins</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}</div>
</td>
<td>{{ club.member_count }}</td>
<td>{{ club.team_count }}</td>
<td>{{ club.event_count }}</td>
<td>{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">
{% if show_archived %}No archived clubs.{% else %}No clubs yet.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,64 @@
{% extends "controlpanel/base.html" %}
{% block heading %}Platform overview{% endblock heading %}
{% block actions %}
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">New club</a>
{% endblock actions %}
{% block panel %}
<div class="stats mb-6 w-full bg-base-100 shadow">
<div class="stat">
<div class="stat-title">Active clubs</div>
<div class="stat-value">{{ totals.clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Archived</div>
<div class="stat-value">{{ totals.archived_clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Members</div>
<div class="stat-value">{{ totals.members }}</div>
</div>
<div class="stat">
<div class="stat-title">Club admins</div>
<div class="stat-value">{{ totals.admins }}</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Clubs</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th>Members</th>
<th>Teams</th>
<th>Events</th>
<th>Admins</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}</div>
</td>
<td>{{ club.member_count }}</td>
<td>{{ club.team_count }}</td>
<td>{{ club.event_count }}</td>
<td>{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No clubs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}