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:
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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user