feat: revocable read-only API keys for machine access

Integrations such as the Wabenhain shop sync no longer need a service
account with a password (and without MFA). An admin creates a key under
Verwaltung > Automatisierung > API-Schlüssel.

- Key format ack_<8>_<43>; only its SHA-256 hash is stored, the key is
  shown exactly once. Optional expiry, "last used" timestamp, revocation
  takes effect immediately; create/revoke are audit-logged without the key.
- A key is not a user: JwtAuthGuard sets request.apiKey, PermissionsGuard
  admits it only to routes whose permission is among its scopes and never
  to user-only routes. Scopes come from a short allowlist (shop-sync:read).
- Managing keys requires the new permission api-keys:manage.
- Keys are excluded from backups on purpose; a restore means issuing a new
  key.

Additive migration (new table).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 01:24:17 +02:00
1 parent ede86c062a
commit 445fb3b94b
21 files changed
+848 -5

No files matched your search

@@ -0,0 +1,204 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import { API_KEY_SCOPE_LABELS, ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
function formatDate(value: string | null): string {
return value
? new Date(value).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })
: "—";
}
function keyState(apiKey: ApiKey): { label: string; active: boolean } {
if (apiKey.revokedAt) return { label: "Widerrufen", active: false };
if (apiKey.expiresAt && new Date(apiKey.expiresAt) <= new Date()) {
return { label: "Abgelaufen", active: false };
}
return { label: "Aktiv", active: true };
}
/**
* Keys for machine access (the Wabenhain shop sync) instead of a service
* account with a password. The key is shown once; ApiCore keeps only a hash.
*/
export function ApiKeysTab() {
const queryClient = useQueryClient();
const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState("");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const [copied, setCopied] = useState(false);
const { data: keys, isLoading, isError } = useQuery({
queryKey: ["api-keys"],
queryFn: fetchApiKeys,
});
const createMutation = useMutation({
mutationFn: createApiKey,
onSuccess: (result) => {
setCreated({ name: result.apiKey.name, token: result.token });
setCopied(false);
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
},
onError: () => toast.error("Schlüssel konnte nicht angelegt werden."),
});
const revokeMutation = useMutation({
mutationFn: revokeApiKey,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
toast.success("Schlüssel widerrufen – er funktioniert ab sofort nicht mehr.");
},
onError: () => toast.error("Schlüssel konnte nicht widerrufen werden."),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({ name: name.trim(), scopes: DEFAULT_SCOPES, expiresInDays: days });
}
async function copyToken() {
if (!created) return;
try {
await navigator.clipboard.writeText(created.token);
setCopied(true);
} catch {
toast.error("Kopieren nicht möglich – bitte den Schlüssel markieren und manuell kopieren.");
}
}
return (
<div className="flex flex-col gap-6">
<p className="max-w-prose text-sm text-muted-foreground">
Ein API-Schlüssel erlaubt einem anderen System genau eine Sache – etwa dem Wabenhain-Shop,
die freigegebenen Shop-Daten zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und
lässt sich jederzeit widerrufen.
</p>
{created ? (
<section
aria-live="polite"
className="flex flex-col gap-3 rounded-2xl border border-primary/40 bg-primary/5 p-4"
>
<div className="flex items-center gap-2 font-medium">
<ShieldCheck className="size-5 text-primary" aria-hidden />
Schlüssel „{created.name}“ angelegt
</div>
<p className="text-sm">
Jetzt kopieren und in Wabenhain als <code className="font-mono">APICORE_API_KEY</code>{" "}
eintragen. Er wird <strong>nur dieses eine Mal</strong> angezeigt.
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg bg-card px-3 py-2 font-mono text-xs select-all">
{created.token}
</code>
<Button size="touch" variant="outline" onClick={() => void copyToken()}>
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
{copied ? "Kopiert" : "Kopieren"}
</Button>
</div>
<Button size="sm" variant="ghost" className="self-start" onClick={() => setCreated(null)}>
Erledigt, Hinweis schließen
</Button>
</section>
) : null}
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-3">
<div className="flex min-w-56 flex-1 flex-col gap-1.5">
<Label htmlFor="api-key-name">Wofür ist der Schlüssel?</Label>
<Input
id="api-key-name"
className="h-11"
value={name}
maxLength={80}
required
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex w-44 flex-col gap-1.5">
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
<Input
id="api-key-expiry"
className="h-11"
type="number"
inputMode="numeric"
min={1}
max={730}
placeholder="bis Widerruf"
value={expiresInDays}
onChange={(event) => setExpiresInDays(event.target.value)}
/>
</div>
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
<KeyRound aria-hidden />
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
</Button>
</form>
<p className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}
</p>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
API-Schlüssel konnten nicht geladen werden.
</p>
) : null}
{keys && keys.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine API-Schlüssel angelegt.</p>
) : null}
{keys && keys.length > 0 ? (
<ul className="flex flex-col gap-2">
{keys.map((apiKey) => {
const state = keyState(apiKey);
return (
<li
key={apiKey.id}
className="flex flex-wrap items-center gap-3 rounded-xl border border-border/60 bg-card p-3"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2 font-medium">
{apiKey.name}
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
</span>
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span>
<span className="text-xs text-muted-foreground">
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
{formatDate(apiKey.lastUsedAt)}
{apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""}
</span>
</div>
{state.active ? (
<ConfirmDialog
triggerLabel="Widerrufen"
triggerSize="sm"
triggerDisabled={revokeMutation.isPending}
title="Schlüssel widerrufen?"
description={`„${apiKey.name}“ funktioniert danach sofort nicht mehr. Das angebundene System braucht dann einen neuen Schlüssel.`}
confirmLabel="Widerrufen"
destructive
isPending={revokeMutation.isPending}
onConfirm={() => revokeMutation.mutate(apiKey.id)}
/>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
}
@@ -11,6 +11,7 @@ import { LabelTemplatesTab } from "./label-templates-tab";
import { SocialMediaTab } from "./social-media-tab";
import { AutomationRulesTab } from "./automation-rules-tab";
import { CalendarSyncTab } from "./calendar-sync-tab";
import { ApiKeysTab } from "./api-keys-tab";
type Tab =
| "webhooks"
@@ -18,7 +19,8 @@ type Tab =
| "label-templates"
| "social-media"
| "rules"
| "calendar-sync";
| "calendar-sync"
| "api-keys";
const TABS: { value: Tab; label: string }[] = [
{ value: "webhooks", label: "Webhooks" },
@@ -27,6 +29,7 @@ const TABS: { value: Tab; label: string }[] = [
{ value: "social-media", label: "Social Media" },
{ value: "rules", label: "Regeln" },
{ value: "calendar-sync", label: "Kalender-Sync" },
{ value: "api-keys", label: "API-Schlüssel" },
];
function isTab(value: string | null): value is Tab {
@@ -47,7 +50,7 @@ function AutomationPageContent() {
<PageHeader
icon={Zap}
title="Automatisierung"
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media, Regeln und Kalender-Sync"
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media, Regeln, Kalender-Sync und API-Schlüssel"
gradient="var(--gradient-sky)"
/>
@@ -59,6 +62,7 @@ function AutomationPageContent() {
{tab === "social-media" ? <SocialMediaTab /> : null}
{tab === "rules" ? <AutomationRulesTab /> : null}
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
{tab === "api-keys" ? <ApiKeysTab /> : null}
</main>
);
}
@@ -18,6 +18,7 @@ const SETTINGS_AREA_PERMISSIONS = [
"backup:import",
"backup:configure",
"calendar:configure",
"api-keys:manage",
];
export default function SettingsLayout({ children }: { children: ReactNode }) {
@@ -105,6 +105,7 @@ const ADMIN_GROUP: NavGroup = {
"label-templates:read",
"site-settings:read",
"calendar:configure",
"api-keys:manage",
],
},
{
+17
View File
@@ -0,0 +1,17 @@
import { apiFetch } from "@/lib/auth/api-client";
import { ApiKey, CreateApiKeyInput, CreatedApiKey } from "./types";
export function fetchApiKeys(): Promise<ApiKey[]> {
return apiFetch<ApiKey[]>("/api-keys");
}
export function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
return apiFetch<CreatedApiKey>("/api-keys", {
method: "POST",
body: JSON.stringify(input),
});
}
export function revokeApiKey(id: string): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
}
@@ -0,0 +1,29 @@
export type ApiKeyScope = "shop-sync:read";
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
};
export interface ApiKey {
id: string;
name: string;
prefix: string;
scopes: ApiKeyScope[];
createdByUserId: string | null;
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
}
export interface CreateApiKeyInput {
name: string;
scopes: ApiKeyScope[];
expiresInDays?: number;
}
export interface CreatedApiKey {
apiKey: ApiKey;
/** Shown exactly once - the server keeps only a hash. */
token: string;
}