Manage platform admins and feature flags from the control panel
Features tab: create/edit flags, flip global switches, and toggle a flag per club from the club detail page. Where `everyone` is set the per-club toggle is replaced by a badge, because a toggle there would have no effect and so would lie about what is on. Admins tab: grant, promote, demote and revoke platform access. Gated on is_superuser, not is_staff -- the panel itself is staff-accessible, so letting staff grant is_superuser would collapse the two levels into one and stop is_superuser being a boundary we can later hang anything on. Two guardrails, enforced in the service so they hold regardless of caller: you cannot strip your own access (you would lose the panel mid-click), and the last superuser can never be demoted (the platform would be locked out of itself). Granted users get an unusable password and must enrol 2FA before they can sign in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
39
controlpanel/templates/controlpanel/admin_form.html
Normal file
39
controlpanel/templates/controlpanel/admin_form.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% block heading %}Grant platform access{% endblock heading %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<span>Platform admins can manage every club. They must 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">
|
||||
{% if field.field.widget.input_type == "checkbox" %}
|
||||
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
|
||||
{{ field|daisy }}
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="label" for="{{ field.id_for_label }}"><span class="label-text">{{ field.label }}</span></label>
|
||||
{{ field|daisy }}
|
||||
{% endif %}
|
||||
{% 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:admins' %}">Cancel</a>
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "user-plus" size=16 %} Grant access</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
73
controlpanel/templates/controlpanel/admins.html
Normal file
73
controlpanel/templates/controlpanel/admins.html
Normal file
@@ -0,0 +1,73 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
|
||||
{% block heading %}Platform admins{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
<p class="text-sm opacity-70">Staff run the panel. Superusers additionally manage this list.</p>
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:admin_add' %}">{% lucide "user-plus" size=16 %} Grant access</a>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Staff</th>
|
||||
<th>Superuser</th>
|
||||
<th>Last login</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for admin in admins %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium">{{ admin.email }}</div>
|
||||
{% if admin.pk == user.pk %}<div class="text-xs opacity-60">That's you</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
|
||||
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
|
||||
<button class="btn btn-xs gap-1 {% if admin.is_staff %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if admin.is_staff %}{% lucide "check" size=14 %} Yes{% else %}No{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
|
||||
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
|
||||
<button class="btn btn-xs gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}No{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{% url 'controlpanel:admin_revoke' admin.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "user-minus" size=14 %} Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">No platform admins.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
@@ -20,6 +20,10 @@
|
||||
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
|
||||
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
|
||||
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a>
|
||||
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
|
||||
{% if user.is_superuser %}
|
||||
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% block panel %}{% endblock panel %}
|
||||
{% endblock main %}
|
||||
|
||||
@@ -50,6 +50,45 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
|
||||
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage features</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{% for entry in flags %}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
|
||||
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
{% if entry.overridden %}
|
||||
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
|
||||
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
|
||||
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-center opacity-60">No features defined yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
87
controlpanel/templates/controlpanel/features.html
Normal file
87
controlpanel/templates/controlpanel/features.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
|
||||
{% block heading %}Features{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:flag_create' %}">{% lucide "plus" size=16 %} New feature</a>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card mb-6 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2>
|
||||
<p class="text-sm opacity-70">
|
||||
Flags are turned on per club. Setting <em>Everyone</em> to Yes or No overrides club targeting entirely.
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Everyone</th>
|
||||
<th>Clubs</th>
|
||||
<th>Note</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for flag in flags %}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{ flag.name }}</td>
|
||||
<td>
|
||||
{% if flag.everyone is True %}
|
||||
<span class="badge badge-success">On for all</span>
|
||||
{% elif flag.everyone is False %}
|
||||
<span class="badge badge-error">Off everywhere</span>
|
||||
{% else %}
|
||||
<span class="badge badge-ghost">Per club</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ flag.clubs.count }}</td>
|
||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:flag_update' flag.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">No features yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "power" size=18 %} Switches</h2>
|
||||
<p class="text-sm opacity-70">Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{% for switch in switches %}
|
||||
<tr>
|
||||
<td class="font-mono font-medium">{{ switch.name }}</td>
|
||||
<td class="opacity-70">{{ switch.note|default:"—" }}</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm gap-1 {% if switch.active %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
|
||||
{% if switch.active %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-center opacity-60">No switches yet — add one in the Django admin.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
33
controlpanel/templates/controlpanel/flag_form.html
Normal file
33
controlpanel/templates/controlpanel/flag_form.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load ui %}
|
||||
|
||||
{% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% 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:features' %}">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
Reference in New Issue
Block a user