diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.spec.tsx new file mode 100644 index 0000000..cfe78c2 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.spec.tsx @@ -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({ui}); +} + +function key(overrides: Partial): 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(); + + 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(); + 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(); + 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(); + expect(await screen.findByText("ERPNext")).toBeInTheDocument(); + expect(screen.getByText("Darf: Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.tsx index f42dc7a..e6fefe9 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/api-keys-tab.tsx @@ -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 APICORE_API_KEY{" "} + 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([...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 (

- 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.

{created ? ( @@ -109,8 +146,7 @@ export function ApiKeysTab() { Schlüssel „{created.name}“ angelegt

- Jetzt kopieren und in Wabenhain als APICORE_API_KEY{" "} - eintragen. Er wird nur dieses eine Mal angezeigt. + {pasteHint(created.scopes)} Er wird nur dieses eine Mal angezeigt.

@@ -164,15 +200,32 @@ export function ApiKeysTab() { aria-describedby="api-key-ips-hint" />
-

- 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.

{isLoading ?

Lädt…

: null} @@ -200,6 +253,9 @@ export function ApiKeysTab() { {state.label} {apiKey.prefix}_… + + Darf: {apiKey.scopes.map(apiKeyScopeLabel).join(" · ")} + Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "} {formatDate(apiKey.lastUsedAt)} diff --git a/ApiCore-Frontend/src/lib/api-keys/types.ts b/ApiCore-Frontend/src/lib/api-keys/types.ts index 9441f64..1eef324 100644 --- a/ApiCore-Frontend/src/lib/api-keys/types.ts +++ b/ApiCore-Frontend/src/lib/api-keys/types.ts @@ -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 = { "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)[scope] ?? scope; +} + export interface ApiKey { id: string; name: string;