From 83092ccb482c2613076830e3f7e440a2fa523aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sun, 14 Dec 2025 17:51:53 +0100 Subject: [PATCH] API token management (#1248) --- bookmarks/admin.py | 20 ++- bookmarks/api/auth.py | 6 +- bookmarks/frontend/behaviors/details-modal.js | 7 - bookmarks/frontend/behaviors/modal.js | 11 +- bookmarks/migrations/0052_apitoken.py | 41 +++++ .../migrations/0053_migrate_api_tokens.py | 32 ++++ bookmarks/models.py | 23 +++ bookmarks/styles/bookmark-details.css | 12 +- bookmarks/styles/crud.css | 38 ++-- bookmarks/styles/theme/modals.css | 1 - .../settings/create_api_token_modal.html | 45 +++++ .../templates/settings/integrations.html | 163 +++++++++++++----- bookmarks/tests/helpers.py | 25 ++- bookmarks/tests/test_auth_api.py | 5 +- bookmarks/tests/test_bookmarks_api.py | 5 +- .../tests/test_bookmarks_api_performance.py | 5 +- .../tests/test_bookmarks_api_permissions.py | 5 +- .../tests/test_settings_integrations_view.py | 96 +++++++++-- .../e2e_test_settings_integrations.py | 67 +++++++ bookmarks/urls.py | 10 ++ bookmarks/views/access.py | 9 +- bookmarks/views/settings.py | 58 ++++++- 22 files changed, 560 insertions(+), 124 deletions(-) create mode 100644 bookmarks/migrations/0052_apitoken.py create mode 100644 bookmarks/migrations/0053_migrate_api_tokens.py create mode 100644 bookmarks/templates/settings/create_api_token_modal.html create mode 100644 bookmarks/tests_e2e/e2e_test_settings_integrations.py diff --git a/bookmarks/admin.py b/bookmarks/admin.py index ba6bb69..7317939 100644 --- a/bookmarks/admin.py +++ b/bookmarks/admin.py @@ -1,4 +1,5 @@ import os +from django import forms from django.contrib import admin, messages from django.contrib.admin import AdminSite from django.contrib.auth.admin import UserAdmin @@ -9,10 +10,9 @@ from django.shortcuts import render from django.urls import path from django.utils.translation import ngettext, gettext from huey.contrib.djhuey import HUEY as huey -from rest_framework.authtoken.admin import TokenAdmin -from rest_framework.authtoken.models import TokenProxy from bookmarks.models import ( + ApiToken, Bookmark, BookmarkAsset, BookmarkBundle, @@ -310,12 +310,26 @@ class AdminFeedToken(admin.ModelAdmin): list_filter = ("user__username",) +class ApiTokenAdminForm(forms.ModelForm): + class Meta: + model = ApiToken + fields = ("name", "user") + + +class AdminApiToken(admin.ModelAdmin): + form = ApiTokenAdminForm + list_display = ("name", "user", "created") + search_fields = ["name", "user__username"] + list_filter = ("user__username",) + ordering = ("-created",) + + linkding_admin_site = LinkdingAdminSite() linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset) linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(BookmarkBundle, AdminBookmarkBundle) linkding_admin_site.register(User, AdminCustomUser) -linkding_admin_site.register(TokenProxy, TokenAdmin) +linkding_admin_site.register(ApiToken, AdminApiToken) linkding_admin_site.register(Toast, AdminToast) linkding_admin_site.register(FeedToken, AdminFeedToken) diff --git a/bookmarks/api/auth.py b/bookmarks/api/auth.py index 019e9d8..7b06ba9 100644 --- a/bookmarks/api/auth.py +++ b/bookmarks/api/auth.py @@ -2,12 +2,16 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication, get_authorization_header +from bookmarks.models import ApiToken + class LinkdingTokenAuthentication(TokenAuthentication): """ - Extends DRF TokenAuthentication to add support for multiple keywords + Extends DRF TokenAuthentication to add support for multiple keywords and + multiple tokens per user. """ + model = ApiToken keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]] def authenticate(self, request): diff --git a/bookmarks/frontend/behaviors/details-modal.js b/bookmarks/frontend/behaviors/details-modal.js index 06a9b47..97a1397 100644 --- a/bookmarks/frontend/behaviors/details-modal.js +++ b/bookmarks/frontend/behaviors/details-modal.js @@ -6,13 +6,6 @@ class DetailsModalBehavior extends ModalBehavior { doClose() { super.doClose(); - // Navigate to close URL - const closeUrl = this.element.dataset.closeUrl; - Turbo.visit(closeUrl, { - action: "replace", - frame: "details-modal", - }); - // Try restore focus to view details to view details link of respective bookmark const bookmarkId = this.element.dataset.bookmarkId; setAfterPageLoadFocusTarget( diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js index 601b899..6283cdc 100644 --- a/bookmarks/frontend/behaviors/modal.js +++ b/bookmarks/frontend/behaviors/modal.js @@ -1,4 +1,4 @@ -import { Behavior } from "./index"; +import { Behavior, registerBehavior } from "./index"; import { FocusTrapController } from "./focus-utils"; export class ModalBehavior extends Behavior { @@ -77,5 +77,14 @@ export class ModalBehavior extends Behavior { this.element.remove(); this.removeScrollLock(); this.element.dispatchEvent(new CustomEvent("modal:close")); + + // Navigate to close URL + const closeUrl = this.element.dataset.closeUrl; + const frame = this.element.dataset.turboFrame; + if (closeUrl) { + Turbo.visit(closeUrl, { frame: frame }); + } } } + +registerBehavior("ld-modal", ModalBehavior); diff --git a/bookmarks/migrations/0052_apitoken.py b/bookmarks/migrations/0052_apitoken.py new file mode 100644 index 0000000..b3fbc69 --- /dev/null +++ b/bookmarks/migrations/0052_apitoken.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.5 on 2025-12-14 16:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0051_fix_normalized_url"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ApiToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.CharField(max_length=40, unique=True)), + ("name", models.CharField(max_length=128)), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/bookmarks/migrations/0053_migrate_api_tokens.py b/bookmarks/migrations/0053_migrate_api_tokens.py new file mode 100644 index 0000000..36a983b --- /dev/null +++ b/bookmarks/migrations/0053_migrate_api_tokens.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.5 on 2025-12-14 16:34 +from django.db import migrations + + +def migrate_tokens_forward(apps, schema_editor): + Token = apps.get_model("authtoken", "Token") + ApiToken = apps.get_model("bookmarks", "ApiToken") + + for old_token in Token.objects.all(): + ApiToken.objects.create( + key=old_token.key, + user=old_token.user, + name="Default Token", + created=old_token.created, + ) + + +def migrate_tokens_reverse(apps, schema_editor): + ApiToken = apps.get_model("bookmarks", "ApiToken") + ApiToken.objects.filter(name="Default Token").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0052_apitoken"), + ("authtoken", "0004_alter_tokenproxy_options"), + ] + + operations = [ + migrations.RunPython(migrate_tokens_forward, migrate_tokens_reverse), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index 3311d6e..e0e8ff2 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -587,6 +587,29 @@ class FeedToken(models.Model): return self.key +class ApiToken(models.Model): + key = models.CharField(max_length=40, unique=True) + user = models.ForeignKey( + User, + related_name="api_tokens", + on_delete=models.CASCADE, + ) + name = models.CharField(max_length=128, blank=False) + created = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if not self.key: + self.key = self.generate_key() + return super().save(*args, **kwargs) + + @classmethod + def generate_key(cls): + return binascii.hexlify(os.urandom(20)).decode() + + def __str__(self): + return f"{self.name} ({self.user.username})" + + class GlobalSettings(models.Model): LANDING_PAGE_LOGIN = "login" LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks" diff --git a/bookmarks/styles/bookmark-details.css b/bookmarks/styles/bookmark-details.css index 303f8e3..0f0e6f8 100644 --- a/bookmarks/styles/bookmark-details.css +++ b/bookmarks/styles/bookmark-details.css @@ -1,5 +1,8 @@ -/* Common styles */ .bookmark-details { + .modal-container { + width: 100%; + } + .title { word-break: break-word; display: -webkit-box; @@ -95,10 +98,3 @@ align-items: center; } } - -/* Bookmark details view specific */ -.bookmark-details.page { - display: flex; - flex-direction: column; - gap: var(--unit-6); -} diff --git a/bookmarks/styles/crud.css b/bookmarks/styles/crud.css index 699df38..0a881ad 100644 --- a/bookmarks/styles/crud.css +++ b/bookmarks/styles/crud.css @@ -37,29 +37,29 @@ } } } +} - .crud-table { - .btn.btn-link { - padding: 0; - height: unset; - } +.crud-table { + .btn.btn-link { + padding: 0; + height: unset; + } - th, - td { - max-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + th, + td { + max-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - th.actions, - td.actions { - width: 1%; - max-width: 150px; + th.actions, + td.actions { + width: 1%; + max-width: 150px; - *:not(:last-child) { - margin-right: var(--unit-2); - } + *:not(:last-child) { + margin-right: var(--unit-2); } } } diff --git a/bookmarks/styles/theme/modals.css b/bookmarks/styles/theme/modals.css index 08840f8..6357f05 100644 --- a/bookmarks/styles/theme/modals.css +++ b/bookmarks/styles/theme/modals.css @@ -62,7 +62,6 @@ gap: var(--unit-4); max-height: 75vh; max-width: var(--control-width-md); - width: 100%; & .modal-header { display: flex; diff --git a/bookmarks/templates/settings/create_api_token_modal.html b/bookmarks/templates/settings/create_api_token_modal.html new file mode 100644 index 0000000..544e478 --- /dev/null +++ b/bookmarks/templates/settings/create_api_token_modal.html @@ -0,0 +1,45 @@ + +
+ {% csrf_token %} + +
+
diff --git a/bookmarks/templates/settings/integrations.html b/bookmarks/templates/settings/integrations.html index 3b83a4b..2fe484f 100644 --- a/bookmarks/templates/settings/integrations.html +++ b/bookmarks/templates/settings/integrations.html @@ -25,7 +25,10 @@

The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application first. Here's how it works:

@@ -108,28 +207,4 @@

- - {% endblock %} diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py index cd15c2e..51d2218 100644 --- a/bookmarks/tests/helpers.py +++ b/bookmarks/tests/helpers.py @@ -14,10 +14,16 @@ from django.test import override_settings from django.utils import timezone from django.utils.crypto import get_random_string from rest_framework import status -from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase -from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User +from bookmarks.models import ( + ApiToken, + Bookmark, + BookmarkAsset, + BookmarkBundle, + Tag, + User, +) class BookmarkFactoryMixin: @@ -275,6 +281,15 @@ class BookmarkFactoryMixin: user.profile.save() return user + def setup_api_token(self, user: User = None, name: str = ""): + if user is None: + user = self.get_or_create_test_user() + if not name: + name = get_random_string(length=32) + token = ApiToken(user=user, name=name) + token.save() + return token + def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]): all_tags = [] for bookmark in bookmarks: @@ -361,9 +376,9 @@ class TagCloudTestMixin(TestCase, HtmlTestMixin): class LinkdingApiTestCase(APITestCase): def authenticate(self): - self.api_token = Token.objects.get_or_create( - user=self.get_or_create_test_user() - )[0] + user = self.get_or_create_test_user() + self.api_token = ApiToken(user=user, name="Test Token") + self.api_token.save() self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key) def get(self, url, expected_status_code=status.HTTP_200_OK): diff --git a/bookmarks/tests/test_auth_api.py b/bookmarks/tests/test_auth_api.py index fba482c..cf96132 100644 --- a/bookmarks/tests/test_auth_api.py +++ b/bookmarks/tests/test_auth_api.py @@ -1,6 +1,5 @@ from django.urls import reverse from rest_framework import status -from rest_framework.authtoken.models import Token from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin @@ -8,9 +7,7 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin class AuthApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): def authenticate(self, keyword): - self.api_token = Token.objects.get_or_create( - user=self.get_or_create_test_user() - )[0] + self.api_token = self.setup_api_token() self.client.credentials(HTTP_AUTHORIZATION=f"{keyword} {self.api_token.key}") def test_auth_with_token_keyword(self): diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py index ea77a0c..749c10b 100644 --- a/bookmarks/tests/test_bookmarks_api.py +++ b/bookmarks/tests/test_bookmarks_api.py @@ -9,7 +9,6 @@ from django.test import override_settings from django.urls import reverse from django.utils import timezone from rest_framework import status -from rest_framework.authtoken.models import Token from rest_framework.response import Response import bookmarks.services.bookmarks @@ -35,9 +34,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): self.mock_assets_upload_snapshot_patcher.stop() def authenticate(self): - self.api_token = Token.objects.get_or_create( - user=self.get_or_create_test_user() - )[0] + self.api_token = self.setup_api_token() self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key) def assertBookmarkListEqual(self, data_list, bookmarks): diff --git a/bookmarks/tests/test_bookmarks_api_performance.py b/bookmarks/tests/test_bookmarks_api_performance.py index 9a00117..3fb25d0 100644 --- a/bookmarks/tests/test_bookmarks_api_performance.py +++ b/bookmarks/tests/test_bookmarks_api_performance.py @@ -3,7 +3,6 @@ from django.db.utils import DEFAULT_DB_ALIAS from django.test.utils import CaptureQueriesContext from django.urls import reverse from rest_framework import status -from rest_framework.authtoken.models import Token from bookmarks.models import GlobalSettings from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin @@ -12,9 +11,7 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): def setUp(self) -> None: - self.api_token = Token.objects.get_or_create( - user=self.get_or_create_test_user() - )[0] + self.api_token = self.setup_api_token() self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key) # create global settings diff --git a/bookmarks/tests/test_bookmarks_api_permissions.py b/bookmarks/tests/test_bookmarks_api_permissions.py index f28e70a..83ea958 100644 --- a/bookmarks/tests/test_bookmarks_api_permissions.py +++ b/bookmarks/tests/test_bookmarks_api_permissions.py @@ -2,16 +2,13 @@ import urllib.parse from django.urls import reverse from rest_framework import status -from rest_framework.authtoken.models import Token from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): def authenticate(self) -> None: - self.api_token = Token.objects.get_or_create( - user=self.get_or_create_test_user() - )[0] + self.api_token = self.setup_api_token() self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key) def test_list_bookmarks_requires_authentication(self): diff --git a/bookmarks/tests/test_settings_integrations_view.py b/bookmarks/tests/test_settings_integrations_view.py index 6514ade..93fe4df 100644 --- a/bookmarks/tests/test_settings_integrations_view.py +++ b/bookmarks/tests/test_settings_integrations_view.py @@ -1,12 +1,11 @@ from django.test import TestCase from django.urls import reverse -from rest_framework.authtoken.models import Token -from bookmarks.tests.helpers import BookmarkFactoryMixin -from bookmarks.models import FeedToken +from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin +from bookmarks.models import ApiToken, FeedToken -class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin): +class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): def setUp(self) -> None: user = self.get_or_create_test_user() @@ -28,22 +27,89 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin): reverse("login") + "?next=" + reverse("linkding:settings.integrations"), ) - def test_should_generate_api_token_if_not_exists(self): - self.assertEqual(Token.objects.count(), 0) + def test_create_api_token(self): + response = self.client.post( + reverse("linkding:settings.integrations.create_api_token"), + {"name": "My Test Token"}, + ) - self.client.get(reverse("linkding:settings.integrations")) - - self.assertEqual(Token.objects.count(), 1) - token = Token.objects.first() + self.assertRedirects(response, reverse("linkding:settings.integrations")) + self.assertEqual(ApiToken.objects.count(), 1) + token = ApiToken.objects.first() self.assertEqual(token.user, self.user) + self.assertEqual(token.name, "My Test Token") - def test_should_not_generate_api_token_if_exists(self): - Token.objects.get_or_create(user=self.user) - self.assertEqual(Token.objects.count(), 1) + def test_create_api_token_with_empty_name(self): + self.client.post( + reverse("linkding:settings.integrations.create_api_token"), + {"name": ""}, + ) - self.client.get(reverse("linkding:settings.integrations")) + self.assertEqual(ApiToken.objects.count(), 1) + token = ApiToken.objects.first() + self.assertEqual(token.name, "API Token") - self.assertEqual(Token.objects.count(), 1) + def test_create_api_token_shows_key_once(self): + self.client.post( + reverse("linkding:settings.integrations.create_api_token"), + {"name": "My Token"}, + ) + + # First load should show the token + response = self.client.get(reverse("linkding:settings.integrations")) + token = ApiToken.objects.first() + self.assertContains(response, token.key) + + # Second load should not show the token + response = self.client.get(reverse("linkding:settings.integrations")) + self.assertNotContains(response, token.key) + + def test_delete_api_token(self): + token = self.setup_api_token(name="To Delete") + + response = self.client.post( + reverse("linkding:settings.integrations.delete_api_token"), + {"token_id": token.id}, + ) + + self.assertRedirects(response, reverse("linkding:settings.integrations")) + self.assertEqual(ApiToken.objects.count(), 0) + + def test_delete_api_token_wrong_user(self): + other_user = self.setup_user(name="other") + token = self.setup_api_token(user=other_user, name="Other's Token") + + response = self.client.post( + reverse("linkding:settings.integrations.delete_api_token"), + {"token_id": token.id}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(ApiToken.objects.count(), 1) + + def test_list_api_tokens(self): + self.setup_api_token(name="Token 1") + self.setup_api_token(name="Token 2") + + other_user = self.setup_user(name="other") + self.setup_api_token(user=other_user, name="Other's Token") + + response = self.client.get(reverse("linkding:settings.integrations")) + soup = self.make_soup(response.content.decode()) + + section = soup.find("turbo-frame", id="api-section") + table = section.find("table") + rows = table.find_all("tr") + + self.assertEqual(len(rows), 3) + + first_row_cells = rows[1].find_all("td") + self.assertEqual(first_row_cells[0].get_text(strip=True), "Token 2") + self.assertIsNotNone(first_row_cells[1].get_text(strip=True)) + + second_row_cells = rows[2].find_all("td") + self.assertEqual(second_row_cells[0].get_text(strip=True), "Token 1") + self.assertIsNotNone(second_row_cells[1].get_text(strip=True)) def test_should_generate_feed_token_if_not_exists(self): self.assertEqual(FeedToken.objects.count(), 0) diff --git a/bookmarks/tests_e2e/e2e_test_settings_integrations.py b/bookmarks/tests_e2e/e2e_test_settings_integrations.py new file mode 100644 index 0000000..cd50b0f --- /dev/null +++ b/bookmarks/tests_e2e/e2e_test_settings_integrations.py @@ -0,0 +1,67 @@ +from django.urls import reverse +from playwright.sync_api import sync_playwright, expect + +from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase + + +class SettingsIntegrationsE2ETestCase(LinkdingE2ETestCase): + def test_create_api_token(self): + with sync_playwright() as p: + self.open(reverse("linkding:settings.integrations"), p) + + # Click create API token button + self.page.get_by_text("Create API token").click() + + # Wait for modal to appear + modal = self.page.locator(".modal") + expect(modal).to_be_visible() + + # Enter custom token name + token_name_input = modal.locator("#token-name") + token_name_input.fill("") + token_name_input.fill("My Test Token") + + # Confirm the dialog + modal.page.get_by_role("button", name="Create Token").click() + + # Verify the API token key is shown in the input + new_token_input = self.page.locator("#new-token-key") + expect(new_token_input).to_be_visible() + token_value = new_token_input.input_value() + self.assertTrue(len(token_value) > 0) + + # Verify the API token is now listed in the table + token_table = self.page.locator("table.crud-table") + expect(token_table).to_be_visible() + expect(token_table.get_by_text("My Test Token")).to_be_visible() + + # Verify the dialog is gone + expect(modal).to_be_hidden() + + # Reload the page to verify the API token key is only shown once + self.page.reload() + + # Token key input should no longer be visible + expect(new_token_input).not_to_be_visible() + + # But the token should still be listed in the table + expect(token_table.get_by_text("My Test Token")).to_be_visible() + + def test_delete_api_token(self): + self.setup_api_token(name="Token To Delete") + + with sync_playwright() as p: + self.open(reverse("linkding:settings.integrations"), p) + + token_table = self.page.locator("table.crud-table") + expect(token_table.get_by_text("Token To Delete")).to_be_visible() + + # Click delete button for the token + token_row = token_table.locator("tr").filter(has_text="Token To Delete") + token_row.get_by_role("button", name="Delete").click() + + # Confirm deletion + self.locate_confirm_dialog().get_by_text("Confirm").click() + + # Verify the token is removed from the table + expect(token_table.get_by_text("Token To Delete")).not_to_be_visible() diff --git a/bookmarks/urls.py b/bookmarks/urls.py index 7c33b7c..c614c46 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -63,6 +63,16 @@ urlpatterns = [ views.settings.integrations, name="settings.integrations", ), + path( + "settings/integrations/create-api-token", + views.settings.create_api_token, + name="settings.integrations.create_api_token", + ), + path( + "settings/integrations/delete-api-token", + views.settings.delete_api_token, + name="settings.integrations.delete_api_token", + ), path("settings/import", views.settings.bookmark_import, name="settings.import"), path("settings/export", views.settings.bookmark_export, name="settings.export"), # Toasts diff --git a/bookmarks/views/access.py b/bookmarks/views/access.py index 3452bbc..936e143 100644 --- a/bookmarks/views/access.py +++ b/bookmarks/views/access.py @@ -1,6 +1,6 @@ from django.http import Http404 -from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast +from bookmarks.models import ApiToken, Bookmark, BookmarkAsset, BookmarkBundle, Toast from bookmarks.type_defs import HttpRequest @@ -65,3 +65,10 @@ def toast_write(request: HttpRequest, toast_id: int | str): return Toast.objects.get(pk=toast_id, owner=request.user) except Toast.DoesNotExist: raise Http404("Toast does not exist") + + +def api_token_write(request: HttpRequest, token_id: int | str): + try: + return ApiToken.objects.get(id=token_id, user=request.user) + except (ApiToken.DoesNotExist, ValueError): + raise Http404("API token does not exist") diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py index 7e6714e..0eda8c5 100644 --- a/bookmarks/views/settings.py +++ b/bookmarks/views/settings.py @@ -12,9 +12,9 @@ from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render from django.urls import reverse from django.utils import timezone -from rest_framework.authtoken.models import Token from bookmarks.models import ( + ApiToken, Bookmark, UserProfileForm, FeedToken, @@ -25,6 +25,7 @@ from bookmarks.services import exporter, tasks from bookmarks.services import importer from bookmarks.type_defs import HttpRequest from bookmarks.utils import app_version +from bookmarks.views import access logger = logging.getLogger(__name__) @@ -168,7 +169,14 @@ def get_ttl_hash(seconds=3600): @login_required def integrations(request): application_url = request.build_absolute_uri(reverse("linkding:bookmarks.new")) - api_token = Token.objects.get_or_create(user=request.user)[0] + + api_tokens = ApiToken.objects.filter(user=request.user).order_by("-created") + api_token_key = request.session.pop("api_token_key", None) + api_token_name = request.session.pop("api_token_name", None) + api_success_message = _find_message_with_tag( + messages.get_messages(request), "api_success_message" + ) + feed_token = FeedToken.objects.get_or_create(user=request.user)[0] all_feed_url = request.build_absolute_uri( reverse("linkding:feeds.all", args=[feed_token.key]) @@ -182,12 +190,16 @@ def integrations(request): public_shared_feed_url = request.build_absolute_uri( reverse("linkding:feeds.public_shared") ) + return render( request, "settings/integrations.html", { "application_url": application_url, - "api_token": api_token.key, + "api_tokens": api_tokens, + "api_token_key": api_token_key, + "api_token_name": api_token_name, + "api_success_message": api_success_message, "all_feed_url": all_feed_url, "unread_feed_url": unread_feed_url, "shared_feed_url": shared_feed_url, @@ -196,6 +208,46 @@ def integrations(request): ) +@login_required +def create_api_token(request): + if request.method == "POST": + name = request.POST.get("name", "").strip() + if not name: + name = "API Token" + + token = ApiToken(user=request.user, name=name) + token.save() + + request.session["api_token_key"] = token.key + request.session["api_token_name"] = token.name + + messages.success( + request, + f'API token "{token.name}" created successfully', + "api_success_message", + ) + + return HttpResponseRedirect(reverse("linkding:settings.integrations")) + + return render(request, "settings/create_api_token_modal.html") + + +@login_required +def delete_api_token(request): + if request.method == "POST": + token_id = request.POST.get("token_id") + token = access.api_token_write(request, token_id) + token_name = token.name + token.delete() + messages.success( + request, + f'API token "{token_name}" has been deleted.', + "api_success_message", + ) + + return HttpResponseRedirect(reverse("linkding:settings.integrations")) + + @login_required def bookmark_import(request: HttpRequest): import_file = request.FILES.get("import_file")