diff --git a/src/app/(app)/verwaltung/api/page.tsx b/src/app/(app)/verwaltung/api/page.tsx
new file mode 100644
index 0000000..55864d9
--- /dev/null
+++ b/src/app/(app)/verwaltung/api/page.tsx
@@ -0,0 +1,77 @@
+import type { Metadata } from 'next'
+import { KeyRound } from 'lucide-react'
+import { TokenVerwaltung } from '@/app/(app)/verwaltung/api/token-verwaltung'
+import {
+ Card,
+ CardBeschreibung,
+ CardInhalt,
+ CardKopf,
+ CardTitel,
+} from '@/components/ui/card'
+import { rechteLesen } from '@/lib/api'
+import { alsDatumUhrzeit } from '@/lib/zeit'
+import { verlangeRolle } from '@/server/berechtigung'
+import { db } from '@/server/db'
+import { umgebung } from '@/server/umgebung'
+
+export const metadata: Metadata = { title: 'API-Tokens' }
+
+export default async function ApiSeite() {
+ await verlangeRolle('ADMIN', '/verwaltung/api')
+
+ const tokens = await db.apiToken.findMany({
+ orderBy: [{ widerrufenAm: 'asc' }, { erstelltAm: 'desc' }],
+ select: {
+ id: true,
+ name: true,
+ praefix: true,
+ rechte: true,
+ gueltigBis: true,
+ letzteNutzung: true,
+ widerrufenAm: true,
+ erstelltAm: true,
+ },
+ })
+
+ const adresse = umgebung().APP_URL
+
+ return (
+
+
+
+
+
API-Tokens
+
+ Zugänge für fremde Anwendungen — mit so wenig Rechten wie möglich.
+
+
+
+
+
+
+ Tokens
+
+ Ein Token ist ein Passwort. Er wird beim Anlegen ein einziges Mal angezeigt;
+ danach liegt in der Datenbank nur noch sein Fingerabdruck. Wer ihn verliert,
+ legt einen neuen an — nachschlagen lässt er sich nicht.
+
+
+
+ ({
+ id: t.id,
+ name: t.name,
+ praefix: t.praefix,
+ rechte: rechteLesen(t.rechte),
+ gueltigBis: t.gueltigBis ? alsDatumUhrzeit(t.gueltigBis) : null,
+ letzteNutzung: t.letzteNutzung ? alsDatumUhrzeit(t.letzteNutzung) : null,
+ widerrufen: t.widerrufenAm !== null,
+ erstelltAm: alsDatumUhrzeit(t.erstelltAm),
+ }))}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(app)/verwaltung/api/token-verwaltung.tsx b/src/app/(app)/verwaltung/api/token-verwaltung.tsx
new file mode 100644
index 0000000..81bbbe0
--- /dev/null
+++ b/src/app/(app)/verwaltung/api/token-verwaltung.tsx
@@ -0,0 +1,244 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Kennzeichen } from '@/components/ui/kennzeichen'
+import { RECHTE, RECHT_BESCHREIBUNG, RECHT_NAME, type Recht } from '@/lib/api'
+import { tokenErzeugen, tokenZuruecknehmen } from '@/server/aktionen/api'
+
+export type TokenAnzeige = {
+ id: string
+ name: string
+ praefix: string
+ rechte: Recht[]
+ gueltigBis: string | null
+ letzteNutzung: string | null
+ widerrufen: boolean
+ erstelltAm: string
+}
+
+export function TokenVerwaltung({
+ tokens,
+ adresse,
+}: {
+ tokens: TokenAnzeige[]
+ adresse: string
+}) {
+ const router = useRouter()
+ const [laeuft, starte] = useTransition()
+ const [felder, setFelder] = useState>({})
+ const [offen, setOffen] = useState(false)
+ const [frisch, setFrisch] = useState<{ name: string; klartext: string } | null>(null)
+
+ function anlegen(daten: FormData) {
+ starte(async () => {
+ const antwort = await tokenErzeugen(daten)
+ if (antwort.ok) {
+ setFelder({})
+ setOffen(false)
+ setFrisch({ name: antwort.name, klartext: antwort.klartext })
+ router.refresh()
+ } else {
+ setFelder(antwort.felder ?? {})
+ toast.error(antwort.fehler)
+ }
+ })
+ }
+
+ function zurueckziehen(id: string) {
+ starte(async () => {
+ const daten = new FormData()
+ daten.set('id', id)
+ const antwort = await tokenZuruecknehmen(daten)
+ if (antwort.ok) {
+ toast.success(antwort.meldung)
+ router.refresh()
+ } else {
+ toast.error(antwort.fehler)
+ }
+ })
+ }
+
+ return (
+
+ {/* Der frisch erzeugte Token – einmalig */}
+ {frisch && (
+
+
„{frisch.name}“ ist angelegt.
+
+ Jetzt kopieren. Sobald du diese Seite verlässt, ist der Wert weg — in der
+ Datenbank liegt nur sein Fingerabdruck.
+
+
+
+
+ {frisch.klartext}
+
+ {
+ void navigator.clipboard
+ .writeText(frisch.klartext)
+ .then(() => toast.success('Kopiert.'))
+ .catch(() => toast.error('Das Kopieren hat nicht geklappt.'))
+ }}
+ >
+
+ Kopieren
+
+ setFrisch(null)}>
+ Fertig
+
+
+
+
+ Ausprobieren:{' '}
+
+ curl -H "Authorization: Bearer …" {adresse}/api/v1/selbst
+
+
+
+ )}
+
+ {/* Liste */}
+ {tokens.length === 0 ? (
+
+ Noch kein Token vergeben. Ohne Token kommt von außen nichts herein.
+
+ ) : (
+
+ {tokens.map((token) => (
+
+
+
+
+
+
+ {token.name}
+
+
+ planer_{token.praefix}_…
+
+ {token.widerrufen && (
+ Zurückgezogen
+ )}
+ {token.rechte.map((r) => (
+
+ {RECHT_NAME[r]}
+
+ ))}
+
+
+ Angelegt {token.erstelltAm}
+ {token.gueltigBis ? ` · gilt bis ${token.gueltigBis}` : ''}
+ {token.letzteNutzung
+ ? ` · zuletzt benutzt ${token.letzteNutzung}`
+ : ' · noch nie benutzt'}
+
+
+
+ {!token.widerrufen && (
+ zurueckziehen(token.id)}
+ >
+
+ Zurückziehen
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Neu */}
+ {offen ? (
+
+ ) : (
+
+
setOffen(true)}>
+
+ Token anlegen
+
+
+ )}
+
+ )
+}
diff --git a/src/server/aktionen/api.ts b/src/server/aktionen/api.ts
new file mode 100644
index 0000000..63bd165
--- /dev/null
+++ b/src/server/aktionen/api.ts
@@ -0,0 +1,103 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { tokenEingabe } from '@/lib/api'
+import { auditSchreiben } from '@/server/audit'
+import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
+import { tokenAnlegen, tokenWiderrufen } from '@/server/api/token'
+
+export type Rueckmeldung =
+ | { ok: true; meldung: string }
+ | { ok: false; fehler: string; felder?: Record }
+
+async function akteurOderFehler() {
+ try {
+ // Tokens sind Zugänge zur ganzen Anwendung. Sie anzulegen ist
+ // Verwaltungssache, nicht Alltag.
+ return { akteur: await pruefeRolle('ADMIN') }
+ } catch (fehler) {
+ if (fehler instanceof NichtErlaubt) {
+ return { antwort: { ok: false as const, fehler: fehler.message } }
+ }
+ throw fehler
+ }
+}
+
+export type TokenAntwort =
+ | { ok: true; klartext: string; name: string }
+ | { ok: false; fehler: string; felder?: Record }
+
+/**
+ * Legt einen Token an und gibt ihn **einmal** zurück.
+ *
+ * Danach steht der Wert nirgends mehr — auch nicht in der Datenbank, dort
+ * liegt nur sein Hash. Wer ihn verliert, legt einen neuen an.
+ */
+export async function tokenErzeugen(daten: FormData): Promise {
+ const geprueft = await akteurOderFehler()
+ if ('antwort' in geprueft) return geprueft.antwort
+ const { akteur } = geprueft
+
+ const gelesen = tokenEingabe.safeParse({
+ name: daten.get('name'),
+ rechte: daten.getAll('rechte'),
+ gueltigTage: daten.get('gueltigTage') || null,
+ })
+
+ if (!gelesen.success) {
+ const felder: Record = {}
+ for (const problem of gelesen.error.issues) {
+ const feld = problem.path.join('.') || 'rechte'
+ felder[feld] ??= problem.message
+ }
+ return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
+ }
+
+ const neu = await tokenAnlegen(
+ akteur.id,
+ gelesen.data.name,
+ gelesen.data.rechte,
+ gelesen.data.gueltigTage,
+ )
+
+ await auditSchreiben({
+ akteur: { id: akteur.id, email: akteur.email },
+ aktion: 'api.token-angelegt',
+ objektTyp: 'ApiToken',
+ objektId: neu.id,
+ // Name, Rechte und Präfix – nie der Token selbst.
+ details: {
+ name: gelesen.data.name,
+ rechte: gelesen.data.rechte.join(','),
+ praefix: neu.praefix,
+ },
+ })
+
+ revalidatePath('/verwaltung/api')
+ return { ok: true, klartext: neu.klartext, name: gelesen.data.name }
+}
+
+/** Zieht einen Token zurück. */
+export async function tokenZuruecknehmen(daten: FormData): Promise {
+ const geprueft = await akteurOderFehler()
+ if ('antwort' in geprueft) return geprueft.antwort
+ const { akteur } = geprueft
+
+ const id = daten.get('id')
+ if (typeof id !== 'string' || id.length === 0) {
+ return { ok: false, fehler: 'Ungültige Anfrage.' }
+ }
+
+ const geklappt = await tokenWiderrufen(id, akteur.id)
+ if (!geklappt) return { ok: false, fehler: 'Diesen Token gibt es nicht mehr.' }
+
+ await auditSchreiben({
+ akteur: { id: akteur.id, email: akteur.email },
+ aktion: 'api.token-widerrufen',
+ objektTyp: 'ApiToken',
+ objektId: id,
+ })
+
+ revalidatePath('/verwaltung/api')
+ return { ok: true, meldung: 'Der Token gilt nicht mehr.' }
+}