feat(ui): Berechtigungen beim Anlegen eines API-Schlüssels wählbar

Bisher vergab die Oberfläche fest shop-sync:read. Jetzt sind alle
freigegebenen Rechte ankreuzbar (Shop lesen, Shop-Buchungen,
Inventar-Spiegel für ERPNext); vorausgewählt bleibt wie bisher nur
Shop lesen, ohne Haken lässt sich nichts anlegen. Die Liste zeigt, was
ein Schlüssel darf, und der Hinweis nach dem Anlegen nennt das Zielsystem.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 11:43:57 +02:00
1 parent 68f81fabd4
commit c4cdb63414
3 files changed
+178 -15

No files matched your search

@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import { ApiKeysTab } from "./api-keys-tab";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import type { ApiKey } from "@/lib/api-keys/types";
vi.mock("@/lib/api-keys/api", () => ({
createApiKey: vi.fn(),
fetchApiKeys: vi.fn(),
revokeApiKey: vi.fn(),
updateApiKeyAllowedIps: vi.fn(),
}));
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
function key(overrides: Partial<ApiKey>): ApiKey {
return {
id: "k1",
name: "Schlüssel",
prefix: "ack_abcd1234",
scopes: ["shop-sync:read"],
createdByUserId: null,
createdAt: "2026-10-01T00:00:00.000Z",
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
...overrides,
};
}
describe("ApiKeysTab", () => {
beforeEach(() => {
vi.mocked(fetchApiKeys).mockReset().mockResolvedValue([]);
vi.mocked(createApiKey).mockReset();
vi.mocked(revokeApiKey).mockReset();
});
it("legt einen Schlüssel nur mit den angekreuzten Berechtigungen an", async () => {
vi.mocked(createApiKey).mockResolvedValueOnce({
apiKey: key({ name: "ERPNext", scopes: ["erp-inventory:read"] }),
// Synthetischer Wert, kein echter Schlüssel.
token: `ack_abcd1234_${"A".repeat(43)}`,
});
renderWithClient(<ApiKeysTab />);
const name = screen.getByLabelText("Wofür ist der Schlüssel?");
await userEvent.clear(name);
await userEvent.type(name, "ERPNext");
await userEvent.click(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ }));
await userEvent.click(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0]).toMatchObject({
name: "ERPNext",
scopes: ["erp-inventory:read"],
});
expect(await screen.findByText(/in ERPNext als Secret/)).toBeInTheDocument();
});
it("schlägt für den Shop wie bisher nur das Lesen vor", () => {
renderWithClient(<ApiKeysTab />);
expect(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ })).toHaveAttribute(
"aria-checked",
"true",
);
expect(
screen.getByRole("checkbox", { name: /Verkäufe und Rückbuchungen/ }),
).toHaveAttribute("aria-checked", "false");
expect(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ })).toHaveAttribute(
"aria-checked",
"false",
);
});
it("lässt ohne Berechtigung nichts anlegen", async () => {
renderWithClient(<ApiKeysTab />);
await userEvent.click(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ }));
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
});
it("zeigt in der Liste, was ein Schlüssel darf", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
key({ id: "k2", name: "ERPNext", scopes: ["erp-inventory:read"] }),
]);
renderWithClient(<ApiKeysTab />);
expect(await screen.findByText("ERPNext")).toBeInTheDocument();
expect(screen.getByText("Darf: Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
});
});
@@ -5,15 +5,36 @@ 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 {
API_KEY_SCOPE_LABELS,
API_KEY_SCOPES,
ApiKey,
ApiKeyScope,
apiKeyScopeLabel,
} 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";
import { ToggleChip } from "@/components/form/toggle-chip";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
// Vorschlag wie bisher: der Wabenhain-Shop liest. Alles andere wird bewusst angekreuzt.
const DEFAULT_SCOPES: readonly ApiKeyScope[] = ["shop-sync:read"];
/** Wohin der frisch angelegte Schlüssel gehört - je nach Berechtigung. */
function pasteHint(scopes: readonly ApiKeyScope[]) {
if (scopes.includes("erp-inventory:read")) {
return <>Jetzt kopieren und in ERPNext als Secret für den Inventar-Abgleich hinterlegen.</>;
}
return (
<>
Jetzt kopieren und in Wabenhain als <code className="font-mono">APICORE_API_KEY</code>{" "}
eintragen.
</>
);
}
function formatDate(value: string | null): string {
return value
@@ -38,7 +59,12 @@ export function ApiKeysTab() {
const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState("");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const [scopes, setScopes] = useState<ApiKeyScope[]>([...DEFAULT_SCOPES]);
const [created, setCreated] = useState<{
name: string;
token: string;
scopes: ApiKeyScope[];
} | null>(null);
const [copied, setCopied] = useState(false);
const { data: keys, isLoading, isError } = useQuery({
@@ -49,7 +75,11 @@ export function ApiKeysTab() {
const createMutation = useMutation({
mutationFn: createApiKey,
onSuccess: (result) => {
setCreated({ name: result.apiKey.name, token: result.token });
setCreated({
name: result.apiKey.name,
token: result.token,
scopes: result.apiKey.scopes,
});
setCopied(false);
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
},
@@ -75,12 +105,18 @@ export function ApiKeysTab() {
const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({
name: name.trim(),
scopes: DEFAULT_SCOPES,
scopes,
expiresInDays: days,
allowedIps: parseAllowlist(allowedIps),
});
}
function toggleScope(scope: ApiKeyScope, checked: boolean) {
setScopes((current) =>
API_KEY_SCOPES.filter((s) => (s === scope ? checked : current.includes(s))),
);
}
async function copyToken() {
if (!created) return;
try {
@@ -94,9 +130,10 @@ export function ApiKeysTab() {
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.
Ein API-Schlüssel erlaubt einem anderen System genau das, was angekreuzt ist – etwa dem
Wabenhain-Shop, die freigegebenen Shop-Daten zu lesen, oder dem Buchhaltungssystem, den
Inventar-Spiegel zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und lässt sich
jederzeit widerrufen.
</p>
{created ? (
@@ -109,8 +146,7 @@ export function ApiKeysTab() {
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.
{pasteHint(created.scopes)} 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">
@@ -164,15 +200,32 @@ export function ApiKeysTab() {
aria-describedby="api-key-ips-hint"
/>
</div>
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
<fieldset className="flex w-full flex-col gap-1.5">
<legend className="mb-1.5 text-sm font-medium">Was darf der Schlüssel?</legend>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{API_KEY_SCOPES.map((scope) => (
<ToggleChip
key={scope}
label={API_KEY_SCOPE_LABELS[scope]}
checked={scopes.includes(scope)}
onChange={(checked) => toggleScope(scope, checked)}
/>
))}
</div>
</fieldset>
<Button
type="submit"
size="touch"
disabled={createMutation.isPending || !name.trim() || scopes.length === 0}
>
<KeyRound aria-hidden />
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
</Button>
</form>
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}. IP-Adressen mit Komma trennen; leer
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
die zuletzt benutzte Adresse lässt sich übernehmen.
Je angebundenem System ein eigener Schlüssel mit nur den nötigen Rechten. IP-Adressen mit
Komma trennen; leer = von überall. Unbekannt? Leer lassen, einmal abrufen lassen, dann hier
„Adressen ändern“ – die zuletzt benutzte Adresse lässt sich übernehmen.
</p>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -200,6 +253,9 @@ export function ApiKeysTab() {
<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">
Darf: {apiKey.scopes.map(apiKeyScopeLabel).join(" · ")}
</span>
<span className="text-xs text-muted-foreground">
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
{formatDate(apiKey.lastUsedAt)}
+11 -1
View File
@@ -1,9 +1,19 @@
export type ApiKeyScope = "shop-sync:read";
/** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */
export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const;
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
"shop-sync:write": "Verkäufe und Rückbuchungen des Shops buchen (Wabenhain)",
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
};
/** Label für die Anzeige; unbekannte Rechte (neueres Backend) unverändert. */
export function apiKeyScopeLabel(scope: string): string {
return (API_KEY_SCOPE_LABELS as Record<string, string>)[scope] ?? scope;
}
export interface ApiKey {
id: string;
name: string;