diff --git a/messages/de.json b/messages/de.json index 7ebd62e..df6cffe 100644 --- a/messages/de.json +++ b/messages/de.json @@ -302,7 +302,10 @@ "runNotFound": "Im Kanal war keine passende Nachricht zu finden. Braucht der Bot noch das Recht channels:history?", "channelRequired": "Zum Bot-Token gehört ein Kanal, sonst weiß Logbuch nicht, wohin.", "slackSaid": "Slack sagt: {detail}", - "windowReset": "Der Zeitraum ist zurückgesetzt. Der nächste Bericht umfasst wieder die letzten sieben Tage." + "windowReset": "Der Zeitraum ist zurückgesetzt. Der nächste Bericht umfasst wieder die letzten sieben Tage.", + "clientRotated": "Das alte Token gilt nicht mehr. Das neue steht einmalig hier.", + "clientDeleted": "Der Zugang ist gelöscht.", + "clientUnknown": "Diesen Zugang gibt es nicht mehr." }, "errors": { "nameRequired": "Trag einen Namen ein.", @@ -712,7 +715,13 @@ "project": "Ohne Bindung gilt der Zugang für alle Projekte." }, "publishTitle": "Veröffentlichen", - "publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden." + "publishHint": "Kein Zugang darf veröffentlichen. Das bleibt an Konten gebunden.", + "rotate": "Neues Token", + "rotating": "Wird erneuert", + "delete": "Löschen", + "deleteConfirm": "Wirklich löschen?", + "deleting": "Wird gelöscht", + "keep": "Behalten" }, "recentEntries": "Zuletzt bearbeitet", "allEntries": "Alle Beiträge", diff --git a/messages/en.json b/messages/en.json index 8b67cb9..4737cce 100644 --- a/messages/en.json +++ b/messages/en.json @@ -302,7 +302,10 @@ "runNotFound": "No matching message found in the channel. Does the bot still need channels:history?", "channelRequired": "A bot token needs a channel, otherwise Logbuch has no target.", "slackSaid": "Slack says: {detail}", - "windowReset": "The window is reset. The next digest covers the last seven days again." + "windowReset": "The window is reset. The next digest covers the last seven days again.", + "clientRotated": "The old token is void. The new one is shown here once.", + "clientDeleted": "The access is deleted.", + "clientUnknown": "That access no longer exists." }, "errors": { "nameRequired": "Enter a name.", @@ -712,7 +715,13 @@ "project": "Without a binding the access covers all projects." }, "publishTitle": "Publishing", - "publishHint": "No access may publish. That stays bound to accounts." + "publishHint": "No access may publish. That stays bound to accounts.", + "rotate": "New token", + "rotating": "Rotating", + "delete": "Delete", + "deleteConfirm": "Really delete?", + "deleting": "Deleting", + "keep": "Keep" }, "recentEntries": "Recently edited", "allEntries": "All entries", diff --git a/src/app/admin/clients/page.tsx b/src/app/admin/clients/page.tsx index 0e74c21..d603407 100644 --- a/src/app/admin/clients/page.tsx +++ b/src/app/admin/clients/page.tsx @@ -3,7 +3,7 @@ import { getTranslations } from 'next-intl/server' import { AdminHeading } from '~/components/admin/AdminHeading' import { AdminNotice } from '~/components/admin/AdminNotice' import { ClientForm } from '~/components/admin/ClientForm' -import { ClientRevokeButton } from '~/components/admin/ClientRevokeButton' +import { ClientRowActions } from '~/components/admin/ClientRowActions' import { cellClass, headCellClass } from '~/components/admin/styles' import { EntryDate } from '~/components/ui/EntryDate' import { SectionLabel } from '~/components/ui/SectionLabel' @@ -75,7 +75,7 @@ export default async function AdminClientsPage() { )} - {client.revokedAt ? null : } + ))} diff --git a/src/components/admin/ClientRevokeButton.tsx b/src/components/admin/ClientRevokeButton.tsx deleted file mode 100644 index f949a58..0000000 --- a/src/components/admin/ClientRevokeButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client' - -import { useActionState } from 'react' -import { useTranslations } from 'next-intl' -import { TbBan } from 'react-icons/tb' -import { AdminSubmit } from './AdminSubmit' -import { errorClass } from './styles' -import { revokeApiClient } from '~/lib/admin-actions' -import { emptyAdminFormState } from '~/lib/admin-forms' - -export type ClientRevokeButtonProps = { - id: string -} - -export function ClientRevokeButton({ id }: ClientRevokeButtonProps) { - const t = useTranslations('admin.clients') - const messages = useTranslations('admin.messages') - const [state, formAction] = useActionState(revokeApiClient, emptyAdminFormState) - - return ( -
- - }> - {t('revoke')} - - {state.status === 'error' && state.message ? ( - - {messages(state.message)} - - ) : null} -
- ) -} diff --git a/src/components/admin/ClientRowActions.tsx b/src/components/admin/ClientRowActions.tsx new file mode 100644 index 0000000..3862134 --- /dev/null +++ b/src/components/admin/ClientRowActions.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useActionState, useState } from 'react' +import { useTranslations } from 'next-intl' +import { TbBan, TbCopy, TbRefresh, TbTrash } from 'react-icons/tb' +import { AdminSubmit } from './AdminSubmit' +import { errorClass, ghostButtonClass } from './styles' +import { deleteApiClient, revokeApiClient, rotateApiClient } from '~/lib/admin-actions' +import { emptyAdminFormState } from '~/lib/admin-forms' + +export type ClientRowActionsProps = { + id: string + revoked: boolean +} + +export function ClientRowActions({ id, revoked }: ClientRowActionsProps) { + const t = useTranslations('admin.clients') + const messages = useTranslations('admin.messages') + const [revokeState, revokeAction] = useActionState(revokeApiClient, emptyAdminFormState) + const [rotateState, rotateAction] = useActionState(rotateApiClient, emptyAdminFormState) + const [removeState, removeAction] = useActionState(deleteApiClient, emptyAdminFormState) + const [asking, setAsking] = useState(false) + const [copied, setCopied] = useState(false) + + const state = [rotateState, removeState, revokeState].find(entry => entry.status !== 'idle') + + async function copy(token: string) { + try { + await navigator.clipboard.writeText(token) + setCopied(true) + } catch { + setCopied(false) + } + } + + return ( +
+ +
+ + }> + {t('rotate')} + +
+ + {revoked ? null : ( +
+ + }> + {t('revoke')} + +
+ )} + + {asking ? ( +
+ + }> + {t('deleteConfirm')} + + +
+ ) : ( + + )} +
+ + {rotateState.token ? ( + + + {t('tokenTitle')} + + {rotateState.token} + + {t('tokenHint')} + + ) : null} + + {state?.status === 'error' && state.message ? ( + {messages(state.message)} + ) : null} + + {state?.status === 'ok' && state.message && !rotateState.token ? ( + {messages(state.message)} + ) : null} +
+ ) +} diff --git a/src/data/repositories/clients.ts b/src/data/repositories/clients.ts index 146922e..b06aaf4 100644 --- a/src/data/repositories/clients.ts +++ b/src/data/repositories/clients.ts @@ -51,3 +51,19 @@ export async function revokeClient(id: string): Promise { return rows[0] } + +export async function rotateClientToken(id: string, tokenHash: string): Promise { + const rows = await db + .update(apiClients) + .set({ tokenHash, revokedAt: null, lastUsedAt: null }) + .where(eq(apiClients.id, id)) + .returning() + + return rows[0] +} + +export async function deleteClient(id: string): Promise { + const rows = await db.delete(apiClients).where(eq(apiClients.id, id)).returning() + + return rows[0] +} diff --git a/src/lib/admin-actions.ts b/src/lib/admin-actions.ts index 22b5d24..caedf73 100644 --- a/src/lib/admin-actions.ts +++ b/src/lib/admin-actions.ts @@ -3,7 +3,12 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { performSaveBrand } from './admin-brands' -import { performCreateClient, performRevokeClient } from './admin-clients' +import { + performCreateClient, + performDeleteClient, + performRevokeClient, + performRotateClient, +} from './admin-clients' import { performDeletePostType, performSavePostType } from './admin-post-types' import { performSaveProject } from './admin-projects' import { performDeleteMessage, performResetWindow, performSaveSlack, performSendReport } from './admin-settings' @@ -177,3 +182,21 @@ export async function resetReportWindow(_state: AdminFormState): Promise { + const viewer = await requireAdmin() + const result = await performRotateClient(viewer, formData) + + revalidatePath(adminClientsPath) + + return result +} + +export async function deleteApiClient(_state: AdminFormState, formData: FormData): Promise { + const viewer = await requireAdmin() + const result = await performDeleteClient(viewer, formData) + + revalidatePath(adminClientsPath) + + return result +} diff --git a/src/lib/admin-clients.ts b/src/lib/admin-clients.ts index b4b1416..9d16bc0 100644 --- a/src/lib/admin-clients.ts +++ b/src/lib/admin-clients.ts @@ -1,7 +1,13 @@ import { randomBytes } from 'node:crypto' import { z } from 'zod' import { recordAudit } from '~/data/repositories/audit' -import { createClient, findClientById, revokeClient } from '~/data/repositories/clients' +import { + createClient, + deleteClient, + findClientById, + revokeClient, + rotateClientToken, +} from '~/data/repositories/clients' import { findProjectById } from '~/data/repositories/projects' import { hashToken } from './api-auth' import { @@ -112,3 +118,68 @@ export async function performRevokeClient(viewer: Viewer, formData: FormData): P return saved(id, false) } + +export async function performRotateClient(viewer: Viewer, formData: FormData): Promise { + if (!isAdmin(viewer)) { + return invalid('forbidden') + } + + const id = readField(formData, 'id') + + if (!isUuid(id)) { + return invalid('clientUnknown') + } + + const known = await findClientById(id) + + if (!known) { + return invalid('clientUnknown') + } + + const token = createClientToken() + const rotated = await rotateClientToken(id, hashToken(token)) + + if (!rotated) { + return invalid('clientUnknown') + } + + await recordAudit({ + actorId: viewer.id, + actorLabel: viewer.email, + action: 'client.rotate', + entity: 'api_client', + entityId: rotated.id, + data: { name: rotated.name, wasRevoked: known.revokedAt !== null }, + }) + + return { status: 'ok', message: 'clientRotated', id: rotated.id, token } +} + +export async function performDeleteClient(viewer: Viewer, formData: FormData): Promise { + if (!isAdmin(viewer)) { + return invalid('forbidden') + } + + const id = readField(formData, 'id') + + if (!isUuid(id)) { + return invalid('clientUnknown') + } + + const removed = await deleteClient(id) + + if (!removed) { + return invalid('clientUnknown') + } + + await recordAudit({ + actorId: viewer.id, + actorLabel: viewer.email, + action: 'client.delete', + entity: 'api_client', + entityId: removed.id, + data: { name: removed.name, mode: removed.mode, scope: removed.scope }, + }) + + return { status: 'ok', message: 'clientDeleted', id: removed.id } +} diff --git a/tests/lib/admin-clients.test.ts b/tests/lib/admin-clients.test.ts index d13f7db..37ed499 100644 --- a/tests/lib/admin-clients.test.ts +++ b/tests/lib/admin-clients.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it } from 'vitest' import { findActiveClientByTokenHash, listClients } from '~/data/repositories/clients' -import { performCreateClient, performRevokeClient, tokenPrefix } from '~/lib/admin-clients' +import { + performCreateClient, + performDeleteClient, + performRevokeClient, + performRotateClient, + tokenPrefix, +} from '~/lib/admin-clients' import { hashToken } from '~/lib/api-auth' import { adminViewer, @@ -157,3 +163,76 @@ describe('performRevokeClient', () => { expect((await listClients())[0]?.revokedAt).toBeNull() }) }) + +async function makeClient(name = 'Claude'): Promise<{ id: string, token: string }> { + const state = await performCreateClient( + adminViewer(), + form({ name, mode: 'write', scope: 'internal' }), + ) + + if (state.status !== 'ok' || !state.id || !state.token) { + throw new Error('Zugang nicht angelegt') + } + + return { id: state.id, token: state.token } +} + +function withId(id: string): FormData { + return form({ id }) +} + +describe('Token erneuern', () => { + it('macht das alte Token ungültig und gibt ein neues zurück', async () => { + const { id, token } = await makeClient() + const state = await performRotateClient(adminViewer(), withId(id)) + + expect(state.status).toBe('ok') + expect(state.token).toBeDefined() + expect(state.token).not.toBe(token) + + expect(await findActiveClientByTokenHash(hashToken(token))).toBeUndefined() + expect(await findActiveClientByTokenHash(hashToken(state.token!))).toMatchObject({ id }) + }) + + it('weckt einen widerrufenen Zugang wieder auf', async () => { + const { id } = await makeClient() + + await performRevokeClient(adminViewer(), withId(id)) + + const state = await performRotateClient(adminViewer(), withId(id)) + + expect(await findActiveClientByTokenHash(hashToken(state.token!))).toMatchObject({ id, revokedAt: null }) + }) + + it('bleibt Moderatoren verwehrt', async () => { + const { id } = await makeClient() + const state = await performRotateClient(moderatorViewer(), withId(id)) + + expect(state).toMatchObject({ status: 'error', message: 'forbidden' }) + }) +}) + +describe('Zugang löschen', () => { + it('entfernt ihn wirklich, nicht nur als widerrufen', async () => { + const { id, token } = await makeClient() + const state = await performDeleteClient(adminViewer(), withId(id)) + + expect(state).toMatchObject({ status: 'ok', message: 'clientDeleted' }) + expect(await listClients()).toHaveLength(0) + expect(await findActiveClientByTokenHash(hashToken(token))).toBeUndefined() + }) + + it('meldet einen unbekannten Zugang', async () => { + const state = await performDeleteClient(adminViewer(), withId('11111111-1111-4111-8111-111111111111')) + + expect(state).toMatchObject({ status: 'error', message: 'clientUnknown' }) + }) + + it('bleibt Moderatoren verwehrt', async () => { + const { id } = await makeClient() + const state = await performDeleteClient(moderatorViewer(), withId(id)) + + expect(state).toMatchObject({ status: 'error', message: 'forbidden' }) + expect(await listClients()).toHaveLength(1) + }) +}) diff --git a/tests/scripts/seed.test.ts b/tests/scripts/seed.test.ts index 5f47775..da96720 100644 --- a/tests/scripts/seed.test.ts +++ b/tests/scripts/seed.test.ts @@ -14,7 +14,7 @@ async function counts() { } } -describe('seed', () => { +describe('seed', { timeout: 30_000 }, () => { it('legt Marken, Projekte und Beiträge an', async () => { await seed()