feat: Tokens verwalten

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-12 11:09:36 +02:00
co-authored by Claude Opus 5
parent 6d3ebd05ae
commit b17c42af56
3 changed files with 424 additions and 0 deletions
+77
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<KeyRound className="text-gedaempft-vordergrund size-6" aria-hidden />
<div>
<h1 className="text-2xl font-semibold tracking-tight">API-Tokens</h1>
<p className="text-gedaempft-vordergrund text-sm">
Zugänge für fremde Anwendungen — mit so wenig Rechten wie möglich.
</p>
</div>
</div>
<Card>
<CardKopf>
<CardTitel className="text-base">Tokens</CardTitel>
<CardBeschreibung>
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.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<TokenVerwaltung
adresse={adresse}
tokens={tokens.map((t) => ({
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),
}))}
/>
</CardInhalt>
</Card>
</div>
)
}
@@ -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<Record<string, string>>({})
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 (
<div className="flex flex-col gap-6">
{/* Der frisch erzeugte Token – einmalig */}
{frisch && (
<div className="border-betont bg-betont/5 rounded-md border p-4">
<p className="text-sm font-medium">„{frisch.name}&ldquo; ist angelegt.</p>
<p className="text-gedaempft-vordergrund mt-1 text-xs">
Jetzt kopieren. Sobald du diese Seite verlässt, ist der Wert weg — in der
Datenbank liegt nur sein Fingerabdruck.
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<code className="border-rand bg-karte grow overflow-x-auto rounded-md border px-3 py-2 font-mono text-xs">
{frisch.klartext}
</code>
<Button
art="umriss"
onClick={() => {
void navigator.clipboard
.writeText(frisch.klartext)
.then(() => toast.success('Kopiert.'))
.catch(() => toast.error('Das Kopieren hat nicht geklappt.'))
}}
>
<Copy aria-hidden />
Kopieren
</Button>
<Button art="leise" onClick={() => setFrisch(null)}>
Fertig
</Button>
</div>
<p className="text-gedaempft-vordergrund mt-3 text-xs">
Ausprobieren:{' '}
<code className="text-xs">
curl -H &quot;Authorization: Bearer …&quot; {adresse}/api/v1/selbst
</code>
</p>
</div>
)}
{/* Liste */}
{tokens.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
Noch kein Token vergeben. Ohne Token kommt von außen nichts herein.
</p>
) : (
<ul className="divide-rand divide-y">
{tokens.map((token) => (
<li key={token.id} className="flex flex-wrap items-center gap-3 py-3">
<KeyRound
className={
token.widerrufen
? 'text-gedaempft-vordergrund size-4 shrink-0'
: 'text-erfolg size-4 shrink-0'
}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm">
<span className={token.widerrufen ? 'line-through' : ''}>
{token.name}
</span>
<code className="text-gedaempft-vordergrund text-xs">
planer_{token.praefix}_…
</code>
{token.widerrufen && (
<Kennzeichen art="neutral">Zurückgezogen</Kennzeichen>
)}
{token.rechte.map((r) => (
<Kennzeichen key={r} art="betont">
{RECHT_NAME[r]}
</Kennzeichen>
))}
</p>
<p className="text-gedaempft-vordergrund text-xs">
Angelegt {token.erstelltAm}
{token.gueltigBis ? ` · gilt bis ${token.gueltigBis}` : ''}
{token.letzteNutzung
? ` · zuletzt benutzt ${token.letzteNutzung}`
: ' · noch nie benutzt'}
</p>
</div>
{!token.widerrufen && (
<Button
art="leise"
groesse="quadrat"
title="Zurückziehen"
disabled={laeuft}
onClick={() => zurueckziehen(token.id)}
>
<Trash2 aria-hidden />
<span className="sr-only">Zurückziehen</span>
</Button>
)}
</li>
))}
</ul>
)}
{/* Neu */}
{offen ? (
<form
className="border-rand flex flex-col gap-4 rounded-md border p-4"
action={anlegen}
>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Wofür ist der Token?</Label>
<Input
id="name"
name="name"
placeholder="z. B. Wartungsplaner-Import"
aria-invalid={Boolean(felder.name)}
required
/>
{felder.name && <p className="text-gefahr text-xs">{felder.name}</p>}
</div>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Was darf er?</legend>
{RECHTE.map((recht) => (
<label key={recht} className="flex items-start gap-3">
<input
type="checkbox"
name="rechte"
value={recht}
className="accent-betont mt-0.5 size-4"
/>
<span>
<span className="text-sm">{RECHT_NAME[recht]}</span>
<span className="text-gedaempft-vordergrund block text-xs">
{RECHT_BESCHREIBUNG[recht]}
</span>
</span>
</label>
))}
{felder.rechte && <p className="text-gefahr text-xs">{felder.rechte}</p>}
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="gueltigTage">Gültig für (Tage)</Label>
<Input
id="gueltigTage"
name="gueltigTage"
type="number"
min={1}
max={3650}
placeholder="leer = unbegrenzt"
aria-invalid={Boolean(felder.gueltigTage)}
/>
<p className="text-gedaempft-vordergrund text-xs">
Ein Token mit Ablauf ist einer, den man nicht vergisst.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
Anlegen
</Button>
<Button type="button" art="leise" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
) : (
<div>
<Button art="umriss" onClick={() => setOffen(true)}>
<Plus aria-hidden />
Token anlegen
</Button>
</div>
)}
</div>
)
}
+103
View File
@@ -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<string, string> }
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<string, string> }
/**
* 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<TokenAntwort> {
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<string, string> = {}
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<Rueckmeldung> {
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.' }
}