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 (
-
- )
-}
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 (
+
+
+
+
+ {revoked ? null : (
+
+ )}
+
+ {asking ? (
+
+ ) : (
+ setAsking(true)}
+ className={`${ghostButtonClass} hover:border-signal hover:text-signal`}
+ >
+
+ {t('delete')}
+
+ )}
+
+
+ {rotateState.token ? (
+
+
+ {t('tokenTitle')}
+
+ {rotateState.token}
+ void copy(rotateState.token ?? '')}
+ className={ghostButtonClass}
+ >
+
+ {copied ? t('copied') : t('copy')}
+
+ {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()