diff --git a/pdfding/core/settings/base.py b/pdfding/core/settings/base.py index e3ec5232..070c1d34 100644 --- a/pdfding/core/settings/base.py +++ b/pdfding/core/settings/base.py @@ -39,6 +39,8 @@ 'allauth.socialaccount', 'allauth.socialaccount.providers.openid_connect', 'allauth.mfa', + 'rest_framework', + 'rest_framework.authtoken', 'django_htmx', 'huey.contrib.djhuey', 'admin', @@ -207,6 +209,17 @@ log_level = environ.get('LOG_LEVEL', 'ERROR') +# Django REST Framework Configuration +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], +} + LOGGING = { 'version': 1, 'disable_existing_loggers': False, diff --git a/pdfding/users/api.py b/pdfding/users/api.py new file mode 100644 index 00000000..d642ac4e --- /dev/null +++ b/pdfding/users/api.py @@ -0,0 +1,37 @@ +from rest_framework import serializers, status, viewsets +from rest_framework.authtoken.models import Token +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer +from rest_framework.response import Response + + +class TokenSerializer(serializers.ModelSerializer): + class Meta: + model = Token + fields = ['key', 'created'] + + +class TokenViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + renderer_classes = [TemplateHTMLRenderer, JSONRenderer] + serializer_class = TokenSerializer + + def list(self, request): + token = Token.objects.filter(user=request.user).first() + return Response({'token': token}, template_name='partials/token_management.html') + + def create(self, request): + token, created = Token.objects.get_or_create(user=request.user) + return Response({'token': token}, template_name='partials/token_management.html', status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) + + @action(detail=False, methods=['post'], url_path='rotate') + def rotate(self, request): + Token.objects.filter(user=request.user).delete() + token = Token.objects.create(user=request.user) + return Response({'token': token}, template_name='partials/token_management.html') + + @action(detail=False, methods=['post'], url_path='delete') + def delete(self, request): + Token.objects.filter(user=request.user).delete() + return Response({'token': None}, template_name='partials/token_management.html') diff --git a/pdfding/users/templates/access_token.html b/pdfding/users/templates/access_token.html new file mode 100644 index 00000000..a0cbb5cf --- /dev/null +++ b/pdfding/users/templates/access_token.html @@ -0,0 +1,68 @@ +{% extends 'layouts/blank.html' %} +{% load i18n %} + +{% block content %} +{% trans "Edit" as edit %} +
+
+ {% include 'includes/settings_sidebar.html' with page='access_token' %} +
+
+
+ {% trans 'Access Token' %} +

{% trans 'Generate a single API token to authenticate your requests without sharing your password.' %}

+
+
+ {% if token %} +
+
+ {% if token.key|length > 20 %} + {{ token.key|slice:":4" }}...{{ token.key|slice:"-4:" }} + {% else %} + {{ token.key }} + {% endif %} +
+
+ {% trans 'Created' %}: {{ token.created|date:"SHORT_DATE_FORMAT" }} +
+
+ {% else %} + {% trans 'No API token generated yet' %} + {% endif %} +
+ +
+ {% if token %} + + +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/pdfding/users/templates/account_settings.html b/pdfding/users/templates/account_settings.html index 4870ea36..c08b3938 100644 --- a/pdfding/users/templates/account_settings.html +++ b/pdfding/users/templates/account_settings.html @@ -7,8 +7,8 @@
{% include 'includes/settings_sidebar.html' with page='account_settings' %}
-
-
+
{% trans 'Account Information' %} @@ -100,7 +100,7 @@
{% endif %} +
- {% endblock %} diff --git a/pdfding/users/templates/includes/settings_sidebar.html b/pdfding/users/templates/includes/settings_sidebar.html index 9454070e..166678e6 100644 --- a/pdfding/users/templates/includes/settings_sidebar.html +++ b/pdfding/users/templates/includes/settings_sidebar.html @@ -28,6 +28,14 @@ +
+ + + + + + +
diff --git a/pdfding/users/templates/partials/token_management.html b/pdfding/users/templates/partials/token_management.html new file mode 100644 index 00000000..1e27cbfa --- /dev/null +++ b/pdfding/users/templates/partials/token_management.html @@ -0,0 +1,51 @@ +{% load i18n %} + +
+
+ {% if token %} +
+
+ {% if token.key|length > 20 %} + {{ token.key|slice:":4" }}...{{ token.key|slice:"-4:" }} + {% else %} + {{ token.key }} + {% endif %} +
+
+ {% trans 'Created' %}: {{ token.created|date:"SHORT_DATE_FORMAT" }} +
+
+ {% else %} + {% trans 'No API token generated yet' %} + {% endif %} +
+ +
+ {% if token %} + + +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
diff --git a/pdfding/users/urls.py b/pdfding/users/urls.py index aa0eb6e5..41497f56 100644 --- a/pdfding/users/urls.py +++ b/pdfding/users/urls.py @@ -1,8 +1,14 @@ -from django.urls import path +from django.urls import include, path +from rest_framework.routers import SimpleRouter from users import views +from users.api import TokenViewSet + +router = SimpleRouter() +router.register('token', TokenViewSet, basename='token') urlpatterns = [ path('account_settings', views.account_settings, name="account_settings"), + path('access_token', views.access_token_settings, name="access_token"), path('danger_settings', views.danger_settings, name="danger_settings"), path('ui_settings', views.ui_settings, name="ui_settings"), path('viewer_settings', views.viewer_settings, name="viewer_settings"), @@ -16,4 +22,5 @@ path('change_collection/', views.ChangeCollection.as_view(), name="change_collection"), path('open_collapse_tags', views.OpenCollapseTags.as_view(), name="open_collapse_tags"), path('signatures', views.Signatures.as_view(), name="signatures"), + path('', include(router.urls)), ] diff --git a/pdfding/users/views.py b/pdfding/users/views.py index 1e2b06c4..f4f0b071 100644 --- a/pdfding/users/views.py +++ b/pdfding/users/views.py @@ -18,6 +18,7 @@ from django.views import View from django_htmx.http import HttpResponseClientRefresh from pdf.services.workspace_services import check_if_collection_part_of_workspace +from rest_framework.authtoken.models import Token from users import forms from users.models import Profile from users.service import create_demo_user, get_secondary_color @@ -32,6 +33,18 @@ def account_settings(request): return render(request, 'account_settings.html', {'uses_social': uses_social}) +def access_token_settings(request): + """View for the access token settings page""" + + try: + token = Token.objects.get(user=request.user) + except Token.DoesNotExist: + token = None + + # pragma: no cover + return render(request, 'access_token.html', context={'token': token}) + + def ui_settings(request): # pragma: no cover """View for the ui settings page""" diff --git a/poetry.lock b/poetry.lock index d361b5c9..be3d6960 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "argon2-cffi" @@ -793,6 +793,21 @@ files = [ asgiref = ">=3.6" django = ">=4.2" +[[package]] +name = "djangorestframework" +version = "3.15.0" +description = "Web APIs for Django, made easy." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "djangorestframework-3.15.0-py3-none-any.whl", hash = "sha256:5fa616048a7ec287fdaab3148aa5151efb73f7f8be1e23a9d18484e61e672695"}, + {file = "djangorestframework-3.15.0.tar.gz", hash = "sha256:3f4a263012e1b263bf49a4907eb4cfe14de840a09b1ba64596d01a9c54835919"}, +] + +[package.dependencies] +django = ">=3.0" + [[package]] name = "fido2" version = "2.1.1" @@ -1763,13 +1778,6 @@ optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, @@ -2115,4 +2123,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.1" python-versions = ">=3.11 <4.0" -content-hash = "31b4d218be8579b8302e8d065ef48dcfa8f2c1b3a591787531ab0d5699a858cf" +content-hash = "523ff6644639a0d35c20a345619d7122b3bb4251b9ccf1242ece55310b544ce0" diff --git a/pyproject.toml b/pyproject.toml index f5340686..3828ba69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ license-files = ["LICENSE"] python = ">=3.11 <4.0" django = "==5.2.13" django-allauth = { extras = ["mfa", "socialaccount"], version = "==65.15.1" } +djangorestframework = "==3.15.0" django-htmx = "==1.27.0" gunicorn = "==25.3.0" huey = "==2.6.0"