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:
co-authored by
Claude Opus 5
parent
6d3ebd05ae
commit
b17c42af56
@@ -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}“ 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 "Authorization: Bearer …" {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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user