Add admin-only Settings -> Users page for creating accounts

Peter needed a second user for throwaway testing without going through
Django admin each time. Superusers now get a "Users" tab in Settings
(hidden for regular users) with a create-user form and a list of existing
accounts. New users are regular (non-staff, non-superuser) by default, with
an optional "Grant admin access" checkbox; creating one immediately issues
their API token and displays it once, since that's the credential an agent
account needs and a human account can otherwise regenerate for themselves
later from their own Settings -> Account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 12:33:38 +01:00
parent f2f2a9c96b
commit edacb86800
7 changed files with 138 additions and 1 deletions

View file

@ -12,7 +12,7 @@ The core product principle is: agents publish content, not files. The server own
- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself.
- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either.
- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions.
- User accounts are created/managed via Django admin (superusers only — regular users are not staff and cannot access `/admin/`).
- User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once.
- `TagColor` (label color overrides) stays global/shared across all users.
- Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work.

View file

@ -16,6 +16,7 @@ urlpatterns = [
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"),
path("settings/users/", views.settings_users, name="settings-users"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"),
]

View file

@ -13,6 +13,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.db.models import Count, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
@ -842,6 +843,41 @@ def settings_account(request):
)
@login_required
def settings_users(request):
if not request.user.is_superuser:
return HttpResponseForbidden("Admin access required")
created_user = None
created_token = None
if request.method == "POST":
username = request.POST.get("username", "").strip()
password = request.POST.get("password", "")
grant_admin = request.POST.get("is_superuser") == "on"
if not username or not password:
messages.error(request, "Username and password are required.")
elif User.objects.filter(username=username).exists():
messages.error(request, f"A user named '{username}' already exists.")
else:
created_user = User.objects.create_user(username=username, password=password)
if grant_admin:
created_user.is_superuser = True
created_user.save(update_fields=["is_superuser"])
created_token = Token.objects.create(user=created_user)
messages.success(request, f"Created user '{username}'.")
return render(
request,
"artifacts/settings_users.html",
{
"active_section": "users",
"users": User.objects.order_by("username"),
"created_user": created_user,
"created_token": created_token,
},
)
@login_required
def settings_demo_data(request):
if request.method == "POST":

View file

@ -15,6 +15,9 @@
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
{% if request.user.is_superuser %}
<a class="{% if active_section == 'users' %}selected{% endif %}" href="{% url 'settings-users' %}">Users</a>
{% endif %}
</nav>
<section class="settings-panel">
{% if messages %}

View file

@ -0,0 +1,31 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Users</h2>
{% if created_token %}
<p><strong>API token for {{ created_user.username }}</strong> — shown once, copy it now:</p>
<p><code class="token-value">{{ created_token.key }}</code></p>
{% endif %}
<h3>Add User</h3>
<p>New users are regular accounts by default — they can log in and use their own token, but cannot reach Django admin unless you grant admin access below.</p>
<form method="post">
{% csrf_token %}
<p><label>Username<br><input type="text" name="username" required autocomplete="off"></label></p>
<p><label>Password<br><input type="password" name="password" required autocomplete="new-password"></label></p>
<p><label><input type="checkbox" name="is_superuser"> Grant admin access</label></p>
<button type="submit">Create User</button>
</form>
<h3>Existing Users</h3>
<ul class="storage-facts">
{% for user in users %}
<li>
{{ user.username }}{% if user == request.user %} (you){% endif %}
{% if user.is_superuser %} &mdash; admin{% endif %}
{% if not user.is_active %} &mdash; disabled{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}

0
tests/views/__init__.py Normal file
View file

View file

@ -0,0 +1,66 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from rest_framework.authtoken.models import Token
User = get_user_model()
class SettingsUsersViewTestCase(TestCase):
def setUp(self):
self.admin = User.objects.create_superuser(username="admin", password="pw", email="")
self.regular = User.objects.create_user(username="regular", password="pw")
def test_requires_login(self):
response = self.client.get(reverse("settings-users"))
self.assertEqual(response.status_code, 302)
def test_non_superuser_forbidden(self):
self.client.force_login(self.regular)
response = self.client.get(reverse("settings-users"))
self.assertEqual(response.status_code, 403)
def test_superuser_can_view(self):
self.client.force_login(self.admin)
response = self.client.get(reverse("settings-users"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "admin")
self.assertContains(response, "regular")
def test_superuser_creates_regular_user_with_token(self):
self.client.force_login(self.admin)
response = self.client.post(
reverse("settings-users"), {"username": "newtester", "password": "hunter2pw"}
)
self.assertEqual(response.status_code, 200)
created = User.objects.get(username="newtester")
self.assertTrue(created.check_password("hunter2pw"))
self.assertFalse(created.is_superuser)
self.assertFalse(created.is_staff)
self.assertTrue(Token.objects.filter(user=created).exists())
self.assertContains(response, Token.objects.get(user=created).key)
def test_superuser_can_grant_admin(self):
self.client.force_login(self.admin)
self.client.post(
reverse("settings-users"),
{"username": "newadmin", "password": "hunter2pw", "is_superuser": "on"},
)
created = User.objects.get(username="newadmin")
self.assertTrue(created.is_superuser)
def test_duplicate_username_rejected(self):
self.client.force_login(self.admin)
response = self.client.post(
reverse("settings-users"), {"username": "regular", "password": "hunter2pw"}
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "already exists")
self.assertEqual(User.objects.filter(username="regular").count(), 1)
def test_missing_fields_rejected(self):
self.client.force_login(self.admin)
response = self.client.post(reverse("settings-users"), {"username": "", "password": ""})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "required")