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. +

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