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 @@
+
The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application first. Here's how it works:
The following token can be used to authenticate 3rd-party applications against the REST API:
-- Please treat this token as you would any other credential. - Any party with access to this token can access and manage all your bookmarks. - If you think that a token was compromised you can revoke (delete) it in the admin panel. - After deleting the token, a new one will be generated when you reload this settings page. -
-Copy this token now, it will only be shown once:
+ ++ API tokens can be used to authenticate 3rd-party applications against the REST API. Please treat + tokens as you would any other credential. Any party with access to a token can access and manage all + your bookmarks. +
+ + {% if api_tokens %} + + {% endif %} + + Create API token +@@ -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")