simple-module-api-tokens 0.0.37__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_tokens/__init__.py +1 -0
- api_tokens/audit.py +52 -0
- api_tokens/constants.py +76 -0
- api_tokens/contracts/__init__.py +11 -0
- api_tokens/contracts/schemas.py +70 -0
- api_tokens/deps.py +66 -0
- api_tokens/endpoints/__init__.py +0 -0
- api_tokens/endpoints/admin_api.py +98 -0
- api_tokens/endpoints/api.py +109 -0
- api_tokens/endpoints/views.py +96 -0
- api_tokens/locales/en.json +104 -0
- api_tokens/models.py +58 -0
- api_tokens/module.py +109 -0
- api_tokens/package.json +19 -0
- api_tokens/pages/Admin.tsx +92 -0
- api_tokens/pages/Index.tsx +95 -0
- api_tokens/pages/api.ts +42 -0
- api_tokens/pages/components/CreateTokenDialog.tsx +186 -0
- api_tokens/pages/components/CreatedTokenDialog.tsx +64 -0
- api_tokens/pages/components/RevokeTokenDialog.tsx +29 -0
- api_tokens/pages/components/TokenTable.tsx +117 -0
- api_tokens/pages/components/useTokenActions.ts +82 -0
- api_tokens/pages/types.ts +40 -0
- api_tokens/py.typed +0 -0
- api_tokens/resolver.py +157 -0
- api_tokens/scopes.py +58 -0
- api_tokens/service.py +175 -0
- api_tokens/services.py +14 -0
- api_tokens/settings.py +17 -0
- api_tokens/tokens.py +83 -0
- simple_module_api_tokens-0.0.37.dist-info/METADATA +70 -0
- simple_module_api_tokens-0.0.37.dist-info/RECORD +35 -0
- simple_module_api_tokens-0.0.37.dist-info/WHEEL +4 -0
- simple_module_api_tokens-0.0.37.dist-info/entry_points.txt +2 -0
- simple_module_api_tokens-0.0.37.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"api_tokens": "API tokens"
|
|
4
|
+
},
|
|
5
|
+
"audit": {
|
|
6
|
+
"token": "API token"
|
|
7
|
+
},
|
|
8
|
+
"errors": {
|
|
9
|
+
"session_required": "API tokens can only be managed from a signed-in browser session, not with a token.",
|
|
10
|
+
"scopes_required": "Choose at least one scope.",
|
|
11
|
+
"scope_not_held": "You cannot grant the scope \"{value}\": you do not hold it.",
|
|
12
|
+
"role_forbidden": "A service key cannot have the \"{role}\" role.",
|
|
13
|
+
"role_not_assignable": "The role \"{value}\" cannot be given to a service key.",
|
|
14
|
+
"lifetime_too_long": "A token can be valid for at most {days} days.",
|
|
15
|
+
"invalid_kind": "This kind of token cannot be created here.",
|
|
16
|
+
"tenant_required": "Choose an organisation before managing its tokens.",
|
|
17
|
+
"not_found": "Token not found.",
|
|
18
|
+
"account_required": "Your account has no local user record, so a personal access token could never sign in as you. Ask an administrator for a service key instead."
|
|
19
|
+
},
|
|
20
|
+
"index": {
|
|
21
|
+
"title": "API tokens",
|
|
22
|
+
"description": "Personal access tokens let scripts and tools call the API as you, limited to the scopes you choose.",
|
|
23
|
+
"usage": "Send a token in the Authorization header: Authorization: Bearer <token>."
|
|
24
|
+
},
|
|
25
|
+
"admin": {
|
|
26
|
+
"title": "API tokens",
|
|
27
|
+
"description": "Every personal access token and service key in this organisation.",
|
|
28
|
+
"tenant_required": "Choose an organisation to see and create its API tokens.",
|
|
29
|
+
"new_service_key": "New service key"
|
|
30
|
+
},
|
|
31
|
+
"list": {
|
|
32
|
+
"name": "Name",
|
|
33
|
+
"token": "Token",
|
|
34
|
+
"kind": "Kind",
|
|
35
|
+
"owner": "Owner",
|
|
36
|
+
"scopes": "Scopes",
|
|
37
|
+
"roles": "Roles",
|
|
38
|
+
"expires": "Expires",
|
|
39
|
+
"last_used": "Last used",
|
|
40
|
+
"status": "Status",
|
|
41
|
+
"actions": "Actions",
|
|
42
|
+
"never": "Never",
|
|
43
|
+
"revoke": "Revoke",
|
|
44
|
+
"empty_title": "No API tokens yet",
|
|
45
|
+
"empty_description": "Create a token to call the API from a script or another service.",
|
|
46
|
+
"scope_count_one": "{count} scope",
|
|
47
|
+
"scope_count_other": "{count} scopes"
|
|
48
|
+
},
|
|
49
|
+
"kind": {
|
|
50
|
+
"personal": "Personal",
|
|
51
|
+
"service": "Service key"
|
|
52
|
+
},
|
|
53
|
+
"status": {
|
|
54
|
+
"active": "Active",
|
|
55
|
+
"revoked": "Revoked",
|
|
56
|
+
"expired": "Expired"
|
|
57
|
+
},
|
|
58
|
+
"form": {
|
|
59
|
+
"new_token": "New token",
|
|
60
|
+
"create_title": "Create a personal access token",
|
|
61
|
+
"create_service_title": "Create a service key",
|
|
62
|
+
"create_description": "The token can do only what its scopes allow, and never more than you can.",
|
|
63
|
+
"create_service_description": "A service key acts for no person. It gets the roles you pick, limited to the scopes you choose.",
|
|
64
|
+
"name": "Name",
|
|
65
|
+
"name_placeholder": "e.g. CI deploy",
|
|
66
|
+
"scopes": "Scopes",
|
|
67
|
+
"scopes_hint": "Only permissions you hold are listed.",
|
|
68
|
+
"roles": "Roles",
|
|
69
|
+
"roles_hint": "The administrator role cannot be given to a service key.",
|
|
70
|
+
"no_roles": "No assignable roles.",
|
|
71
|
+
"expiry": "Expires after",
|
|
72
|
+
"days_one": "{count} day",
|
|
73
|
+
"days_other": "{count} days",
|
|
74
|
+
"submit": "Create token",
|
|
75
|
+
"cancel": "Cancel"
|
|
76
|
+
},
|
|
77
|
+
"created": {
|
|
78
|
+
"title": "Copy your new token",
|
|
79
|
+
"warning": "This is the only time the token is shown. Store it somewhere safe now — it cannot be recovered.",
|
|
80
|
+
"copy": "Copy",
|
|
81
|
+
"copied": "Copied",
|
|
82
|
+
"done": "Done"
|
|
83
|
+
},
|
|
84
|
+
"revoke": {
|
|
85
|
+
"title": "Revoke \"{name}\"?",
|
|
86
|
+
"description": "Anything using this token stops working on its next request. This cannot be undone.",
|
|
87
|
+
"confirm": "Revoke",
|
|
88
|
+
"cancel": "Cancel"
|
|
89
|
+
},
|
|
90
|
+
"toasts": {
|
|
91
|
+
"created": "Token created",
|
|
92
|
+
"revoked": "Token revoked",
|
|
93
|
+
"failed": "Something went wrong. Please try again.",
|
|
94
|
+
"revoked_all_one": "{count} token revoked",
|
|
95
|
+
"revoked_all_other": "{count} tokens revoked"
|
|
96
|
+
},
|
|
97
|
+
"revoke_all": {
|
|
98
|
+
"button": "Revoke all",
|
|
99
|
+
"title": "Revoke all your tokens?",
|
|
100
|
+
"description": "Every personal access token you hold stops working on its next request, in every organisation. Use this if a token may have leaked: changing your password or signing out everywhere does not end tokens.",
|
|
101
|
+
"confirm": "Revoke all",
|
|
102
|
+
"cancel": "Cancel"
|
|
103
|
+
}
|
|
104
|
+
}
|
api_tokens/models.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""SQLModel tables for the api_tokens module.
|
|
2
|
+
|
|
3
|
+
One table holds both credential kinds. Only ``sha256(token)`` is stored; the
|
|
4
|
+
secret itself is shown once, in the response that created it, and nowhere
|
|
5
|
+
else. ``prefix`` is a short, non-secret slice so a person can tell their
|
|
6
|
+
tokens apart in a list.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
import sqlalchemy as sa
|
|
16
|
+
from fastapi_users_db_sqlalchemy.generics import GUID
|
|
17
|
+
from simple_module_db.base import create_module_base
|
|
18
|
+
from simple_module_db.mixins import AuditMixin, MultiTenantMixin
|
|
19
|
+
from simple_module_db.types import AwareDateTime
|
|
20
|
+
from sqlalchemy import Index
|
|
21
|
+
from sqlmodel import Field
|
|
22
|
+
|
|
23
|
+
from api_tokens.constants import IX_TOKEN_HASH, NAME_MAX_LENGTH, TABLE_TOKEN, TokenKind
|
|
24
|
+
|
|
25
|
+
Base = create_module_base("api_tokens")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiToken(Base, AuditMixin, MultiTenantMixin, table=True): # ty: ignore[unsupported-base]
|
|
29
|
+
"""A personal access token or a service API key."""
|
|
30
|
+
|
|
31
|
+
__tablename__ = TABLE_TOKEN
|
|
32
|
+
# The hash is a credential-equivalent lookup key and ``last_used_at`` is
|
|
33
|
+
# bumped by a Core UPDATE that never reaches the audit listener anyway;
|
|
34
|
+
# neither belongs in the audit trail. Create and revoke still do.
|
|
35
|
+
__audit_exclude_fields__: ClassVar[set[str]] = {"token_hash", "last_used_at"}
|
|
36
|
+
# ``token_hash`` leads, so the per-request lookup by hash alone is an
|
|
37
|
+
# index seek; ``tenant_id`` is in the key because every unique key on a
|
|
38
|
+
# tenant-scoped table must be (SM024). A SHA-256 of 32 random bytes does
|
|
39
|
+
# not collide, so the wider key loses nothing.
|
|
40
|
+
__table_args__ = (Index(IX_TOKEN_HASH, "token_hash", "tenant_id", unique=True),)
|
|
41
|
+
|
|
42
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
43
|
+
kind: str = Field(max_length=16, default=TokenKind.PERSONAL)
|
|
44
|
+
name: str = Field(max_length=NAME_MAX_LENGTH)
|
|
45
|
+
prefix: str = Field(max_length=16)
|
|
46
|
+
# Looked up before any tenant is known — see ``__table_args__``.
|
|
47
|
+
token_hash: str = Field(max_length=64)
|
|
48
|
+
# ``GUID``, the type ``users_user.id`` has, so the values compare equal on
|
|
49
|
+
# SQLite too (``sa.Uuid`` stores bare hex there — GH #339). No foreign key:
|
|
50
|
+
# every module owns its own ``MetaData``, so one cannot name another
|
|
51
|
+
# module's table. A deleted owner is handled where it matters — the
|
|
52
|
+
# resolver finds no account and refuses the token.
|
|
53
|
+
owner_user_id: uuid.UUID | None = Field(default=None, sa_type=GUID, index=True)
|
|
54
|
+
scopes: list[str] = Field(default_factory=list, sa_type=sa.JSON())
|
|
55
|
+
roles: list[str] = Field(default_factory=list, sa_type=sa.JSON())
|
|
56
|
+
expires_at: datetime = Field(sa_type=AwareDateTime())
|
|
57
|
+
last_used_at: datetime | None = Field(default=None, sa_type=AwareDateTime())
|
|
58
|
+
revoked_at: datetime | None = Field(default=None, sa_type=AwareDateTime())
|
api_tokens/module.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""ApiTokens module: personal access tokens and service API keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
11
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
12
|
+
from simple_module_core.tenancy import TenantRole, tenant_role
|
|
13
|
+
|
|
14
|
+
from api_tokens import constants as c
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from fastapi import APIRouter, FastAPI
|
|
18
|
+
from simple_module_core.audit_links import AuditLinkRegistry
|
|
19
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiTokensModule(ModuleBase):
|
|
23
|
+
meta = ModuleMeta(
|
|
24
|
+
name=c.MODULE_NAME,
|
|
25
|
+
route_prefix=c.API_PREFIX,
|
|
26
|
+
view_prefix=c.VIEW_PREFIX,
|
|
27
|
+
admin_view_prefix=c.ADMIN_VIEW_PREFIX,
|
|
28
|
+
# Auth owns the resolver chain this module appends to; Users owns the
|
|
29
|
+
# accounts a personal token acts for; Settings stores the lifetime cap.
|
|
30
|
+
depends_on=[c.MODULE_AUTH, c.MODULE_USERS, c.MODULE_SETTINGS],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
34
|
+
from api_tokens.services import ApiTokensServices
|
|
35
|
+
from api_tokens.settings import ApiTokensSettings
|
|
36
|
+
|
|
37
|
+
# SM009 is AST-based: resolve via importlib like the other
|
|
38
|
+
# settings-backed modules.
|
|
39
|
+
register_module_settings = importlib.import_module(
|
|
40
|
+
"settings.registration"
|
|
41
|
+
).register_module_settings
|
|
42
|
+
register_module_settings(
|
|
43
|
+
app,
|
|
44
|
+
c.MODULE_PACKAGE,
|
|
45
|
+
ApiTokensSettings,
|
|
46
|
+
lambda s: ApiTokensServices(settings=s),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
50
|
+
from api_tokens.endpoints.admin_api import router as admin_api
|
|
51
|
+
from api_tokens.endpoints.api import router as api
|
|
52
|
+
from api_tokens.endpoints.views import router as views
|
|
53
|
+
|
|
54
|
+
api_router.include_router(api)
|
|
55
|
+
api_router.include_router(admin_api)
|
|
56
|
+
view_router.include_router(views)
|
|
57
|
+
|
|
58
|
+
def register_admin_routes(self, admin_router: APIRouter) -> None:
|
|
59
|
+
from api_tokens.endpoints.views import admin_router as admin_views
|
|
60
|
+
|
|
61
|
+
admin_router.include_router(admin_views)
|
|
62
|
+
|
|
63
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
64
|
+
registry.add(
|
|
65
|
+
MenuItem(
|
|
66
|
+
label=c.MENU_LABEL,
|
|
67
|
+
label_key=c.MENU_LABEL_KEY,
|
|
68
|
+
url=c.USER_URL,
|
|
69
|
+
icon=c.MENU_ICON,
|
|
70
|
+
order=c.MENU_USER_ORDER,
|
|
71
|
+
section=MenuSection.USER_DROPDOWN,
|
|
72
|
+
permissions=[c.PERM_USE],
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
registry.add(
|
|
76
|
+
MenuItem(
|
|
77
|
+
label=c.MENU_LABEL,
|
|
78
|
+
label_key=c.MENU_LABEL_KEY,
|
|
79
|
+
url=c.ADMIN_URL,
|
|
80
|
+
icon=c.MENU_ICON,
|
|
81
|
+
order=c.MENU_ADMIN_ORDER,
|
|
82
|
+
section=MenuSection.ADMIN_SIDEBAR,
|
|
83
|
+
group="Access",
|
|
84
|
+
group_key="ui.nav_groups.access",
|
|
85
|
+
permissions=[c.PERM_MANAGE],
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
90
|
+
registry.add_group(c.PERM_GROUP, [c.PERM_USE, c.PERM_MANAGE])
|
|
91
|
+
registry.map_role(c.USER_ROLE, [c.PERM_USE])
|
|
92
|
+
for role in TenantRole:
|
|
93
|
+
registry.map_role(tenant_role(role), [c.PERM_USE])
|
|
94
|
+
|
|
95
|
+
def register_audit_links(self, registry: AuditLinkRegistry) -> None:
|
|
96
|
+
from api_tokens.audit import build_token_audit_link
|
|
97
|
+
|
|
98
|
+
registry.register(build_token_audit_link())
|
|
99
|
+
|
|
100
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
101
|
+
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
102
|
+
return {c.LOCALE_NAMESPACE: base}
|
|
103
|
+
|
|
104
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
105
|
+
from api_tokens.resolver import resolve_api_token
|
|
106
|
+
|
|
107
|
+
resolvers = app.state.auth.principal_resolvers
|
|
108
|
+
if resolve_api_token not in resolvers:
|
|
109
|
+
resolvers.append(resolve_api_token)
|
api_tokens/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/api-tokens",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the ApiTokens module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^3.7.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"lucide-react": "^1.43.0",
|
|
17
|
+
"sonner": "^2.0.8"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Head, usePage } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner';
|
|
4
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
5
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
6
|
+
import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
|
|
7
|
+
import { Building2, Plus } from 'lucide-react';
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
import { CreatedTokenDialog } from './components/CreatedTokenDialog';
|
|
10
|
+
import { CreateTokenDialog } from './components/CreateTokenDialog';
|
|
11
|
+
import { RevokeTokenDialog } from './components/RevokeTokenDialog';
|
|
12
|
+
import { TokenTable } from './components/TokenTable';
|
|
13
|
+
import { useTokenActions } from './components/useTokenActions';
|
|
14
|
+
import type { CreatePayload, TokenFormProps } from './types';
|
|
15
|
+
|
|
16
|
+
interface Props extends TokenFormProps {
|
|
17
|
+
roles: string[];
|
|
18
|
+
tenant_required: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Every token in the active tenant; service keys are created here. */
|
|
22
|
+
function Admin() {
|
|
23
|
+
const props = usePage<{ props: Props }>().props as unknown as Props;
|
|
24
|
+
const { t } = useT();
|
|
25
|
+
const [creating, setCreating] = useState(false);
|
|
26
|
+
// An audit-log link lands here as ?token=<id>; mark that row.
|
|
27
|
+
const highlightId =
|
|
28
|
+
typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('token');
|
|
29
|
+
const actions = useTokenActions({
|
|
30
|
+
csrfToken: props.csrf_token,
|
|
31
|
+
createUrl: `${props.api_url}/admin/service-keys`,
|
|
32
|
+
revokeUrl: (id) => `${props.api_url}/admin/tokens/${id}/revoke`,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function submit(payload: CreatePayload) {
|
|
36
|
+
const ok = await actions.create(payload);
|
|
37
|
+
if (ok) setCreating(false);
|
|
38
|
+
return ok;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<Head title={t(keys.api_tokens.admin.title)} />
|
|
44
|
+
<PageShell
|
|
45
|
+
title={t(keys.api_tokens.admin.title)}
|
|
46
|
+
description={t(keys.api_tokens.admin.description)}
|
|
47
|
+
actions={
|
|
48
|
+
<Button onClick={() => setCreating(true)} disabled={props.tenant_required}>
|
|
49
|
+
<Plus className="size-4" />
|
|
50
|
+
{t(keys.api_tokens.admin.new_service_key)}
|
|
51
|
+
</Button>
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
{props.tenant_required ? (
|
|
55
|
+
<InlineBanner
|
|
56
|
+
icon={Building2}
|
|
57
|
+
tone="warning"
|
|
58
|
+
title={t(keys.api_tokens.admin.tenant_required)}
|
|
59
|
+
/>
|
|
60
|
+
) : (
|
|
61
|
+
<TokenTable
|
|
62
|
+
tokens={props.tokens}
|
|
63
|
+
showOwner
|
|
64
|
+
highlightId={highlightId}
|
|
65
|
+
onRevoke={actions.setPendingRevoke}
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
</PageShell>
|
|
69
|
+
|
|
70
|
+
<CreateTokenDialog
|
|
71
|
+
open={creating}
|
|
72
|
+
onOpenChange={setCreating}
|
|
73
|
+
variant="service"
|
|
74
|
+
scopes={props.scopes}
|
|
75
|
+
roles={props.roles}
|
|
76
|
+
expiryPresets={props.expiry_presets}
|
|
77
|
+
busy={actions.busy}
|
|
78
|
+
onSubmit={submit}
|
|
79
|
+
/>
|
|
80
|
+
<CreatedTokenDialog token={actions.created} onClose={actions.clearCreated} />
|
|
81
|
+
<RevokeTokenDialog
|
|
82
|
+
token={actions.pendingRevoke}
|
|
83
|
+
busy={actions.busy}
|
|
84
|
+
onCancel={() => actions.setPendingRevoke(null)}
|
|
85
|
+
onConfirm={actions.confirmRevoke}
|
|
86
|
+
/>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
Admin.layout = [AdminLayout];
|
|
92
|
+
export default Admin;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Head, usePage } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog';
|
|
4
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
5
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
6
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
7
|
+
import { Ban, Plus } from 'lucide-react';
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
import { CreatedTokenDialog } from './components/CreatedTokenDialog';
|
|
10
|
+
import { CreateTokenDialog } from './components/CreateTokenDialog';
|
|
11
|
+
import { RevokeTokenDialog } from './components/RevokeTokenDialog';
|
|
12
|
+
import { TokenTable } from './components/TokenTable';
|
|
13
|
+
import { useTokenActions } from './components/useTokenActions';
|
|
14
|
+
import { type CreatePayload, type TokenFormProps, tokenStatus } from './types';
|
|
15
|
+
|
|
16
|
+
/** Your own personal access tokens: list, create (secret shown once), revoke. */
|
|
17
|
+
function Index() {
|
|
18
|
+
const props = usePage<{ props: TokenFormProps }>().props as unknown as TokenFormProps;
|
|
19
|
+
const { t } = useT();
|
|
20
|
+
const [creating, setCreating] = useState(false);
|
|
21
|
+
const actions = useTokenActions({
|
|
22
|
+
csrfToken: props.csrf_token,
|
|
23
|
+
createUrl: `${props.api_url}/tokens/`,
|
|
24
|
+
revokeUrl: (id) => `${props.api_url}/tokens/${id}/revoke`,
|
|
25
|
+
revokeAllUrl: `${props.api_url}/tokens/revoke-all`,
|
|
26
|
+
});
|
|
27
|
+
const hasLive = props.tokens.some((tok) => tokenStatus(tok) === 'active');
|
|
28
|
+
|
|
29
|
+
async function submit(payload: CreatePayload) {
|
|
30
|
+
const ok = await actions.create(payload);
|
|
31
|
+
if (ok) setCreating(false);
|
|
32
|
+
return ok;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<Head title={t(keys.api_tokens.index.title)} />
|
|
38
|
+
<PageShell
|
|
39
|
+
title={t(keys.api_tokens.index.title)}
|
|
40
|
+
description={t(keys.api_tokens.index.description)}
|
|
41
|
+
actions={
|
|
42
|
+
<div className="flex gap-2">
|
|
43
|
+
<Button
|
|
44
|
+
variant="outline"
|
|
45
|
+
onClick={() => actions.setPendingRevokeAll(true)}
|
|
46
|
+
disabled={!hasLive}
|
|
47
|
+
>
|
|
48
|
+
<Ban className="size-4" />
|
|
49
|
+
{t(keys.api_tokens.revoke_all.button)}
|
|
50
|
+
</Button>
|
|
51
|
+
<Button onClick={() => setCreating(true)} disabled={props.scopes.length === 0}>
|
|
52
|
+
<Plus className="size-4" />
|
|
53
|
+
{t(keys.api_tokens.form.new_token)}
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
}
|
|
57
|
+
>
|
|
58
|
+
<p className="text-sm text-muted-foreground">{t(keys.api_tokens.index.usage)}</p>
|
|
59
|
+
<TokenTable tokens={props.tokens} onRevoke={actions.setPendingRevoke} />
|
|
60
|
+
</PageShell>
|
|
61
|
+
|
|
62
|
+
<CreateTokenDialog
|
|
63
|
+
open={creating}
|
|
64
|
+
onOpenChange={setCreating}
|
|
65
|
+
variant="personal"
|
|
66
|
+
scopes={props.scopes}
|
|
67
|
+
expiryPresets={props.expiry_presets}
|
|
68
|
+
busy={actions.busy}
|
|
69
|
+
onSubmit={submit}
|
|
70
|
+
/>
|
|
71
|
+
<CreatedTokenDialog token={actions.created} onClose={actions.clearCreated} />
|
|
72
|
+
<RevokeTokenDialog
|
|
73
|
+
token={actions.pendingRevoke}
|
|
74
|
+
busy={actions.busy}
|
|
75
|
+
onCancel={() => actions.setPendingRevoke(null)}
|
|
76
|
+
onConfirm={actions.confirmRevoke}
|
|
77
|
+
/>
|
|
78
|
+
<ConfirmActionDialog
|
|
79
|
+
open={actions.pendingRevokeAll}
|
|
80
|
+
onOpenChange={(open) => !open && actions.setPendingRevokeAll(false)}
|
|
81
|
+
tone="destructive"
|
|
82
|
+
icon={Ban}
|
|
83
|
+
title={t(keys.api_tokens.revoke_all.title)}
|
|
84
|
+
description={t(keys.api_tokens.revoke_all.description)}
|
|
85
|
+
confirmLabel={t(keys.api_tokens.revoke_all.confirm)}
|
|
86
|
+
cancelLabel={t(keys.api_tokens.revoke_all.cancel)}
|
|
87
|
+
busy={actions.busy}
|
|
88
|
+
onConfirm={actions.confirmRevokeAll}
|
|
89
|
+
/>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Index.layout = [AuthenticatedLayout];
|
|
95
|
+
export default Index;
|
api_tokens/pages/api.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON calls to the api_tokens endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Plain `fetch`, not Inertia's router: creating a token must hand the page
|
|
5
|
+
* its secret as data, once, which a redirect cannot carry. Every mutation
|
|
6
|
+
* echoes the session's CSRF token (`RequiresCsrf` on the server).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ApiResult<T> {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
data: T | null;
|
|
12
|
+
/** The server's message when it sent a plain string; null otherwise. */
|
|
13
|
+
detail: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function postJson<T>(
|
|
17
|
+
url: string,
|
|
18
|
+
csrfToken: string,
|
|
19
|
+
body?: unknown,
|
|
20
|
+
): Promise<ApiResult<T>> {
|
|
21
|
+
let response: Response;
|
|
22
|
+
try {
|
|
23
|
+
response = await fetch(url, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
credentials: 'same-origin',
|
|
26
|
+
headers: {
|
|
27
|
+
Accept: 'application/json',
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'X-CSRF-Token': csrfToken,
|
|
30
|
+
},
|
|
31
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
return { ok: false, data: null, detail: null };
|
|
35
|
+
}
|
|
36
|
+
const json = await response.json().catch(() => null);
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const detail = json && typeof json.detail === 'string' ? json.detail : null;
|
|
39
|
+
return { ok: false, data: null, detail };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, data: json as T, detail: null };
|
|
42
|
+
}
|