From 0fb37a59fad582419e6b8efda8ebeaba126a7fab Mon Sep 17 00:00:00 2001 From: obervinov Date: Mon, 29 Jun 2026 23:55:38 +0400 Subject: [PATCH] feat: add delete button for revoked API tokens Revoked tokens previously stayed in the list with no way to remove them. Adds a delete action (hard delete, scoped to the owner, gated behind demo mode) shown on revoked rows, alongside the existing revoke action on active ones. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/apps/users/tests.py | 28 +++++++++++++++++++ app/apps/users/urls.py | 5 ++++ app/apps/users/views.py | 11 ++++++++ app/templates/users/fragments/api_tokens.html | 15 ++++++++++ 4 files changed, 59 insertions(+) diff --git a/app/apps/users/tests.py b/app/apps/users/tests.py index ed80bfc..5bdb615 100644 --- a/app/apps/users/tests.py +++ b/app/apps/users/tests.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse +from django.utils import timezone from apps.users.models import APIToken @@ -43,3 +44,30 @@ class UserAPITokenViewsTests(TestCase): token.refresh_from_db() self.assertIsNotNone(token.revoked_at) self.assertContains(response, "Revoked") + + def test_can_delete_revoked_api_token(self): + token, _ = APIToken.objects.create_token(user=self.user, name="n8n") + token.revoked_at = timezone.now() + token.save(update_fields=["revoked_at"]) + + response = self.client.delete( + reverse("user_api_token_delete", kwargs={"token_id": token.id}), + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(APIToken.objects.filter(id=token.id).exists()) + + def test_cannot_delete_other_users_api_token(self): + other = get_user_model().objects.create_user( + email="other@example.com", password="test-password" + ) + token, _ = APIToken.objects.create_token(user=other, name="theirs") + + response = self.client.delete( + reverse("user_api_token_delete", kwargs={"token_id": token.id}), + **self.htmx_headers, + ) + + self.assertEqual(response.status_code, 404) + self.assertTrue(APIToken.objects.filter(id=token.id).exists()) diff --git a/app/apps/users/urls.py b/app/apps/users/urls.py index 4cb4aaa..ca2faaa 100644 --- a/app/apps/users/urls.py +++ b/app/apps/users/urls.py @@ -42,6 +42,11 @@ urlpatterns = [ views.api_token_revoke, name="user_api_token_revoke", ), + path( + "user/api-tokens//delete/", + views.api_token_delete, + name="user_api_token_delete", + ), path( "users/", views.users_index, diff --git a/app/apps/users/views.py b/app/apps/users/views.py index 986020d..523acc1 100644 --- a/app/apps/users/views.py +++ b/app/apps/users/views.py @@ -168,6 +168,17 @@ def api_token_revoke(request, token_id): return _render_api_tokens(request) +@only_htmx +@htmx_login_required +@disabled_on_demo +@require_http_methods(["DELETE"]) +def api_token_delete(request, token_id): + token = get_object_or_404(APIToken, id=token_id, user=request.user) + token.delete() + messages.success(request, _("API token deleted successfully")) + return _render_api_tokens(request) + + @only_htmx @htmx_login_required @require_http_methods(["GET"]) diff --git a/app/templates/users/fragments/api_tokens.html b/app/templates/users/fragments/api_tokens.html index e1af756..c2b217e 100644 --- a/app/templates/users/fragments/api_tokens.html +++ b/app/templates/users/fragments/api_tokens.html @@ -87,6 +87,21 @@ _="install prompt_swal"> + {% else %} + + + {% endif %}