diff --git a/docker/orthos/devel-server.sh b/docker/orthos/devel-server.sh
index 34134c198..5fe796153 100755
--- a/docker/orthos/devel-server.sh
+++ b/docker/orthos/devel-server.sh
@@ -30,10 +30,21 @@ server_start() {
git switch "$OLD_BRANCH"
git stash pop
python3.11 manage.py migrate
- # Load test machine fixtures for development
- python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_domain_orthos2test.json || true
- python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_machine_docker.json || true
- DJANGO_SUPERUSER_PASSWORD="$ORTHOS2_SUPERUSER_PASSWORD" python3.11 manage.py createsuperuser --noinput --username admin --email admin@example.com
+ # Load test machine fixtures for development. Both fixtures use fixed
+ # keys/fqdns that collide with a previous run's data on the persistent dev
+ # DB, so skip loading them again if that data is already there.
+ python3.11 manage.py shell -c "
+from orthos2.data.models import ServerConfig
+exit(0 if ServerConfig.objects.filter(key='domain.validendings').exists() else 1)
+" || python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_domain_orthos2test.json
+ python3.11 manage.py shell -c "
+from orthos2.data.models import Machine
+exit(0 if Machine.objects.filter(fqdn='testmachine.orthos2.test').exists() else 1)
+" || python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_machine_docker.json
+ python3.11 manage.py shell -c "
+from django.contrib.auth.models import User
+exit(0 if User.objects.filter(username='admin').exists() else 1)
+" || DJANGO_SUPERUSER_PASSWORD="$ORTHOS2_SUPERUSER_PASSWORD" python3.11 manage.py createsuperuser --noinput --username admin --email admin@example.com
python3.11 manage.py shell
+
+
+
+
{{ group.name }}
+
+
+
+
+
+ | Permissions |
+
+
+
+ {% for permission in group.permissions.all %}
+
+ | {{ permission.content_type }} | {{ permission.name }} |
+
+ {% empty %}
+
+ | No permissions assigned |
+
+ {% endfor %}
+
+
+
+
+
+
+
+ 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 000000000..940fe0a1c
--- /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 %}
+
+{% endblock %}
diff --git a/orthos2/frontend/templates/frontend/groups/groups.html b/orthos2/frontend/templates/frontend/groups/groups.html
new file mode 100644
index 000000000..d1ee40c89
--- /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 %}
+
+
+
+ | Name |
+ Permissions |
+ |
+
+
+
+
+ {% for group in page_obj %}
+
+ |
+ {{ group.name }}
+ |
+ {{ group.permissions.count }} |
+
+
+ Delete
+
+ |
+
+ {% endfor %}
+
+
+
+ {% 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 000000000..97fa66a75
--- /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 %}
+
+{% endblock %}
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 000000000..127dca8ac
--- /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
+
+
+
+
+ | Server URL |
+ Handle |
+ Assoc Type |
+ Issued |
+ Lifetime |
+
+
+
+ {% for association in associations %}
+
+ | {{ association.server_url }} |
+ {{ association.handle }} |
+ {{ association.assoc_type }} |
+ {{ association.issued }} |
+ {{ association.lifetime }} |
+
+ {% empty %}
+
+ | No associations found. |
+
+ {% endfor %}
+
+
+
+
+
Nonces
+
+
+
+
+ | Server URL |
+ Timestamp |
+ Salt |
+
+
+
+ {% for nonce in nonces %}
+
+ | {{ nonce.server_url }} |
+ {{ nonce.timestamp }} |
+ {{ nonce.salt }} |
+
+ {% empty %}
+
+ | No nonces found. |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/orthos2/frontend/templates/frontend/snippet.navlinks.html b/orthos2/frontend/templates/frontend/snippet.navlinks.html
index 79983a730..6a983bea7 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' 'groups' 'oidc_diagnostics' '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 000000000..4451ad7e0
--- /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 %}
+
+{% endblock %}
diff --git a/orthos2/frontend/templates/frontend/tokens/tokens.html b/orthos2/frontend/templates/frontend/tokens/tokens.html
new file mode 100644
index 000000000..da900b033
--- /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 %}
+
+
+
+ | Owner |
+ Key |
+ Created |
+ |
+
+
+
+
+ {% for token in page_obj %}
+
+ |
+ {{ token.user.username }}
+ |
+ {{ token.key }} |
+ {{ token.created }} |
+
+
+ Revoke
+
+ |
+
+ {% endfor %}
+
+
+
+ {% include 'frontend/snippet.paginator.html' %}
+ {% else %}
+
No tokens found!
+ {% endif %}
+
+{% endblock %}
diff --git a/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html b/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html
index c2840f02b..aa2147777 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
+
+
+
+
+
+
+
+
+
+
+
+
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 000000000..f148b4dbe
--- /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 %}
+
+
+
+ | Key |
+ Created |
+ |
+
+
+
+ {% for token in tokens %}
+
+ {{ token.key }} |
+ {{ token.created }} |
+
+
+ Revoke
+
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
No tokens for this user.
+ {% endif %}
+
+{% endblock %}
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 000000000..540001b6b
--- /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 %}
+
+{% endblock %}
diff --git a/orthos2/frontend/templates/frontend/users/user.html b/orthos2/frontend/templates/frontend/users/user.html
index 4af2df3bf..79bcd134f 100644
--- a/orthos2/frontend/templates/frontend/users/user.html
+++ b/orthos2/frontend/templates/frontend/users/user.html
@@ -21,6 +21,13 @@
>Reservations
+
+ Tokens
+
@@ -29,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 000000000..f08ce7f41
--- /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 %}
+
+{% endblock %}
diff --git a/orthos2/frontend/templates/frontend/users/users_filterbar.html b/orthos2/frontend/templates/frontend/users/users_filterbar.html
index 0b82ceb3e..e83ff069b 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_group_views.py b/orthos2/frontend/tests/admin/test_group_views.py
new file mode 100644
index 000000000..9b599bd48
--- /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/tests/admin/test_oidc_diagnostics_views.py b/orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py
new file mode 100644
index 000000000..b940e51a2
--- /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/tests/admin/test_token_views.py b/orthos2/frontend/tests/admin/test_token_views.py
new file mode 100644
index 000000000..179c17d77
--- /dev/null
+++ b/orthos2/frontend/tests/admin/test_token_views.py
@@ -0,0 +1,87 @@
+"""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_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.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):
+ 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={"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={"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={"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={"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={"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_admin_views.py b/orthos2/frontend/tests/admin/test_user_admin_views.py
new file mode 100644
index 000000000..1a56b0cc8
--- /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/tests/admin/test_user_tokens_tab.py b/orthos2/frontend/tests/admin/test_user_tokens_tab.py
new file mode 100644
index 000000000..51f82c544
--- /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 116a309b4..8825f1589 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,
@@ -268,11 +289,40 @@
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(
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$",
+ 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//",
+ 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 866df15e1..202d394a5 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,
@@ -123,6 +124,7 @@
manufacturer_fetch_netbox,
manufacturer_netboxcomparison,
)
+from .oidc_diagnostics import oidc_diagnostics
from .regenerate import (
regenerate_cobbler,
regenerate_domain_cobbler,
@@ -178,8 +180,20 @@
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
+from .users import (
+ DeleteUser,
+ NewUser,
+ UserDetailedEdit,
+ UserListView,
+ user_detail,
+ user_reservations,
+ user_reserve_machine,
+ user_send_password_reset,
+ user_toggle_active,
+ user_tokens,
+)
__all__ = [
"annotation",
@@ -247,6 +261,11 @@
"enclosure_netboxcomparison",
"enclosure_compare_netbox",
"enclosure_fetch_netbox",
+ "GroupListView",
+ "NewGroup",
+ "GroupDetailedEdit",
+ "DeleteGroup",
+ "group_detail",
"regenerate_cobbler",
"regenerate_domain_cscreen",
"regenerate_domain_cobbler",
@@ -261,6 +280,14 @@
"user_detail",
"user_reservations",
"user_reserve_machine",
+ "user_tokens",
+ "NewUser",
+ "UserDetailedEdit",
+ "DeleteUser",
+ "user_toggle_active",
+ "user_send_password_reset",
+ "TokenListView",
+ "DeleteToken",
"RemotePowerDevicesListView",
"RemotePowerDeviceDetailedEdit",
"NewRemotePowerDevice",
@@ -278,6 +305,7 @@
"manufacturer_fetch_netbox",
"manufacturer_netboxcomparison",
"manufacturer_compare_netbox",
+ "oidc_diagnostics",
"DeviceTypeListView",
"NewDeviceType",
"DeviceTypeDetailedEdit",
diff --git a/orthos2/frontend/views/group.py b/orthos2/frontend/views/group.py
new file mode 100644
index 000000000..f6edb1feb
--- /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)},
+ )
diff --git a/orthos2/frontend/views/oidc_diagnostics.py b/orthos2/frontend/views/oidc_diagnostics.py
new file mode 100644
index 000000000..96cc0edce
--- /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",
+ },
+ )
diff --git a/orthos2/frontend/views/token.py b/orthos2/frontend/views/token.py
new file mode 100644
index 000000000..f865f7202
--- /dev/null
+++ b/orthos2/frontend/views/token.py
@@ -0,0 +1,51 @@
+"""
+All views that are under "/tokens".
+"""
+
+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
+
+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
+ """
+ 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"
+ return context
diff --git a/orthos2/frontend/views/user.py b/orthos2/frontend/views/user.py
index d80b914f5..6ba4638e4 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 40f7e1500..d90d8a2b0 100644
--- a/orthos2/frontend/views/users.py
+++ b/orthos2/frontend/views/users.py
@@ -17,11 +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
@@ -81,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:
@@ -120,6 +208,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
diff --git a/orthos2/settings.py b/orthos2/settings.py
index 31b61a8cf..fab3e672a 100644
--- a/orthos2/settings.py
+++ b/orthos2/settings.py
@@ -77,7 +77,6 @@ def _environ_get_and_map(
"orthos2.meta.apps.MetaConfig",
"orthos2.taskmanager.apps.TaskManagerConfig",
"orthos2.utils.apps.UtilsConfig",
- "django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
diff --git a/orthos2/taskmanager/admin.py b/orthos2/taskmanager/admin.py
deleted file mode 100644
index 5805db308..000000000
--- a/orthos2/taskmanager/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/urls.py b/orthos2/urls.py
index d34ca15b9..34caef502 100644
--- a/orthos2/urls.py
+++ b/orthos2/urls.py
@@ -15,12 +15,10 @@
1. Import the include() function: from django.urls import url, include
2. Add a URL to urlpatterns: re_path(r'^blog/', include('blog.urls'))
"""
-from django.contrib import admin
from django.urls import include, path, re_path
urlpatterns = [
path("", include("social_django.urls", namespace="social")),
re_path(r"^", include("orthos2.frontend.urls", namespace="frontend")),
- re_path(r"^admin/", admin.site.urls),
re_path(r"^api/", include("orthos2.api.urls", namespace="api")),
]
diff --git a/orthos2/utils/admin.py b/orthos2/utils/admin.py
deleted file mode 100644
index 846f6b406..000000000
--- a/orthos2/utils/admin.py
+++ /dev/null
@@ -1 +0,0 @@
-# Register your models here.