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. - 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. - `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. - 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. - `TagColor` (label color overrides) stays global/shared across all users.
- Use random 8-character artifact IDs. - Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work. - 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("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
path("settings/", views.settings_home, name="settings-home"), path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"), 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/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"), 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 import messages
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required 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.db.models import Count, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render 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 @login_required
def settings_demo_data(request): def settings_demo_data(request):
if request.method == "POST": 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 == '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 == '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> <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> </nav>
<section class="settings-panel"> <section class="settings-panel">
{% if messages %} {% 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")