From fe1752ad94307763007e6166ce6c3e5e7aa26555 Mon Sep 17 00:00:00 2001 From: Enno Gotthold Date: Sat, 22 Aug 2026 10:23:01 +0200 Subject: [PATCH 1/7] feat(frontend): add superuser Token management UI Adds a superuser-only Token list + revoke view, replacing the only remaining reason to visit /admin/authtoken/tokenproxy/. Self-service token regeneration for one's own account already existed; this adds the "see/revoke anyone's token" admin-facing view. Part of the Django Admin removal effort (A1/5). --- .../templates/frontend/snippet.navlinks.html | 7 +- .../tokens/token_confirm_deletion.html | 24 ++++++ .../templates/frontend/tokens/tokens.html | 54 +++++++++++++ .../frontend/tests/admin/test_token_views.py | 79 +++++++++++++++++++ orthos2/frontend/urls.py | 6 ++ orthos2/frontend/views/__init__.py | 3 + orthos2/frontend/views/token.py | 42 ++++++++++ 7 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 orthos2/frontend/templates/frontend/tokens/token_confirm_deletion.html create mode 100644 orthos2/frontend/templates/frontend/tokens/tokens.html create mode 100644 orthos2/frontend/tests/admin/test_token_views.py create mode 100644 orthos2/frontend/views/token.py diff --git a/orthos2/frontend/templates/frontend/snippet.navlinks.html b/orthos2/frontend/templates/frontend/snippet.navlinks.html index 79983a73..1514a163 100644 --- a/orthos2/frontend/templates/frontend/snippet.navlinks.html +++ b/orthos2/frontend/templates/frontend/snippet.navlinks.html @@ -108,7 +108,7 @@ REST API {% if request.user.is_superuser %} - {% active_group request 'users' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} + {% active_group request 'users' 'tokens' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} + {% endif %} diff --git a/orthos2/frontend/templates/frontend/tokens/token_confirm_deletion.html b/orthos2/frontend/templates/frontend/tokens/token_confirm_deletion.html new file mode 100644 index 00000000..4451ad7e --- /dev/null +++ b/orthos2/frontend/templates/frontend/tokens/token_confirm_deletion.html @@ -0,0 +1,24 @@ +{% extends 'frontend/base.html' %} +{% load static %} +{% load tags %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} +

+ Are you sure you want to revoke the API token belonging to + "{{ object.user.username }}"? +

+ +
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/tokens/tokens.html b/orthos2/frontend/templates/frontend/tokens/tokens.html new file mode 100644 index 00000000..d1e73d45 --- /dev/null +++ b/orthos2/frontend/templates/frontend/tokens/tokens.html @@ -0,0 +1,54 @@ +{% extends 'frontend/base.html' %} +{% load static %} +{% load tags %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} + {% include 'frontend/snippet.paginator.html' %} + +
+ {% if page_obj %} + + + + + + + + + + + + {% for token in page_obj %} + + + + + + + {% endfor %} + +
OwnerKeyCreated 
+ {{ token.user.username }} + {{ token.key|slice:":8" }}…{{ token.created }} + + Revoke + +
+ + {% include 'frontend/snippet.paginator.html' %} + {% else %} +
No tokens found!
+ {% endif %} +
+{% endblock %} diff --git a/orthos2/frontend/tests/admin/test_token_views.py b/orthos2/frontend/tests/admin/test_token_views.py new file mode 100644 index 00000000..1b430597 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_token_views.py @@ -0,0 +1,79 @@ +"""Tests for the Token management frontend views.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from rest_framework.authtoken.models import Token + + +class TokenListViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.owner = User.objects.get(username="user") + self.token = Token.objects.create(user=self.owner) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:tokens") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(self.owner) + url = reverse("frontend:tokens") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_lists_tokens(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:tokens") + response = self.client.get(url) + assert response.status_code == 200 + assert b"user" in response.content + + def test_superuser_get_shows_truncated_key(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:tokens") + response = self.client.get(url) + assert (self.token.key[:8] + "…").encode() in response.content + + +class DeleteTokenViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.owner = User.objects.get(username="user") + self.token = Token.objects.create(user=self.owner) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(self.owner) + url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_confirmation_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_revokes_token(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + response = self.client.post(url) + assert response.status_code == 302 + assert not Token.objects.filter(pk=self.token.key).exists() + + def test_regular_user_post_does_not_revoke_token(self) -> None: + self.client.force_login(self.owner) + url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + response = self.client.post(url) + assert response.status_code == 403 + assert Token.objects.filter(pk=self.token.key).exists() diff --git a/orthos2/frontend/urls.py b/orthos2/frontend/urls.py index 116a309b..15ae5a83 100644 --- a/orthos2/frontend/urls.py +++ b/orthos2/frontend/urls.py @@ -273,6 +273,12 @@ re_path( r"^password/restore$", views.users_password_restore, name="password_restore" ), + path("tokens", views.TokenListView.as_view(), name="tokens"), + path( + "tokens/delete//", + views.DeleteToken.as_view(), + name="delete_token", + ), re_path(r"^statistics$", views.statistics, name="statistics"), re_path( r"^ajax/machine/(?P[0-9]+)/annotation/add", diff --git a/orthos2/frontend/views/__init__.py b/orthos2/frontend/views/__init__.py index 866df15e..33d6781f 100644 --- a/orthos2/frontend/views/__init__.py +++ b/orthos2/frontend/views/__init__.py @@ -178,6 +178,7 @@ SystemListView, system_detail, ) +from .token import DeleteToken, TokenListView from .user import users_create, users_password_restore, users_preferences from .users import UserListView, user_detail, user_reservations, user_reserve_machine @@ -261,6 +262,8 @@ "user_detail", "user_reservations", "user_reserve_machine", + "TokenListView", + "DeleteToken", "RemotePowerDevicesListView", "RemotePowerDeviceDetailedEdit", "NewRemotePowerDevice", diff --git a/orthos2/frontend/views/token.py b/orthos2/frontend/views/token.py new file mode 100644 index 00000000..38e35f4c --- /dev/null +++ b/orthos2/frontend/views/token.py @@ -0,0 +1,42 @@ +""" +All views that are under "/tokens". +""" + +from typing import Any, Dict + +from django.db.models import QuerySet +from django.urls import reverse_lazy +from django.views.generic import DeleteView, ListView +from rest_framework.authtoken.models import Token + +from orthos2.frontend.mixins import SuperuserRequiredMixin + + +class TokenListView(SuperuserRequiredMixin, ListView): # type: ignore + model = Token + template_name = "frontend/tokens/tokens.html" + paginate_by = 50 + + def get_queryset(self) -> "QuerySet[Token]": + return ( + super() + .get_queryset() + .select_related("user") # type: ignore[attr-defined] + .order_by("user__username") + ) + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Tokens" + return context + + +class DeleteToken(SuperuserRequiredMixin, DeleteView): # type: ignore + model = Token + template_name = "frontend/tokens/token_confirm_deletion.html" + success_url = reverse_lazy("frontend:tokens") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Revoke Token" + return context From 95e60dfeec20db05143dd4e6cce97011d9f62550 Mon Sep 17 00:00:00 2001 From: Enno Gotthold Date: Sat, 22 Aug 2026 10:29:15 +0200 Subject: [PATCH 2/7] feat(frontend): show full token key, add per-user Tokens tab Superusers auditing/revoking tokens need the full key, not a masked prefix. Also add a "Tokens" tab on the User detail page so a token can be found starting from the user rather than only from the global list. Key the revoke URL on the token's owning user (Token.user is a OneToOneField) instead of the token's own key, so the secret never ends up in a URL and thus browser history / access logs. --- .../templates/frontend/tokens/tokens.html | 4 +- .../frontend/users/detail/tokens.html | 39 +++++++++++++++ .../templates/frontend/users/user.html | 7 +++ .../frontend/tests/admin/test_token_views.py | 22 ++++++--- .../tests/admin/test_user_tokens_tab.py | 47 +++++++++++++++++++ orthos2/frontend/urls.py | 7 ++- orthos2/frontend/views/__init__.py | 9 +++- orthos2/frontend/views/token.py | 11 ++++- orthos2/frontend/views/users.py | 18 +++++++ 9 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 orthos2/frontend/templates/frontend/users/detail/tokens.html create mode 100644 orthos2/frontend/tests/admin/test_user_tokens_tab.py diff --git a/orthos2/frontend/templates/frontend/tokens/tokens.html b/orthos2/frontend/templates/frontend/tokens/tokens.html index d1e73d45..da900b03 100644 --- a/orthos2/frontend/templates/frontend/tokens/tokens.html +++ b/orthos2/frontend/templates/frontend/tokens/tokens.html @@ -31,11 +31,11 @@ >{{ token.user.username }} - {{ token.key|slice:":8" }}… + {{ token.key }} {{ token.created }} Revoke diff --git a/orthos2/frontend/templates/frontend/users/detail/tokens.html b/orthos2/frontend/templates/frontend/users/detail/tokens.html new file mode 100644 index 00000000..f148b4db --- /dev/null +++ b/orthos2/frontend/templates/frontend/users/detail/tokens.html @@ -0,0 +1,39 @@ +{% extends 'frontend/users/user.html' %} + +{% block tabcontent %} +
+
+
Tokens
+
+ + {% if tokens %} + + + + + + + + + + {% for token in tokens %} + + + + + + {% endfor %} + +
KeyCreated 
{{ token.key }}{{ token.created }} + + Revoke + +
+ {% else %} +
No tokens for this user.
+ {% endif %} +
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/users/user.html b/orthos2/frontend/templates/frontend/users/user.html index 4af2df3b..61fc3929 100644 --- a/orthos2/frontend/templates/frontend/users/user.html +++ b/orthos2/frontend/templates/frontend/users/user.html @@ -21,6 +21,13 @@ >Reservations
+
diff --git a/orthos2/frontend/tests/admin/test_token_views.py b/orthos2/frontend/tests/admin/test_token_views.py index 1b430597..179c17d7 100644 --- a/orthos2/frontend/tests/admin/test_token_views.py +++ b/orthos2/frontend/tests/admin/test_token_views.py @@ -32,11 +32,19 @@ def test_superuser_get_lists_tokens(self) -> None: assert response.status_code == 200 assert b"user" in response.content - def test_superuser_get_shows_truncated_key(self) -> None: + def test_superuser_get_shows_full_key(self) -> None: self.client.force_login(User.objects.get(username="superuser")) url = reverse("frontend:tokens") response = self.client.get(url) - assert (self.token.key[:8] + "…").encode() in response.content + assert self.token.key.encode() in response.content + + def test_revoke_link_does_not_embed_key_in_url(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:tokens") + response = self.client.get(url) + delete_url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) + assert delete_url.encode() in response.content + assert self.token.key not in delete_url class DeleteTokenViewTest(TestCase): @@ -47,33 +55,33 @@ def setUp(self) -> None: self.token = Token.objects.create(user=self.owner) def test_unauthenticated_get_redirects_to_login(self) -> None: - url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) response = self.client.get(url) assert response.status_code == 302 assert "login" in response.url.lower() # type: ignore[attr-defined] def test_regular_user_get_is_forbidden(self) -> None: self.client.force_login(self.owner) - url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) response = self.client.get(url) assert response.status_code == 403 def test_superuser_get_shows_confirmation_page(self) -> None: self.client.force_login(User.objects.get(username="superuser")) - url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) response = self.client.get(url) assert response.status_code == 200 def test_superuser_post_revokes_token(self) -> None: self.client.force_login(User.objects.get(username="superuser")) - url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) response = self.client.post(url) assert response.status_code == 302 assert not Token.objects.filter(pk=self.token.key).exists() def test_regular_user_post_does_not_revoke_token(self) -> None: self.client.force_login(self.owner) - url = reverse("frontend:delete_token", kwargs={"pk": self.token.key}) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) response = self.client.post(url) assert response.status_code == 403 assert Token.objects.filter(pk=self.token.key).exists() diff --git a/orthos2/frontend/tests/admin/test_user_tokens_tab.py b/orthos2/frontend/tests/admin/test_user_tokens_tab.py new file mode 100644 index 00000000..51f82c54 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_user_tokens_tab.py @@ -0,0 +1,47 @@ +"""Tests for the per-user Tokens tab on the User detail page.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from rest_framework.authtoken.models import Token + + +class UserTokensViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_full_key_of_users_token(self) -> None: + token = Token.objects.create(user=self.target) + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + assert token.key.encode() in response.content + + def test_superuser_get_with_no_token_shows_empty_state(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_user_detail_page_links_to_tokens_tab(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_detail", kwargs={"id": self.target.pk}) + response = self.client.get(url) + self.assertContains( + response, reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + ) diff --git a/orthos2/frontend/urls.py b/orthos2/frontend/urls.py index 15ae5a83..0aadb682 100644 --- a/orthos2/frontend/urls.py +++ b/orthos2/frontend/urls.py @@ -268,6 +268,11 @@ views.user_reserve_machine, name="user_reserve_machine", ), + re_path( + r"^user/(?P[0-9]+)/tokens$", + views.user_tokens, + name="user_tokens", + ), re_path(r"^user/create$", views.users_create, name="create_user"), re_path(r"^user/preferences$", views.users_preferences, name="preferences_user"), re_path( @@ -275,7 +280,7 @@ ), path("tokens", views.TokenListView.as_view(), name="tokens"), path( - "tokens/delete//", + "tokens/delete//", views.DeleteToken.as_view(), name="delete_token", ), diff --git a/orthos2/frontend/views/__init__.py b/orthos2/frontend/views/__init__.py index 33d6781f..963bd157 100644 --- a/orthos2/frontend/views/__init__.py +++ b/orthos2/frontend/views/__init__.py @@ -180,7 +180,13 @@ ) from .token import DeleteToken, TokenListView from .user import users_create, users_password_restore, users_preferences -from .users import UserListView, user_detail, user_reservations, user_reserve_machine +from .users import ( + UserListView, + user_detail, + user_reservations, + user_reserve_machine, + user_tokens, +) __all__ = [ "annotation", @@ -262,6 +268,7 @@ "user_detail", "user_reservations", "user_reserve_machine", + "user_tokens", "TokenListView", "DeleteToken", "RemotePowerDevicesListView", diff --git a/orthos2/frontend/views/token.py b/orthos2/frontend/views/token.py index 38e35f4c..f865f720 100644 --- a/orthos2/frontend/views/token.py +++ b/orthos2/frontend/views/token.py @@ -2,9 +2,10 @@ All views that are under "/tokens". """ -from typing import Any, Dict +from typing import Any, Dict, Optional from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy from django.views.generic import DeleteView, ListView from rest_framework.authtoken.models import Token @@ -32,10 +33,18 @@ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: class DeleteToken(SuperuserRequiredMixin, DeleteView): # type: ignore + """ + Keyed on the owning user's id, not the token's key, so the secret key + never ends up in a URL (browser history, server access logs, referers). + """ + model = Token template_name = "frontend/tokens/token_confirm_deletion.html" success_url = reverse_lazy("frontend:tokens") + def get_object(self, queryset: Optional["QuerySet[Token]"] = None) -> Token: + return get_object_or_404(Token, user_id=self.kwargs["user_id"]) + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: context = super().get_context_data(**kwargs) context["title"] = "Revoke Token" diff --git a/orthos2/frontend/views/users.py b/orthos2/frontend/views/users.py index 40f7e150..8f6ac311 100644 --- a/orthos2/frontend/views/users.py +++ b/orthos2/frontend/views/users.py @@ -19,6 +19,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator from django.views.generic import ListView +from rest_framework.authtoken.models import Token from orthos2.data.models import Machine from orthos2.frontend.forms.reservemachine import ReserveMachineForUserForm @@ -120,6 +121,23 @@ def user_reservations(request: HttpRequest, id: int) -> HttpResponse: raise Http404("User does not exist") +@login_required +def user_tokens(request: HttpRequest, id: int) -> HttpResponse: + if not request.user.is_superuser: + raise PermissionDenied + user_obj = get_object_or_404(User, pk=id) + tokens = Token.objects.filter(user=user_obj) + return render( + request, + "frontend/users/detail/tokens.html", + { + "user_obj": user_obj, + "tokens": tokens, + "title": f"User {user_obj.username} Tokens", + }, + ) + + @login_required def user_reserve_machine( request: HttpRequest, id: int From 357397bee5598e2c50dc3d3dde04f6dc1f59a402 Mon Sep 17 00:00:00 2001 From: Enno Gotthold Date: Sat, 22 Aug 2026 10:37:06 +0200 Subject: [PATCH 3/7] feat(frontend): add superuser User create/edit/delete/deactivate Extends the existing read-only User list/detail with full superuser account management: create (unusable password until a reset email is sent), edit (username/email/name/flags/groups), deactivate/reactivate, hard delete, and a "send password reset email" action. The reset action is disabled for OIDC-linked accounts, which have no local password to reset. Replaces the last reason to visit /admin/auth/user/. Part of the Django Admin removal effort (A2/5). --- mypy.ini | 3 + orthos2/frontend/forms/useradmin.py | 21 ++ .../users/detail/snippets/sidebar.html | 51 ++++ .../templates/frontend/users/new_user.html | 43 ++++ .../templates/frontend/users/user.html | 6 +- .../frontend/users/user_confirm_deletion.html | 20 ++ .../frontend/users/users_filterbar.html | 4 + .../tests/admin/test_user_admin_views.py | 234 ++++++++++++++++++ orthos2/frontend/urls.py | 21 ++ orthos2/frontend/views/__init__.py | 10 + orthos2/frontend/views/user.py | 24 +- orthos2/frontend/views/users.py | 89 ++++++- 12 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 orthos2/frontend/forms/useradmin.py create mode 100644 orthos2/frontend/templates/frontend/users/new_user.html create mode 100644 orthos2/frontend/templates/frontend/users/user_confirm_deletion.html create mode 100644 orthos2/frontend/tests/admin/test_user_admin_views.py diff --git a/mypy.ini b/mypy.ini index 6dca1908..c0b6567d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -10,3 +10,6 @@ django_settings_module = "orthos2.settings" [mypy-ansible.*] # See: https://github.com/ansible/ansible/issues/69424 ignore_missing_imports = True + +[mypy-social_django.*] +ignore_missing_imports = True diff --git a/orthos2/frontend/forms/useradmin.py b/orthos2/frontend/forms/useradmin.py new file mode 100644 index 00000000..c564b8e6 --- /dev/null +++ b/orthos2/frontend/forms/useradmin.py @@ -0,0 +1,21 @@ +""" +This module contains the form used by superusers to create/edit any User account. +""" + +from django import forms +from django.contrib.auth.models import User + + +class UserAdminForm(forms.ModelForm): # type: ignore + class Meta: # type: ignore + model = User + fields = [ + "username", + "email", + "first_name", + "last_name", + "is_active", + "is_staff", + "is_superuser", + "groups", + ] diff --git a/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html b/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html index c2840f02..aa214777 100644 --- a/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html +++ b/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html @@ -10,3 +10,54 @@
Actions
>Reserve Machine
+
+ +
+
+
+ {% csrf_token %} + +
+
+
+
+
+ {% csrf_token %} + +
+
+
+ diff --git a/orthos2/frontend/templates/frontend/users/new_user.html b/orthos2/frontend/templates/frontend/users/new_user.html new file mode 100644 index 00000000..540001b6 --- /dev/null +++ b/orthos2/frontend/templates/frontend/users/new_user.html @@ -0,0 +1,43 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
+ {% csrf_token %} + + {{ form.non_field_errors }} + + {% for field in form %} + {{ field.errors }} +
+
+ +
+
+ {{ field }} + {{ field.help_text }} +
+
+ {% endfor %} + + +
+
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/users/user.html b/orthos2/frontend/templates/frontend/users/user.html index 61fc3929..79bcd134 100644 --- a/orthos2/frontend/templates/frontend/users/user.html +++ b/orthos2/frontend/templates/frontend/users/user.html @@ -36,9 +36,9 @@ Admin: - {% url 'admin:auth_user_change' user_obj.id %}Edit: + {% url 'frontend:edit_user' user_obj.id %} diff --git a/orthos2/frontend/templates/frontend/users/user_confirm_deletion.html b/orthos2/frontend/templates/frontend/users/user_confirm_deletion.html new file mode 100644 index 00000000..f08ce7f4 --- /dev/null +++ b/orthos2/frontend/templates/frontend/users/user_confirm_deletion.html @@ -0,0 +1,20 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} +

Are you sure you want to delete user "{{ object.username }}"?

+ +
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/users/users_filterbar.html b/orthos2/frontend/templates/frontend/users/users_filterbar.html index 0b82ceb3..e83ff069 100644 --- a/orthos2/frontend/templates/frontend/users/users_filterbar.html +++ b/orthos2/frontend/templates/frontend/users/users_filterbar.html @@ -54,6 +54,10 @@ Found {{ paginator.count }} User(s) + + + + diff --git a/orthos2/frontend/tests/admin/test_user_admin_views.py b/orthos2/frontend/tests/admin/test_user_admin_views.py new file mode 100644 index 00000000..1a56b0cc --- /dev/null +++ b/orthos2/frontend/tests/admin/test_user_admin_views.py @@ -0,0 +1,234 @@ +"""Tests for the superuser User create/edit/delete/deactivate frontend views.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from social_django.models import UserSocialAuth + +from orthos2.taskmanager.models import SingleTask + + +class NewUserViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:new_user") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_user") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_user") + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_creates_user_with_unusable_password(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_user") + response = self.client.post( + url, + { + "username": "newperson", + "email": "newperson@foo.bar", + "first_name": "New", + "last_name": "Person", + "is_active": "on", + }, + ) + assert response.status_code == 302 + new_user = User.objects.get(username="newperson") + assert not new_user.has_usable_password() + + def test_regular_user_post_does_not_create_user(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_user") + response = self.client.post(url, {"username": "newperson"}) + assert response.status_code == 403 + assert not User.objects.filter(username="newperson").exists() + + +class UserDetailedEditViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user( + username="targetuser", email="target@foo.bar" + ) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_updates_user(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.post( + url, + { + "username": "targetuser", + "email": "changed@foo.bar", + "first_name": "", + "last_name": "", + "is_active": "on", + "is_staff": "on", + }, + ) + assert response.status_code == 302 + self.target.refresh_from_db() + assert self.target.email == "changed@foo.bar" + assert self.target.is_staff + + def test_regular_user_post_does_not_update_user(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.post(url, {"username": "targetuser", "is_staff": "on"}) + assert response.status_code == 403 + self.target.refresh_from_db() + assert not self.target.is_staff + + +class DeleteUserViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_confirmation_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_deletes_user(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + assert not User.objects.filter(pk=self.target.pk).exists() + + def test_regular_user_post_does_not_delete_user(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 403 + assert User.objects.filter(pk=self.target.pk).exists() + + +class UserToggleActiveViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser", is_active=True) + + def test_unauthenticated_post_redirects_to_login(self) -> None: + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_post_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 403 + + def test_superuser_post_deactivates_active_user(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert not self.target.is_active + + def test_superuser_post_reactivates_inactive_user(self) -> None: + self.target.is_active = False + self.target.save() + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert self.target.is_active + + +class UserSendPasswordResetViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser") + self.target.set_unusable_password() + self.target.save() + + def test_unauthenticated_post_redirects_to_login(self) -> None: + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_post_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 403 + + def test_superuser_post_sets_usable_password_and_queues_email(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert self.target.has_usable_password() + assert SingleTask.objects.filter(name="SendRestoredPassword").exists() + + def test_superuser_post_is_blocked_for_oidc_linked_user(self) -> None: + UserSocialAuth.objects.create( + user=self.target, provider="oidc", uid="oidc-uid-1" + ) + self.client.force_login(User.objects.get(username="superuser")) + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert not self.target.has_usable_password() + assert not SingleTask.objects.filter(name="SendRestoredPassword").exists() diff --git a/orthos2/frontend/urls.py b/orthos2/frontend/urls.py index 0aadb682..b1d9109e 100644 --- a/orthos2/frontend/urls.py +++ b/orthos2/frontend/urls.py @@ -253,6 +253,27 @@ r"^logout/$", auth_views.LogoutView.as_view(), {"next_page": "/"}, name="logout" ), path("users", views.UserListView.as_view(), name="users"), + path("users/new", views.NewUser.as_view(), name="new_user"), + path( + "users/edit//", + views.UserDetailedEdit.as_view(), + name="edit_user", + ), + path( + "users/delete//", + views.DeleteUser.as_view(), + name="delete_user", + ), + re_path( + r"^user/(?P[0-9]+)/toggle-active$", + views.user_toggle_active, + name="user_toggle_active", + ), + re_path( + r"^user/(?P[0-9]+)/send-password-reset$", + views.user_send_password_reset, + name="user_send_password_reset", + ), re_path( r"^user/(?P[0-9]+)/detail$", views.user_detail, diff --git a/orthos2/frontend/views/__init__.py b/orthos2/frontend/views/__init__.py index 963bd157..e02a3959 100644 --- a/orthos2/frontend/views/__init__.py +++ b/orthos2/frontend/views/__init__.py @@ -181,10 +181,15 @@ from .token import DeleteToken, TokenListView from .user import users_create, users_password_restore, users_preferences from .users import ( + DeleteUser, + NewUser, + UserDetailedEdit, UserListView, user_detail, user_reservations, user_reserve_machine, + user_send_password_reset, + user_toggle_active, user_tokens, ) @@ -269,6 +274,11 @@ "user_reservations", "user_reserve_machine", "user_tokens", + "NewUser", + "UserDetailedEdit", + "DeleteUser", + "user_toggle_active", + "user_send_password_reset", "TokenListView", "DeleteToken", "RemotePowerDevicesListView", diff --git a/orthos2/frontend/views/user.py b/orthos2/frontend/views/user.py index d80b914f..6ba4638e 100644 --- a/orthos2/frontend/views/user.py +++ b/orthos2/frontend/views/user.py @@ -29,6 +29,22 @@ from orthos2.taskmanager.models import TaskManager +def reset_and_notify_password(user: User) -> None: + """ + Set `user` a new random password and email it via `SendRestoredPassword`. + + Shared by the self-service "forgot password" flow and the superuser + "send password reset email" action on a User's edit page. + """ + alphabet = string.ascii_letters + string.digits + password = "".join(secrets.choice(alphabet) for i in range(10)) # type: ignore + user.set_password(password) + user.save() + + task = tasks.SendRestoredPassword(user.id, password) # type: ignore + TaskManager.add(task) + + def users_create( request: HttpRequest, ) -> Union[HttpResponsePermanentRedirect, HttpResponseRedirect, HttpResponse]: @@ -94,13 +110,7 @@ def users_password_restore( messages.error(request, "E-Mail/login does not exist.") return redirect("frontend:password_restore") - alphabet = string.ascii_letters + string.digits - password = "".join(secrets.choice(alphabet) for i in range(10)) # type: ignore - user.set_password(password) - user.save() - - task = tasks.SendRestoredPassword(user.id, password) # type: ignore - TaskManager.add(task) + reset_and_notify_password(user) # check for multiple accounts from deprecated Orthos task = tasks.CheckMultipleAccounts(user.id) # type: ignore diff --git a/orthos2/frontend/views/users.py b/orthos2/frontend/views/users.py index 8f6ac311..d90d8a2b 100644 --- a/orthos2/frontend/views/users.py +++ b/orthos2/frontend/views/users.py @@ -17,12 +17,16 @@ HttpResponseRedirect, ) from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse_lazy from django.utils.decorators import method_decorator -from django.views.generic import ListView +from django.views.generic import CreateView, DeleteView, ListView, UpdateView from rest_framework.authtoken.models import Token from orthos2.data.models import Machine from orthos2.frontend.forms.reservemachine import ReserveMachineForUserForm +from orthos2.frontend.forms.useradmin import UserAdminForm +from orthos2.frontend.mixins import SuperuserRequiredMixin +from orthos2.frontend.views.user import reset_and_notify_password class UserListView(ListView): # type: ignore @@ -82,6 +86,89 @@ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: return context +class NewUser(SuperuserRequiredMixin, CreateView): + model = User + template_name = "frontend/users/new_user.html" + success_url = reverse_lazy("frontend:users") + form_class = UserAdminForm + + def form_valid(self, form: UserAdminForm) -> HttpResponseRedirect: + self.object = form.save(commit=False) + # No password field on this form - the account starts with no usable + # password; a superuser grants access via "Send password reset email". + self.object.set_unusable_password() + self.object.save() + form.save_m2m() + messages.success( + self.request, + f"User '{self.object.username}' created. " + "Use 'Send password reset email' on their profile to grant access.", + ) + return redirect(self.get_success_url()) + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "New User" + context["action"] = "new" + return context + + +class UserDetailedEdit(SuperuserRequiredMixin, UpdateView): + model = User + template_name = "frontend/users/new_user.html" + success_url = reverse_lazy("frontend:users") + form_class = UserAdminForm + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Edit User" + context["action"] = "edit" + return context + + +class DeleteUser(SuperuserRequiredMixin, DeleteView): # type: ignore + model = User + template_name = "frontend/users/user_confirm_deletion.html" + success_url = reverse_lazy("frontend:users") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Delete User" + return context + + +@login_required +def user_toggle_active(request: HttpRequest, id: int) -> HttpResponseRedirect: + if not request.user.is_superuser: + raise PermissionDenied + user_obj = get_object_or_404(User, pk=id) + if request.method == "POST": + user_obj.is_active = not user_obj.is_active + user_obj.save() + state = "activated" if user_obj.is_active else "deactivated" + messages.success(request, f"User '{user_obj.username}' {state}.") + return redirect("frontend:user_detail", id=id) + + +@login_required +def user_send_password_reset(request: HttpRequest, id: int) -> HttpResponseRedirect: + if not request.user.is_superuser: + raise PermissionDenied + user_obj = get_object_or_404(User, pk=id) + if request.method == "POST": + if user_obj.social_auth.exists(): # type: ignore[attr-defined] + messages.error( + request, + f"'{user_obj.username}' logs in via OIDC - no local password to reset.", + ) + else: + reset_and_notify_password(user_obj) + messages.success( + request, f"Password reset email sent to '{user_obj.username}'." + ) + return redirect("frontend:user_detail", id=id) + + @login_required def user_detail(request: HttpRequest, id: int) -> HttpResponse: if not request.user.is_superuser: From 4da54a9aa177b867f5c967bb37adb4b491750ed4 Mon Sep 17 00:00:00 2001 From: Enno Gotthold Date: Sat, 22 Aug 2026 10:41:26 +0200 Subject: [PATCH 4/7] feat(frontend): add minimal Group CRUD Adds a superuser-only list/detail/create/edit/delete for django.contrib.auth.models.Group (name + permissions), for admin-app parity. Nothing in Orthos2's own authorization logic checks group membership today - this only replaces /admin/auth/group/ access. Part of the Django Admin removal effort (A3/5). --- orthos2/frontend/forms/group.py | 12 ++ .../frontend/groups/detail/overview.html | 67 +++++++ .../groups/group_confirm_deletion.html | 20 ++ .../templates/frontend/groups/groups.html | 62 ++++++ .../templates/frontend/groups/new_group.html | 43 ++++ .../templates/frontend/snippet.navlinks.html | 7 +- .../frontend/tests/admin/test_group_views.py | 184 ++++++++++++++++++ orthos2/frontend/urls.py | 17 ++ orthos2/frontend/views/__init__.py | 6 + orthos2/frontend/views/group.py | 82 ++++++++ 10 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 orthos2/frontend/forms/group.py create mode 100644 orthos2/frontend/templates/frontend/groups/detail/overview.html create mode 100644 orthos2/frontend/templates/frontend/groups/group_confirm_deletion.html create mode 100644 orthos2/frontend/templates/frontend/groups/groups.html create mode 100644 orthos2/frontend/templates/frontend/groups/new_group.html create mode 100644 orthos2/frontend/tests/admin/test_group_views.py create mode 100644 orthos2/frontend/views/group.py diff --git a/orthos2/frontend/forms/group.py b/orthos2/frontend/forms/group.py new file mode 100644 index 00000000..24e956a3 --- /dev/null +++ b/orthos2/frontend/forms/group.py @@ -0,0 +1,12 @@ +""" +This module contains the form used to create/edit a Group. +""" + +from django import forms +from django.contrib.auth.models import Group + + +class GroupForm(forms.ModelForm): # type: ignore + class Meta: # type: ignore + model = Group + fields = ["name", "permissions"] diff --git a/orthos2/frontend/templates/frontend/groups/detail/overview.html b/orthos2/frontend/templates/frontend/groups/detail/overview.html new file mode 100644 index 00000000..fc817eeb --- /dev/null +++ b/orthos2/frontend/templates/frontend/groups/detail/overview.html @@ -0,0 +1,67 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
+
+
{{ group.name }}
+
+ + + + + + + + + {% for permission in group.permissions.all %} + + + + {% empty %} + + + + {% endfor %} + +
Permissions
{{ permission.content_type }} | {{ permission.name }}
No permissions assigned
+
+ +
+
+
Actions
+
+ +
+ +
+
+ + Edit: + {% url 'frontend:edit_group' group.id %} +
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/groups/group_confirm_deletion.html b/orthos2/frontend/templates/frontend/groups/group_confirm_deletion.html new file mode 100644 index 00000000..940fe0a1 --- /dev/null +++ b/orthos2/frontend/templates/frontend/groups/group_confirm_deletion.html @@ -0,0 +1,20 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} +

Are you sure you want to delete group "{{ object.name }}"?

+ +
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/groups/groups.html b/orthos2/frontend/templates/frontend/groups/groups.html new file mode 100644 index 00000000..d1ee40c8 --- /dev/null +++ b/orthos2/frontend/templates/frontend/groups/groups.html @@ -0,0 +1,62 @@ +{% extends 'frontend/base.html' %} +{% load static %} +{% load tags %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
+ Found {{ paginator.count }} Group(s) + + + +
+
+
+ {% include 'frontend/snippet.paginator.html' %} + +
+ {% if page_obj %} + + + + + + + + + + + {% for group in page_obj %} + + + + + + {% endfor %} + +
NamePermissions 
+ {{ group.name }} + {{ group.permissions.count }} + + Delete + +
+ + {% include 'frontend/snippet.paginator.html' %} + {% else %} +
No groups found!
+ {% endif %} +
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/groups/new_group.html b/orthos2/frontend/templates/frontend/groups/new_group.html new file mode 100644 index 00000000..97fa66a7 --- /dev/null +++ b/orthos2/frontend/templates/frontend/groups/new_group.html @@ -0,0 +1,43 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
+ {% csrf_token %} + + {{ form.non_field_errors }} + + {% for field in form %} + {{ field.errors }} +
+
+ +
+
+ {{ field }} + {{ field.help_text }} +
+
+ {% endfor %} + + +
+
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/snippet.navlinks.html b/orthos2/frontend/templates/frontend/snippet.navlinks.html index 1514a163..4f5435f8 100644 --- a/orthos2/frontend/templates/frontend/snippet.navlinks.html +++ b/orthos2/frontend/templates/frontend/snippet.navlinks.html @@ -108,7 +108,7 @@ REST API {% if request.user.is_superuser %} - {% active_group request 'users' 'tokens' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} + {% active_group request 'users' 'tokens' 'groups' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} + {% endif %} diff --git a/orthos2/frontend/tests/admin/test_group_views.py b/orthos2/frontend/tests/admin/test_group_views.py new file mode 100644 index 00000000..9b599bd4 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_group_views.py @@ -0,0 +1,184 @@ +"""Tests for the Group CRUD frontend views.""" + +from django.contrib.auth.models import Group, Permission, User +from django.test import TestCase +from django.urls import reverse + + +class GroupListViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:groups") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:groups") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_lists_groups(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:groups") + response = self.client.get(url) + assert response.status_code == 200 + assert b"Operators" in response.content + + +class GroupDetailViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.group = Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:group_detail", kwargs={"id": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:group_detail", kwargs={"id": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_detail_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:group_detail", kwargs={"id": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 200 + assert b"Operators" in response.content + + def test_nonexistent_group_returns_404(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:group_detail", kwargs={"id": 99999}) + response = self.client.get(url) + assert response.status_code == 404 + + +class NewGroupViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:new_group") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_group") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_group") + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_creates_group_with_permissions(self) -> None: + permission = Permission.objects.first() + assert permission is not None + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_group") + response = self.client.post( + url, {"name": "Operators", "permissions": [permission.pk]} + ) + assert response.status_code == 302 + group = Group.objects.get(name="Operators") + assert permission in group.permissions.all() + + def test_regular_user_post_does_not_create_group(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_group") + response = self.client.post(url, {"name": "Operators"}) + assert response.status_code == 403 + assert not Group.objects.filter(name="Operators").exists() + + +class GroupDetailedEditViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.group = Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_updates_group(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url, {"name": "Operators Renamed"}) + assert response.status_code == 302 + self.group.refresh_from_db() + assert self.group.name == "Operators Renamed" + + def test_regular_user_post_does_not_update_group(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url, {"name": "Operators Renamed"}) + assert response.status_code == 403 + self.group.refresh_from_db() + assert self.group.name == "Operators" + + +class DeleteGroupViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.group = Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_confirmation_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_deletes_group(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url) + assert response.status_code == 302 + assert not Group.objects.filter(pk=self.group.pk).exists() + + def test_regular_user_post_does_not_delete_group(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url) + assert response.status_code == 403 + assert Group.objects.filter(pk=self.group.pk).exists() diff --git a/orthos2/frontend/urls.py b/orthos2/frontend/urls.py index b1d9109e..ddbdaed4 100644 --- a/orthos2/frontend/urls.py +++ b/orthos2/frontend/urls.py @@ -299,6 +299,23 @@ re_path( r"^password/restore$", views.users_password_restore, name="password_restore" ), + path("groups", views.GroupListView.as_view(), name="groups"), + re_path( + r"^group/(?P[0-9]+)/detail$", + views.group_detail, + name="group_detail", + ), + path("groups/new", views.NewGroup.as_view(), name="new_group"), + path( + "groups/edit//", + views.GroupDetailedEdit.as_view(), + name="edit_group", + ), + path( + "groups/delete//", + views.DeleteGroup.as_view(), + name="delete_group", + ), path("tokens", views.TokenListView.as_view(), name="tokens"), path( "tokens/delete//", diff --git a/orthos2/frontend/views/__init__.py b/orthos2/frontend/views/__init__.py index e02a3959..66f64aa5 100644 --- a/orthos2/frontend/views/__init__.py +++ b/orthos2/frontend/views/__init__.py @@ -66,6 +66,7 @@ enclosure_machines, enclosure_netboxcomparison, ) +from .group import DeleteGroup, GroupDetailedEdit, GroupListView, NewGroup, group_detail from .machine import ( BMCDetailedEdit, DeleteAnnotation, @@ -259,6 +260,11 @@ "enclosure_netboxcomparison", "enclosure_compare_netbox", "enclosure_fetch_netbox", + "GroupListView", + "NewGroup", + "GroupDetailedEdit", + "DeleteGroup", + "group_detail", "regenerate_cobbler", "regenerate_domain_cscreen", "regenerate_domain_cobbler", diff --git a/orthos2/frontend/views/group.py b/orthos2/frontend/views/group.py new file mode 100644 index 00000000..f6edb1fe --- /dev/null +++ b/orthos2/frontend/views/group.py @@ -0,0 +1,82 @@ +""" +All views that are under "/groups". +""" + +from typing import Any, Dict + +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import Group +from django.core.exceptions import PermissionDenied +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import CreateView, DeleteView, ListView, UpdateView + +from orthos2.frontend.forms.group import GroupForm +from orthos2.frontend.mixins import SuperuserRequiredMixin + + +class GroupListView(SuperuserRequiredMixin, ListView): + model = Group + template_name = "frontend/groups/groups.html" + paginate_by = 50 + ordering = "name" + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Groups" + return context + + +class NewGroup(SuperuserRequiredMixin, CreateView): + model = Group + template_name = "frontend/groups/new_group.html" + success_url = reverse_lazy("frontend:groups") + form_class = GroupForm + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "New Group" + context["action"] = "new" + return context + + +class GroupDetailedEdit(SuperuserRequiredMixin, UpdateView): + model = Group + template_name = "frontend/groups/new_group.html" + success_url = reverse_lazy("frontend:groups") + form_class = GroupForm + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Edit Group" + context["action"] = "edit" + return context + + +class DeleteGroup(SuperuserRequiredMixin, DeleteView): # type: ignore + model = Group + template_name = "frontend/groups/group_confirm_deletion.html" + success_url = reverse_lazy("frontend:groups") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Delete Group" + return context + + +@login_required +def group_detail(request: HttpRequest, id: int) -> HttpResponse: + if not request.user.is_superuser: # type: ignore + raise PermissionDenied + + try: + group = Group.objects.prefetch_related("permissions").get(pk=id) + except Group.DoesNotExist: + raise Http404("Group does not exist") + + return render( + request, + "frontend/groups/detail/overview.html", + {"group": group, "title": "Group {}".format(group.name)}, + ) From 7a95e4511e21bb85c35547c3cdfbaf2da5109331 Mon Sep 17 00:00:00 2001 From: Enno Gotthold Date: Sat, 22 Aug 2026 10:44:07 +0200 Subject: [PATCH 5/7] feat(frontend): add read-only OIDC diagnostics page Association and Nonce are ephemeral social-django bookkeeping tables for the OIDC login handshake - never read or written by Orthos2 code and not meant for hands-on editing. Adds a superuser-only, read-only list of both for troubleshooting, without exposing Association.secret. Part of the Django Admin removal effort (A4/5). --- .../oidc_diagnostics/oidc_diagnostics.html | 66 +++++++++++++++++++ .../templates/frontend/snippet.navlinks.html | 9 ++- .../admin/test_oidc_diagnostics_views.py | 51 ++++++++++++++ orthos2/frontend/urls.py | 1 + orthos2/frontend/views/__init__.py | 2 + orthos2/frontend/views/oidc_diagnostics.py | 28 ++++++++ 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 orthos2/frontend/templates/frontend/oidc_diagnostics/oidc_diagnostics.html create mode 100644 orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py create mode 100644 orthos2/frontend/views/oidc_diagnostics.py diff --git a/orthos2/frontend/templates/frontend/oidc_diagnostics/oidc_diagnostics.html b/orthos2/frontend/templates/frontend/oidc_diagnostics/oidc_diagnostics.html new file mode 100644 index 00000000..127dca8a --- /dev/null +++ b/orthos2/frontend/templates/frontend/oidc_diagnostics/oidc_diagnostics.html @@ -0,0 +1,66 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
Associations
+
+ + + + + + + + + + + + {% for association in associations %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
Server URLHandleAssoc TypeIssuedLifetime
{{ association.server_url }}{{ association.handle }}{{ association.assoc_type }}{{ association.issued }}{{ association.lifetime }}
No associations found.
+ +
+
Nonces
+
+ + + + + + + + + + {% for nonce in nonces %} + + + + + + {% empty %} + + + + {% endfor %} + +
Server URLTimestampSalt
{{ nonce.server_url }}{{ nonce.timestamp }}{{ nonce.salt }}
No nonces found.
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/snippet.navlinks.html b/orthos2/frontend/templates/frontend/snippet.navlinks.html index 4f5435f8..50ed19fb 100644 --- a/orthos2/frontend/templates/frontend/snippet.navlinks.html +++ b/orthos2/frontend/templates/frontend/snippet.navlinks.html @@ -108,7 +108,7 @@ REST API {% if request.user.is_superuser %} - {% active_group request 'users' 'tokens' 'groups' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} + {% active_group request 'users' 'tokens' 'groups' 'oidc_diagnostics' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} + {% endif %} diff --git a/orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py b/orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py new file mode 100644 index 00000000..b940e51a --- /dev/null +++ b/orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py @@ -0,0 +1,51 @@ +"""Tests for the read-only OIDC diagnostics (Association/Nonce) frontend view.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from social_django.models import Association, Nonce + + +class OidcDiagnosticsViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.association = Association.objects.create( + server_url="https://idp.example.test", + handle="assoc-handle-1", + secret="", + issued=0, + lifetime=0, + assoc_type="HMAC-SHA1", + ) + self.nonce = Nonce.objects.create( + server_url="https://idp.example.test", timestamp=0, salt="nonce-salt-1" + ) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_lists_associations_and_nonces(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert response.status_code == 200 + assert b"assoc-handle-1" in response.content + assert b"nonce-salt-1" in response.content + + def test_superuser_get_does_not_expose_association_secret(self) -> None: + self.association.secret = "super-secret-value" + self.association.save() + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert b"super-secret-value" not in response.content diff --git a/orthos2/frontend/urls.py b/orthos2/frontend/urls.py index ddbdaed4..8825f158 100644 --- a/orthos2/frontend/urls.py +++ b/orthos2/frontend/urls.py @@ -299,6 +299,7 @@ re_path( r"^password/restore$", views.users_password_restore, name="password_restore" ), + path("oidc-diagnostics", views.oidc_diagnostics, name="oidc_diagnostics"), path("groups", views.GroupListView.as_view(), name="groups"), re_path( r"^group/(?P[0-9]+)/detail$", diff --git a/orthos2/frontend/views/__init__.py b/orthos2/frontend/views/__init__.py index 66f64aa5..202d394a 100644 --- a/orthos2/frontend/views/__init__.py +++ b/orthos2/frontend/views/__init__.py @@ -124,6 +124,7 @@ manufacturer_fetch_netbox, manufacturer_netboxcomparison, ) +from .oidc_diagnostics import oidc_diagnostics from .regenerate import ( regenerate_cobbler, regenerate_domain_cobbler, @@ -304,6 +305,7 @@ "manufacturer_fetch_netbox", "manufacturer_netboxcomparison", "manufacturer_compare_netbox", + "oidc_diagnostics", "DeviceTypeListView", "NewDeviceType", "DeviceTypeDetailedEdit", diff --git a/orthos2/frontend/views/oidc_diagnostics.py b/orthos2/frontend/views/oidc_diagnostics.py new file mode 100644 index 00000000..96cc0edc --- /dev/null +++ b/orthos2/frontend/views/oidc_diagnostics.py @@ -0,0 +1,28 @@ +""" +Read-only superuser view of social-django's OIDC handshake bookkeeping +tables (Association, Nonce). Neither is written or read by any Orthos2 +code - they exist purely to support the OIDC login handshake, and this +view exists only so a superuser can inspect them while troubleshooting. +""" + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from social_django.models import Association, Nonce + + +@login_required +def oidc_diagnostics(request: HttpRequest) -> HttpResponse: + if not request.user.is_superuser: # type: ignore + raise PermissionDenied + + return render( + request, + "frontend/oidc_diagnostics/oidc_diagnostics.html", + { + "associations": Association.objects.all().order_by("-issued"), + "nonces": Nonce.objects.all().order_by("-timestamp"), + "title": "OIDC Diagnostics", + }, + ) From 995b356a705d0ab9332c004ea182b6770384d736 Mon Sep 17 00:00:00 2001 From: Enno Gotthold Date: Sat, 22 Aug 2026 10:53:01 +0200 Subject: [PATCH 6/7] feat(frontend): remove django.contrib.admin Users, Groups, Tokens, Associations, Nonces, and UserSocialAuths all now have frontend equivalents (or an explicit no-UI decision for the purely internal OIDC bookkeeping tables). Drop django.contrib.admin from INSTALLED_APPS, the /admin/ URL include, the "Django Admin" nav link, and the now-pointless empty admin.py files. Completes the Django Admin removal effort (A5/5). --- orthos2/api/admin.py | 1 - orthos2/data/admin.py | 1 - orthos2/frontend/templates/frontend/snippet.navlinks.html | 3 --- orthos2/settings.py | 1 - orthos2/taskmanager/admin.py | 1 - orthos2/urls.py | 2 -- orthos2/utils/admin.py | 1 - 7 files changed, 10 deletions(-) delete mode 100644 orthos2/api/admin.py delete mode 100644 orthos2/data/admin.py delete mode 100644 orthos2/taskmanager/admin.py delete mode 100644 orthos2/utils/admin.py diff --git a/orthos2/api/admin.py b/orthos2/api/admin.py deleted file mode 100644 index 846f6b40..00000000 --- a/orthos2/api/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/orthos2/data/admin.py b/orthos2/data/admin.py deleted file mode 100644 index 5805db30..00000000 --- a/orthos2/data/admin.py +++ /dev/null @@ -1 +0,0 @@ -# All data models are managed through the frontend and API CRUD views instead of Django Admin. diff --git a/orthos2/frontend/templates/frontend/snippet.navlinks.html b/orthos2/frontend/templates/frontend/snippet.navlinks.html index 50ed19fb..6a983bea 100644 --- a/orthos2/frontend/templates/frontend/snippet.navlinks.html +++ b/orthos2/frontend/templates/frontend/snippet.navlinks.html @@ -121,9 +121,6 @@ Administration