From edacb868004b1973f616cbc0260203bf950b6885 Mon Sep 17 00:00:00 2001 From: peter Date: Wed, 2 Sep 2026 12:33:38 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- artifacts/urls.py | 1 + artifacts/views.py | 36 ++++++++++++++ templates/artifacts/settings_base.html | 3 ++ templates/artifacts/settings_users.html | 31 ++++++++++++ tests/views/__init__.py | 0 tests/views/test_settings_users.py | 66 +++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 templates/artifacts/settings_users.html create mode 100644 tests/views/__init__.py create mode 100644 tests/views/test_settings_users.py diff --git a/CLAUDE.md b/CLAUDE.md index 5f6f9c5..4c73bda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/artifacts/urls.py b/artifacts/urls.py index a3d1f65..875c73d 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ path("api/artifacts/", 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"), ] diff --git a/artifacts/views.py b/artifacts/views.py index 758b2c1..9ee640c 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -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": diff --git a/templates/artifacts/settings_base.html b/templates/artifacts/settings_base.html index 3b205b6..e02062e 100644 --- a/templates/artifacts/settings_base.html +++ b/templates/artifacts/settings_base.html @@ -15,6 +15,9 @@ Account Storage Demo Data + {% if request.user.is_superuser %} + Users + {% endif %}
{% if messages %} diff --git a/templates/artifacts/settings_users.html b/templates/artifacts/settings_users.html new file mode 100644 index 0000000..880f972 --- /dev/null +++ b/templates/artifacts/settings_users.html @@ -0,0 +1,31 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Users

+ + {% if created_token %} +

API token for {{ created_user.username }} — shown once, copy it now:

+

{{ created_token.key }}

+ {% endif %} + +

Add User

+

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.

+
+ {% csrf_token %} +

+

+

+ +
+ +

Existing Users

+
    + {% for user in users %} +
  • + {{ user.username }}{% if user == request.user %} (you){% endif %} + {% if user.is_superuser %} — admin{% endif %} + {% if not user.is_active %} — disabled{% endif %} +
  • + {% endfor %} +
+{% endblock %} diff --git a/tests/views/__init__.py b/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/views/test_settings_users.py b/tests/views/test_settings_users.py new file mode 100644 index 0000000..3e160ae --- /dev/null +++ b/tests/views/test_settings_users.py @@ -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")