Skip to content

Commit

Permalink
feat(users): allow users to manage application tokens
Browse files Browse the repository at this point in the history
See #318
  • Loading branch information
Jenselme committed Nov 24, 2024
1 parent 72c5282 commit c703c9e
Show file tree
Hide file tree
Showing 10 changed files with 332 additions and 2 deletions.
1 change: 1 addition & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,3 +619,4 @@ def before_send_to_sentry(event, hint):
ARTICLE_FETCH_TIMEOUT = env.int("LEGADILO_ARTICLE_FETCH_TIMEOUT", default=50)
RSS_FETCH_TIMEOUT = env.int("LEGADILO_RSS_FETCH_TIMEOUT", default=300)
CONTACT_EMAIL = env.str("LEGADILO_CONTACT_EMAIL", default=None)
TOKEN_LENGTH = 50
55 changes: 55 additions & 0 deletions legadilo/templates/users/manage_tokens.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{% extends "base.html" %}

{% load i18n crispy_forms_tags %}

{% block title %}
{% translate "Manage API tokens" %}
{% endblock title %}
{% block content %}
<h1>{% translate "Manage API tokens" %}</h1>
{% if new_application_token %}
<p class="alert-success">
{% blocktranslate with token_title=new_application_token.title %}
Successfully created token <em>{{ token_title }}</em>. Copy the token below, you
won’t be able to get it back.
{% endblocktranslate %}
<pre>{{ new_application_token.token }}</pre>
</p>
{% endif %}
<h2 class="mt-4">{% translate "List of tokens" %}</h2>
<ul class="list-group">
{% for token in tokens %}
<li class="list-group-item" hx-target="this" hx-swap="outerHTML">
<strong>{{ token.title }}</strong>
<span>
{% blocktranslate with created_at=token.created_at|date:"SHORT_DATETIME_FORMAT" %}
Created on {{ created_at }}
{% endblocktranslate %}
</span>
<span>
{% if token.validity_end %}
{% blocktranslate with validity_end=token.validity_end|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ validity_end }}
{% endblocktranslate %}
{% endif %}
</span>
<form hx-post="{% url 'users:delete_token' token_id=token.id %}"
data-modal-id="danger-modal"
data-modal-title="{% translate 'Confirm token deletion' %}"
data-modal-body="{% blocktranslate with token_title=token.title %}Are you sure you want to delete the token '{{ token_title }}'{% endblocktranslate %}"
hx-confirm="Delete?">
{% csrf_token %}
<button type="submit" class="btn btn-danger">{% translate "Delete" %}</button>
</form>
</li>
{% empty %}
<div class="list-group-item">{% translate "No token found" %}</div>
{% endfor %}
</ul>
<h2 class="mt-4">{% translate "Create new token" %}</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary">{% translate "Create" %}</button>
</form>
{% endblock content %}
3 changes: 3 additions & 0 deletions legadilo/templates/users/user_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ <h2>{{ object.email }}</h2>
<a class="btn btn-primary"
href="{% url 'import_export:import_export_articles' %}"
role="button">{% translate "Import/Export articles" %}</a>
<a class="btn btn-primary"
href="{% url 'users:list_tokens' %}"
role="button">{% translate "Manage API tokens" %}</a>
</div>
</div>
<!-- End Action buttons -->
Expand Down
7 changes: 6 additions & 1 deletion legadilo/users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from django.utils.translation import gettext_lazy as _

from legadilo.users.forms import UserAdminChangeForm, UserAdminCreationForm
from legadilo.users.models import Notification, UserSettings
from legadilo.users.models import ApplicationToken, Notification, UserSettings

User = get_user_model()

Expand Down Expand Up @@ -90,3 +90,8 @@ class NotificationAdmin(admin.ModelAdmin):
autocomplete_fields = ("user",)
list_display = ("title", "created_at", "user", "is_read")
list_filter = ("is_read",)


@admin.register(ApplicationToken)
class ApplicationTokenAdmin(admin.ModelAdmin):
autocomplete_fields = ("user",)
73 changes: 73 additions & 0 deletions legadilo/users/migrations/0006_applicationtoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Legadilo
# Copyright (C) 2023-2024 by Legadilo contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

# Generated by Django 5.1.3 on 2024-11-23 20:47

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0005_notification_link_notification_link_text_and_more"),
]

operations = [
migrations.CreateModel(
name="ApplicationToken",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("title", models.CharField(max_length=255)),
("token", models.CharField(max_length=255)),
(
"validity_end",
models.DateTimeField(
blank=True,
help_text="Leave empty to have a token that will last until deletion.",
null=True,
verbose_name="Validity end",
),
),
("last_used_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="application_tokens",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["title"],
"constraints": [
models.UniqueConstraint(
fields=("token",), name="users_applicationtoken_token_unique"
),
models.UniqueConstraint(
fields=("title", "user"), name="users_applicationtoken_title_user_unique"
),
],
},
),
]
3 changes: 2 additions & 1 deletion legadilo/users/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from .application_token import ApplicationToken
from .notification import Notification
from .user import User
from .user_settings import UserSettings

__all__ = ["Notification", "User", "UserSettings"]
__all__ = ["ApplicationToken", "Notification", "User", "UserSettings"]
79 changes: 79 additions & 0 deletions legadilo/users/models/application_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Legadilo
# Copyright (C) 2023-2024 by Legadilo contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from __future__ import annotations

import secrets
from datetime import datetime
from typing import TYPE_CHECKING

from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _

from .user import User

if TYPE_CHECKING:
from django_stubs_ext.db.models import TypedModelMeta
else:
TypedModelMeta = object


class ApplicationTokenManager(models.Manager["ApplicationToken"]):
def get_queryset(self):
return super().get_queryset().defer("token")

def create_new_token(
self, user: User, title: str, validity_end: datetime | None = None
) -> ApplicationToken:
return self.create(
title=title,
token=secrets.token_urlsafe(settings.TOKEN_LENGTH),
validity_end=validity_end,
user=user,
)


class ApplicationToken(models.Model):
title = models.CharField(max_length=255)
token = models.CharField(max_length=255)
validity_end = models.DateTimeField(
verbose_name=_("Validity end"),
help_text=_("Leave empty to have a token that will last until deletion."),
null=True,
blank=True,
)
last_used_at = models.DateTimeField(null=True, blank=True)

created_at = models.DateTimeField(auto_now_add=True)

user = models.ForeignKey(
"users.User", related_name="application_tokens", on_delete=models.CASCADE
)

objects = ApplicationTokenManager()

class Meta(TypedModelMeta):
ordering = ["title"]
constraints = [
models.UniqueConstraint(fields=["token"], name="%(app_label)s_%(class)s_token_unique"),
models.UniqueConstraint(
fields=["title", "user"], name="%(app_label)s_%(class)s_title_user_unique"
),
]

def __str__(self):
return f"ApplicationToken(id={self.id}, user_id={self.user_id}, title={self.title})"
2 changes: 2 additions & 0 deletions legadilo/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@
path("~settings/", view=views.user_update_settings_view, name="update_settings"),
path("<int:pk>/", view=views.user_detail_view, name="detail"),
path("notifications/", views.list_notifications_view, name="list_notifications"),
path("tokens/", views.list_tokens_view, name="list_tokens"),
path("tokens/<int:token_id>/delete/", views.delete_token_view, name="delete_token"),
]
3 changes: 3 additions & 0 deletions legadilo/users/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from .manage_tokens_views import delete_token_view, list_tokens_view
from .notifications_views import list_notifications_view
from .user_views import (
user_detail_view,
Expand All @@ -23,7 +24,9 @@
)

__all__ = [
"delete_token_view",
"list_notifications_view",
"list_tokens_view",
"user_detail_view",
"user_redirect_view",
"user_update_settings_view",
Expand Down
108 changes: 108 additions & 0 deletions legadilo/users/views/manage_tokens_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Legadilo
# Copyright (C) 2023-2024 by Legadilo contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods, require_POST

from legadilo.core.forms.widgets import AutocompleteSelectWidget, DateTimeWidget
from legadilo.core.models import Timezone
from legadilo.users.models import ApplicationToken
from legadilo.users.user_types import AuthenticatedHttpRequest


class CreateTokenForm(forms.ModelForm):
timezone = forms.ModelChoiceField(
Timezone.objects.all(),
required=True,
widget=AutocompleteSelectWidget(),
help_text=_("The timezone in which the validity end date should be understood."),
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["validity_end"].widget = DateTimeWidget()

class Meta:
model = ApplicationToken
fields = ("title", "validity_end")

def clean(self):
super().clean()

if self.cleaned_data["validity_end"] and self.cleaned_data["timezone"]:
self.cleaned_data["validity_end"] = self.cleaned_data["validity_end"].replace(
tzinfo=self.cleaned_data["timezone"].zone_info
)


@login_required
@require_http_methods(["GET", "POST"])
def list_tokens_view(request: AuthenticatedHttpRequest) -> TemplateResponse:
form = CreateTokenForm(initial={"timezone": request.user.settings.timezone})
new_application_token = None

if request.method == "POST":
form = CreateTokenForm(request.POST)
if form.is_valid():
new_application_token, form = _create_token(request, form)

return TemplateResponse(
request,
"users/manage_tokens.html",
{
"new_application_token": new_application_token,
"tokens": ApplicationToken.objects.filter(user=request.user),
"form": form,
},
)


def _create_token(
request: AuthenticatedHttpRequest, form: CreateTokenForm
) -> tuple[ApplicationToken | None, CreateTokenForm]:
try:
new_application_token = ApplicationToken.objects.create_new_token(
request.user, form.cleaned_data["title"], form.cleaned_data["validity_end"]
)
form = CreateTokenForm(
initial={
"validity_end": form.cleaned_data["validity_end"].replace(tzinfo=None).isoformat()
if form.cleaned_data["validity_end"]
else "",
"timezone": form.cleaned_data["timezone"],
}
)
except IntegrityError:
new_application_token = None
messages.error(request, _("A token already exists with this name"))

return new_application_token, form


@login_required
@require_POST
def delete_token_view(request: AuthenticatedHttpRequest, token_id: int) -> HttpResponse:
token = get_object_or_404(ApplicationToken, id=token_id, user=request.user)
token.delete()

return HttpResponse()

0 comments on commit c703c9e

Please sign in to comment.