Add apps settings pages

This commit is contained in:
Andrew Godwin 2023-05-03 10:39:00 -06:00
parent 55de31e3de
commit 32e74a620d
14 changed files with 279 additions and 34 deletions

View file

@ -1,3 +1,5 @@
import secrets
from django.db import models
@ -17,3 +19,23 @@ class Application(models.Model):
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@classmethod
def create(
cls,
client_name: str,
redirect_uris: str,
website: str | None,
scopes: str | None = None,
):
client_id = "tk-" + secrets.token_urlsafe(16)
client_secret = secrets.token_urlsafe(40)
return cls.objects.create(
name=client_name,
website=website,
client_id=client_id,
client_secret=client_secret,
redirect_uris=redirect_uris,
scopes=scopes or "read",
)

View file

@ -1,3 +1,4 @@
import urlman
from django.db import models
@ -37,6 +38,9 @@ class Token(models.Model):
updated = models.DateTimeField(auto_now=True)
revoked = models.DateTimeField(blank=True, null=True)
class urls(urlman.Urls):
edit = "/@{self.identity.handle}/settings/tokens/{self.id}/"
def has_scope(self, scope: str):
"""
Returns if this token has the given scope.

View file

@ -1,5 +1,3 @@
import secrets
from hatchway import QueryOrBody, api_view
from .. import schemas
@ -14,14 +12,10 @@ def add_app(
scopes: QueryOrBody[None | str] = None,
website: QueryOrBody[None | str] = None,
) -> schemas.Application:
client_id = "tk-" + secrets.token_urlsafe(16)
client_secret = secrets.token_urlsafe(40)
application = Application.objects.create(
name=client_name,
application = Application.create(
client_name=client_name,
website=website,
client_id=client_id,
client_secret=client_secret,
redirect_uris=redirect_uris,
scopes=scopes or "read",
scopes=scopes,
)
return schemas.Application.from_orm(application)

View file

@ -292,6 +292,6 @@ class Config(models.Model):
class DomainOptions(pydantic.BaseModel):
site_name: str = ""
site_icon: UploadedImage | None = None # type: ignore
site_icon: UploadedImage | None = None
hide_login: bool = False
custom_css: str = ""

View file

@ -278,6 +278,7 @@ header menu a small {
nav {
padding: 10px 0px 20px 5px;
border-radius: 5px;
}
nav hr {
@ -377,7 +378,6 @@ nav .identity-banner a:hover {
.settings nav {
width: var(--width-sidebar-medium);
background: var(--color-bg-menu);
border-radius: 0 0 5px 0;
}
.settings nav h2 {
@ -1095,6 +1095,25 @@ blockquote {
border-left: 2px solid var(--color-bg-menu);
}
.secret .label {
background-color: var(--color-bg-menu);
padding: 3px 7px;
border-radius: 3px;
cursor: pointer;
}
.secret.visible .label {
display: none;
}
.secret .value {
display: none;
}
.secret.visible .value {
display: inline;
}
/* Logged out homepage */
@ -1301,9 +1320,10 @@ table.metadata td .emoji {
min-width: 16px;
}
/* Announcements */
/* Announcements/Flash messages */
.announcement {
.announcement,
.message {
background-color: var(--color-highlight);
border-radius: 5px;
margin: 10px 0 0 0;
@ -1311,7 +1331,12 @@ table.metadata td .emoji {
position: relative;
}
.announcement .dismiss {
.message {
background-color: var(--color-bg-menu);
}
.announcement .dismiss,
.message .dismiss {
position: absolute;
top: 5px;
right: 10px;

View file

@ -5,6 +5,7 @@ import signal
import time
import traceback
import uuid
from collections.abc import Callable
from asgiref.sync import async_to_sync, sync_to_async
from django.conf import settings
@ -21,7 +22,7 @@ class LoopingTask:
copy running at a time.
"""
def __init__(self, callable):
def __init__(self, callable: Callable):
self.callable = callable
self.task: asyncio.Task | None = None

View file

@ -2,25 +2,12 @@ from django.conf import settings as djsettings
from django.contrib import admin as djadmin
from django.urls import include, path, re_path
from activities.views import (
compose,
debug,
posts,
timelines,
)
from activities.views import compose, debug, posts, timelines
from api.views import oauth
from core import views as core
from mediaproxy import views as mediaproxy
from stator import views as stator
from users.views import (
activitypub,
admin,
announcements,
auth,
identity,
settings,
)
from users.views.settings import follows
from users.views import activitypub, admin, announcements, auth, identity, settings
urlpatterns = [
path("", core.homepage),
@ -78,6 +65,21 @@ urlpatterns = [
settings.CsvFollowers.as_view(),
name="settings_export_followers_csv",
),
path(
"@<handle>/settings/tokens/",
settings.TokensRoot.as_view(),
name="settings_tokens",
),
path(
"@<handle>/settings/tokens/create/",
settings.TokenCreate.as_view(),
name="settings_token_create",
),
path(
"@<handle>/settings/tokens/<pk>/",
settings.TokenEdit.as_view(),
name="settings_token_edit",
),
path(
"admin/",
admin.AdminRoot.as_view(),

View file

@ -4,3 +4,13 @@
{{ announcement.html }}
</div>
{% endfor %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<div class="message {{ message.tags }}">
<a class="dismiss" title="Dismiss" _="on click remove closest <div/>"><i class="fa-solid fa-xmark"></i></a>
{{ message }}
</div>
{% endfor %}
</ul>
{% endif %}

View file

@ -14,6 +14,10 @@
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import/Export</span>
</a>
<a href="{% url "settings_tokens" handle=identity.handle %}" {% if section == "tokens" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-window-restore"></i>
<span>Authorized Apps</span>
</a>
<hr>
<h3>Tools</h3>
<a href="{% url "settings_follows" handle=identity.handle %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">

View file

@ -0,0 +1,27 @@
{% extends "settings/base.html" %}
{% block subtitle %}Add App{% endblock %}
{% block settings_content %}
<form action="." method="POST">
<h1>Create Personal Token</h1>
<p>
This lets you create a personal application token that you can
use to talk to the API.
</p>
<p>
<b>Do not create a token if someone else tells you to - it can let
them take over your account!</b>
</p>
{% csrf_token %}
<fieldset>
<legend>App Details</legend>
{% include "forms/_field.html" with field=form.name %}
{% include "forms/_field.html" with field=form.scope %}
</fieldset>
<div class="buttons">
<a href="{% url "settings_tokens" handle=identity.handle %}" class="button secondary left">Back</a>
<button>Create</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "settings/base.html" %}
{% block subtitle %}Token{% endblock %}
{% block settings_content %}
<h1>{{ token.application.name }}</h1>
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Application</legend>
<table class="metadata">
<tr>
<th>Name</th>
<td>{{ token.application.name }}</td>
</tr>
{% if token.application.website %}
<tr>
<th>Website</th>
<td>{{ token.application.website }}</td>
</tr>
{% endif %}
<tr>
<th>Authorized</th>
<td>{{ token.created }}</td>
</tr>
</table>
</fieldset>
<fieldset>
<legend>Token</legend>
<table class="metadata">
<tr>
<th>Access Token</th>
<td>
<span class="secret" _="on click add .visible to me">
<span class="label">Click to reveal</span>
<tt class="value">{{ token.token }}</tt>
</span>
</td>
</tr>
</table>
</fieldset>
<div class="buttons">
<a href="{% url "settings_tokens" handle=identity.handle %}" class="button secondary left">Back</a>
<button class="danger">Remove Access</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "settings/base.html" %}
{% load activity_tags %}
{% block subtitle %}Apps{% endblock %}
{% block settings_content %}
<div class="view-options">
<span class="spacer"></span>
<a href="{% url "settings_token_create" handle=identity.handle %}" class="button"><i class="fa-solid fa-plus"></i> Create Personal Token</a>
</div>
<table class="items">
{% for token in tokens %}
<tr>
<td class="icon">
<a href="{{ token.urls.edit }}" class="overlay"></a>
<i class="fa-solid fa-window-restore"></i>
</td>
<td class="name">
<a href="{{ token.urls.edit }}" class="overlay"></a>
{{ token.application.name }}
</td>
<td class="stat">
Created {{ token.created|timedeltashort }} ago
</td>
</tr>
{% empty %}
<tr class="empty"><td>You have no apps authorized against this identity.</td></tr>
{% endfor %}
</table>
{% endblock %}

View file

@ -1,19 +1,20 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import View
from django.shortcuts import redirect
from django.contrib.auth.decorators import login_required
from users.views.settings.follows import FollowsPage # noqa
from users.views.settings.import_export import ( # noqa
CsvFollowers,
CsvFollowing,
ImportExportPage,
)
from users.views.settings.interface import InterfacePage # noqa
from users.views.settings.posting import PostingPage # noqa
from users.views.settings.profile import ProfilePage # noqa
from users.views.settings.security import SecurityPage # noqa
from users.views.settings.settings_page import SettingsPage # noqa
from users.views.settings.follows import FollowsPage # noqa
from users.views.settings.interface import InterfacePage # noqa
from users.views.settings.tokens import TokenCreate, TokenEdit, TokensRoot # noqa
@method_decorator(login_required, name="dispatch")

View file

@ -0,0 +1,76 @@
import secrets
from django import forms
from django.contrib import messages
from django.shortcuts import redirect
from django.views.generic import DetailView, FormView
from django.views.generic.list import ListView
from api.models.application import Application
from api.models.token import Token
from users.views.base import IdentityViewMixin
class TokensRoot(IdentityViewMixin, ListView):
"""
Shows a listing of tokens the user has authorized
"""
template_name = "settings/tokens.html"
extra_context = {"section": "tokens"}
context_object_name = "tokens"
def get_queryset(self):
return Token.objects.filter(
user=self.request.user,
identity=self.identity,
).prefetch_related("application")
class TokenCreate(IdentityViewMixin, FormView):
"""
Allows the user to create a new app and token just for themselves.
"""
template_name = "settings/token_create.html"
class form_class(forms.Form):
name = forms.CharField(help_text="Identifies this app in your app list")
scope = forms.ChoiceField(
choices=(("read", "Read-only access"), ("write", "Full access")),
help_text="What should this app be able to do with your account?",
)
def form_valid(self, form):
scopes = "read write push" if form.cleaned_data["scope"] == "write" else "read"
application = Application.create(
client_name=form.cleaned_data["name"],
website=None,
redirect_uris="urn:ietf:wg:oauth:2.0:oob",
scopes=scopes,
)
token = Token.objects.create(
application=application,
user=self.request.user,
identity=self.identity,
token=secrets.token_urlsafe(43),
scopes=scopes,
)
return redirect("settings_token_edit", handle=self.identity.handle, pk=token.pk)
class TokenEdit(IdentityViewMixin, DetailView):
template_name = "settings/token_edit.html"
extra_context = {"section": "tokens"}
def get_queryset(self):
return self.identity.tokens
def post(self, request, *args, **kwargs):
self.object = self.get_object()
messages.success(
request, f"{self.object.application.name}'s access has been removed."
)
self.object.delete()
return redirect("settings_tokens", handle=self.identity.handle)