web: migrate Token List to web

This commit is contained in:
Jens Langhammer 2021-02-19 18:59:24 +01:00
parent fd28f37c0d
commit 6597d5bd28
17 changed files with 285 additions and 194 deletions

View file

@ -1,102 +0,0 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load authentik_utils %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="pf-icon pf-icon-security"></i>
{% trans 'Tokens' %}
</h1>
<p>{% trans "Tokens are used throughout authentik for Email validation stages, Recovery keys and API access." %}</p>
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
{% if object_list %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
{% include 'partials/toolbar_search.html' %}
{% include 'partials/pagination.html' %}
</div>
</div>
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
<thead>
<tr role="row">
<th role="columnheader" scope="col">{% trans 'Identifier' %}</th>
<th role="columnheader" scope="col">{% trans 'User' %}</th>
<th role="columnheader" scope="col">{% trans 'Expires?' %}</th>
<th role="columnheader" scope="col">{% trans 'Expiry Date' %}</th>
<th role="cell"></th>
</tr>
</thead>
<tbody role="rowgroup">
{% for token in object_list %}
<tr role="row">
<th role="columnheader">
<div>{{ token.identifier }}</div>
</th>
<td role="cell">
<span>
{{ token.user }}
</span>
</td>
<td role="cell">
<span>
{{ token.expiring|yesno:"Yes,No" }}
</span>
</td>
<td role="cell">
<span>
{% if not token.expiring %}
-
{% else %}
{{ token.expires }}
{% endif %}
</span>
</td>
<td>
<ak-modal-button href="{% url 'authentik_admin:token-delete' pk=token.pk %}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
{% trans 'Delete' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-token-copy-button identifier="{{ token.identifier }}">
{% trans 'Copy token' %}
</ak-token-copy-button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pf-c-pagination pf-m-bottom">
{% include 'partials/pagination.html' %}
</div>
{% else %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
{% include 'partials/toolbar_search.html' %}
</div>
</div>
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<i class="fas fa-key pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">
{% trans 'No Tokens.' %}
</h1>
<div class="pf-c-empty-state__body">
{% if request.GET.search != "" %}
{% trans "Your search query doesn't match any token." %}
{% else %}
{% trans 'Currently no tokens exist.' %}
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -53,7 +53,6 @@ urlpatterns = [
name="application-delete",
),
# Tokens
path("tokens/", tokens.TokenListView.as_view(), name="tokens"),
path(
"tokens/<uuid:pk>/delete/",
tokens.TokenDeleteView.as_view(),

View file

@ -1,39 +1,12 @@
"""authentik Token administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import ListView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from guardian.mixins import PermissionRequiredMixin
from authentik.admin.views.utils import (
DeleteMessageView,
SearchListMixin,
UserPaginateListMixin,
)
from authentik.admin.views.utils import DeleteMessageView
from authentik.core.models import Token
class TokenListView(
LoginRequiredMixin,
PermissionListMixin,
UserPaginateListMixin,
SearchListMixin,
ListView,
):
"""Show list of all tokens"""
model = Token
permission_required = "authentik_core.view_token"
ordering = "expires"
template_name = "administration/token/list.html"
search_fields = [
"identifier",
"intent",
"user__username",
"description",
]
class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
"""Delete token"""
@ -41,5 +14,5 @@ class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage
permission_required = "authentik_core.delete_token"
template_name = "generic/delete.html"
success_url = reverse_lazy("authentik_admin:tokens")
success_url = "/"
success_message = _("Successfully deleted Token")

View file

@ -7,19 +7,14 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseRedirect
from django.shortcuts import redirect
from django.shortcuts import redirect, reverse
from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views.generic import DetailView, UpdateView
from guardian.mixins import (
PermissionRequiredMixin,
)
from guardian.mixins import PermissionRequiredMixin
from authentik.admin.forms.users import UserForm
from authentik.admin.views.utils import (
BackSuccessUrlMixin,
DeleteMessageView,
)
from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView
from authentik.core.models import Token, User
from authentik.lib.views import CreateAssignPermView

View file

@ -19,3 +19,5 @@ class GroupViewSet(ModelViewSet):
queryset = Group.objects.all()
serializer_class = GroupSerializer
search_fields = ["name", "is_superuser"]
filterset_fields = ["name", "is_superuser"]

View file

@ -9,6 +9,7 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.users import UserSerializer
from authentik.core.models import Token
from authentik.events.models import Event, EventAction
@ -16,10 +17,21 @@ from authentik.events.models import Event, EventAction
class TokenSerializer(ModelSerializer):
"""Token Serializer"""
user = UserSerializer()
class Meta:
model = Token
fields = ["pk", "identifier", "intent", "user", "description"]
fields = [
"pk",
"identifier",
"intent",
"user",
"description",
"expires",
"expiring",
]
depth = 2
class TokenViewSerializer(Serializer):
@ -40,6 +52,19 @@ class TokenViewSet(ModelViewSet):
lookup_field = "identifier"
queryset = Token.filter_not_expired()
serializer_class = TokenSerializer
search_fields = [
"identifier",
"intent",
"user__username",
"description",
]
filterset_fields = [
"identifier",
"intent",
"user__username",
"description",
]
ordering = ["expires"]
@swagger_auto_schema(responses={200: TokenViewSerializer(many=False)})
@action(detail=True)

View file

@ -37,6 +37,7 @@ class UserSerializer(ModelSerializer):
"is_superuser",
"email",
"avatar",
"attributes",
]
@ -45,6 +46,8 @@ class UserViewSet(ModelViewSet):
queryset = User.objects.none()
serializer_class = UserSerializer
search_fields = ["username", "name", "is_active"]
filterset_fields = ["username", "name", "is_active"]
def get_queryset(self):
return User.objects.all().exclude(pk=get_anonymous_user().pk)

View file

@ -186,6 +186,8 @@ class StageViewSet(ReadOnlyModelViewSet):
queryset = Stage.objects.all()
serializer_class = StageSerializer
search_fields = ["name"]
filterset_fields = ["name"]
def get_queryset(self):
return Stage.objects.select_subclasses()

View file

@ -61,6 +61,8 @@ class ServiceConnectionViewSet(ModelViewSet):
queryset = OutpostServiceConnection.objects.select_subclasses()
serializer_class = ServiceConnectionSerializer
search_fields = ["name"]
filterset_fields = ["name"]
@swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False)

View file

@ -107,6 +107,7 @@ class PolicyViewSet(ReadOnlyModelViewSet):
"bindings": ["isnull"],
"promptstage": ["isnull"],
}
search_fields = ["name"]
def get_queryset(self):
return Policy.objects.select_subclasses().prefetch_related(

View file

@ -309,6 +309,16 @@ paths:
operationId: core_groups_list
description: Group Viewset
parameters:
- name: name
in: query
description: ''
required: false
type: string
- name: is_superuser
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -436,6 +446,26 @@ paths:
operationId: core_tokens_list
description: Token Viewset
parameters:
- name: identifier
in: query
description: ''
required: false
type: string
- name: intent
in: query
description: ''
required: false
type: string
- name: user__username
in: query
description: ''
required: false
type: string
- name: description
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -582,6 +612,21 @@ paths:
operationId: core_users_list
description: User Viewset
parameters:
- name: username
in: query
description: ''
required: false
type: string
- name: name
in: query
description: ''
required: false
type: string
- name: is_active
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -649,6 +694,21 @@ paths:
operationId: core_users_me
description: Get information about current user
parameters:
- name: username
in: query
description: ''
required: false
type: string
- name: name
in: query
description: ''
required: false
type: string
- name: is_active
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -2107,6 +2167,11 @@ paths:
operationId: outposts_service_connections_all_list
description: ServiceConnection Viewset
parameters:
- name: name
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -2174,6 +2239,11 @@ paths:
operationId: outposts_service_connections_all_types
description: Get all creatable service connection types
parameters:
- name: name
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -5506,6 +5576,11 @@ paths:
operationId: stages_all_list
description: Stage Viewset
parameters:
- name: name
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -5557,6 +5632,11 @@ paths:
operationId: stages_all_types
description: Get all creatable stage types
parameters:
- name: name
in: query
description: ''
required: false
type: string
- name: ordering
in: query
description: Which field to use when ordering the results.
@ -8091,48 +8171,8 @@ definitions:
attributes:
title: Attributes
type: object
Token:
description: Token Serializer
required:
- identifier
- user
type: object
properties:
pk:
title: Token uuid
type: string
format: uuid
readOnly: true
identifier:
title: Identifier
type: string
format: slug
pattern: ^[-a-zA-Z0-9_]+$
maxLength: 255
minLength: 1
intent:
title: Intent
type: string
enum:
- verification
- api
- recovery
user:
title: User
type: integer
description:
title: Description
type: string
TokenView:
description: Show token's current key
type: object
properties:
key:
title: Key
type: string
readOnly: true
minLength: 1
User:
title: User
description: User Serializer
required:
- username
@ -8179,6 +8219,56 @@ definitions:
title: Avatar
type: string
readOnly: true
attributes:
title: Attributes
type: object
Token:
description: Token Serializer
required:
- identifier
- user
type: object
properties:
pk:
title: Token uuid
type: string
format: uuid
readOnly: true
identifier:
title: Identifier
type: string
format: slug
pattern: ^[-a-zA-Z0-9_]+$
maxLength: 255
minLength: 1
intent:
title: Intent
type: string
enum:
- verification
- api
- recovery
user:
$ref: '#/definitions/User'
description:
title: Description
type: string
expires:
title: Expires
type: string
format: date-time
expiring:
title: Expiring
type: boolean
TokenView:
description: Show token's current key
type: object
properties:
key:
title: Key
type: string
readOnly: true
minLength: 1
CertificateKeyPair:
description: CertificateKeyPair Serializer
required:

View file

@ -1,11 +1,43 @@
import { DefaultClient } from "./Client";
import { AKResponse, DefaultClient, QueryArguments } from "./Client";
import { User } from "./Users";
interface TokenResponse {
key: string;
export enum TokenIntent {
INTENT_VERIFICATION = "verification",
INTENT_API = "api",
INTENT_RECOVERY = "recovery",
}
export function tokenByIdentifier(identifier: string): Promise<string> {
return DefaultClient.fetch<TokenResponse>(["core", "tokens", identifier, "view_key"]).then(
(r) => r.key
);
export class Token {
pk: string;
identifier: string;
intent: TokenIntent;
user: User;
description: string;
expires: number;
expiring: boolean;
constructor() {
throw Error();
}
static get(pk: string): Promise<User> {
return DefaultClient.fetch<User>(["core", "tokens", pk]);
}
static list(filter?: QueryArguments): Promise<AKResponse<Token>> {
return DefaultClient.fetch<AKResponse<Token>>(["core", "tokens"], filter);
}
static adminUrl(rest: string): string {
return `/administration/tokens/${rest}`;
}
static getKey(identifier: string): Promise<string> {
return DefaultClient.fetch<{ key: string }>(["core", "tokens", identifier, "view_key"]).then(
(r) => r.key
);
}
}

View file

@ -3,7 +3,7 @@ import { css, CSSResult, customElement, html, LitElement, property, TemplateResu
import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css";
// @ts-ignore
import ButtonStyle from "@patternfly/patternfly/components/Button/button.css";
import { tokenByIdentifier } from "../../api/Tokens";
import { Token } from "../../api/Tokens";
import { ColorStyles, ERROR_CLASS, PRIMARY_CLASS, SUCCESS_CLASS } from "../../constants";
@customElement("ak-token-copy-button")
@ -35,7 +35,7 @@ export class TokenCopyButton extends LitElement {
}, 1500);
return;
}
tokenByIdentifier(this.identifier).then((token) => {
Token.getKey(this.identifier).then((token) => {
navigator.clipboard.writeText(token).then(() => {
this.buttonClass = SUCCESS_CLASS;
setTimeout(() => {

View file

@ -43,7 +43,7 @@ export class TablePagination extends LitElement {
<button
class="pf-c-button pf-m-plain"
@click=${() => { this.pageChangeHandler(this.pages?.next || 0); }}
?disabled="${(this.pages?.next || 0) < 0}"
?disabled="${(this.pages?.next || 0) <= 0}"
aria-label="${gettext("Go to next page")}"
>
<i class="fas fa-angle-right" aria-hidden="true"></i>

View file

@ -50,7 +50,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
new SidebarItem("User", "/users"),
new SidebarItem("Groups", "/groups"),
new SidebarItem("Certificates", "/crypto/certificates"),
new SidebarItem("Tokens", "/administration/tokens/"),
new SidebarItem("Tokens", "/tokens"),
).when((): Promise<boolean> => {
return User.me().then(u => u.is_superuser);
}),

View file

@ -0,0 +1,67 @@
import { gettext } from "django";
import { customElement, html, property, TemplateResult } from "lit-element";
import { AKResponse } from "../../api/Client";
import { TablePage } from "../../elements/table/TablePage";
import "../../elements/buttons/ModalButton";
import "../../elements/buttons/Dropdown";
import { TableColumn } from "../../elements/table/Table";
import { Token } from "../../api/Tokens";
@customElement("ak-token-list")
export class TokenListPage extends TablePage<Token> {
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return gettext("Tokens");
}
pageDescription(): string {
return gettext("Tokens are used throughout authentik for Email validation stages, Recovery keys and API access.");
}
pageIcon(): string {
return gettext("pf-icon pf-icon-security");
}
@property()
order = "expires";
apiEndpoint(page: number): Promise<AKResponse<Token>> {
return Token.list({
ordering: this.order,
page: page,
search: this.search || "",
});
}
columns(): TableColumn[] {
return [
new TableColumn("Identifier", "identifier"),
new TableColumn("User", "user"),
new TableColumn("Expires?", "expiring"),
new TableColumn("Expiry date", "expires"),
new TableColumn(""),
];
}
row(item: Token): TemplateResult[] {
return [
html`${item.identifier}`,
html`${item.user.username}`,
html`${item.expiring ? "Yes" : "No"}`,
html`${item.expiring ? new Date(item.expires * 1000).toLocaleString() : '-'}`,
html`
<ak-modal-button href="${Token.adminUrl(`${item.pk}/delete/`)}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
${gettext('Delete')}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-token-copy-button identifier="${item.identifier}">
${gettext('Copy Key')}
</ak-token-copy-button>
`,
];
}
}

View file

@ -22,6 +22,7 @@ import "./pages/sources/SourcesListPage";
import "./pages/sources/SourceViewPage";
import "./pages/groups/GroupListPage";
import "./pages/users/UserListPage";
import "./pages/tokens/TokenListPage";
import "./pages/system-tasks/SystemTaskListPage";
export const ROUTES: Route[] = [
@ -47,6 +48,7 @@ export const ROUTES: Route[] = [
new Route(new RegExp("^/groups$"), html`<ak-group-list></ak-group-list>`),
new Route(new RegExp("^/users$"), html`<ak-user-list></ak-user-list>`),
new Route(new RegExp("^/flows$"), html`<ak-flow-list></ak-flow-list>`),
new Route(new RegExp("^/tokens$"), html`<ak-token-list></ak-token-list>`),
new Route(new RegExp(`^/flows/(?<slug>${SLUG_REGEX})$`)).then((args) => {
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
}),