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>
)
}