From b17c42af568e95aef3ec720a604232d492d359a8 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 12 Sep 2026 11:09:36 +0200 Subject: [PATCH] feat: Tokens verwalten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlegen ist Verwaltungssache: Ein Token ist ein Zugang zur ganzen Anwendung, nicht zu einer Aufgabe. Beim Anlegen erscheint der Wert ein einziges Mal, mit dem Hinweis, dass er danach weg ist, und einem curl-Aufruf zum Ausprobieren. Ein Ablauf laesst sich setzen — ein Token mit Ablauf ist einer, den man nicht vergisst. Co-Authored-By: Claude Opus 5 --- src/app/(app)/verwaltung/api/page.tsx | 77 ++++++ .../(app)/verwaltung/api/token-verwaltung.tsx | 244 ++++++++++++++++++ src/server/aktionen/api.ts | 103 ++++++++ 3 files changed, 424 insertions(+) create mode 100644 src/app/(app)/verwaltung/api/page.tsx create mode 100644 src/app/(app)/verwaltung/api/token-verwaltung.tsx create mode 100644 src/server/aktionen/api.ts 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} + + + +
+ +

+ 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 && ( + + )} +
  • + ))} +
+ )} + + {/* Neu */} + {offen ? ( +
+
+ + + {felder.name &&

{felder.name}

} +
+ +
+ Was darf er? + {RECHTE.map((recht) => ( + + ))} + {felder.rechte &&

{felder.rechte}

} +
+ +
+ + +

+ Ein Token mit Ablauf ist einer, den man nicht vergisst. +

+
+ +
+ + +
+
+ ) : ( +
+ +
+ )} +
+ ) +} 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.' } +}