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:
parent
f2f2a9c96b
commit
edacb86800
7 changed files with 138 additions and 1 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
31
templates/artifacts/settings_users.html
Normal file
31
templates/artifacts/settings_users.html
Normal 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 %} — admin{% endif %}
|
||||||
|
{% if not user.is_active %} — disabled{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
0
tests/views/__init__.py
Normal file
0
tests/views/__init__.py
Normal file
66
tests/views/test_settings_users.py
Normal file
66
tests/views/test_settings_users.py
Normal 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")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue