Merge feat/erp-inventar-export: read-only inventory mirror for ERPNext

Brings the ERPNext inventory export (T8) into main after its review:
strict seit format, settle window on the DB clock, BatchMovement
(createdAt, id) index, one scope per new API key and the failed-attempt
lockout on the shared throttler storage. Conflicts resolved in the
permission catalog (System and ERP-Anbindung groups) and ARCHITECTURE.md
(lockout is Annahme 30).

Gates on the merged state: backend lint, typecheck, 1909 unit, 471 e2e,
build, no schema drift; frontend 929 tests, lint, typecheck, build.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:44:03 +02:00
commit 2d5c740f7f
33 files changed
+3933 -106

No files matched your search

@@ -3,8 +3,14 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiKeysTab } from "./api-keys-tab";
import { createApiKey, fetchApiKeys } from "@/lib/api-keys/api";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import type { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
// Rechte des angemeldeten Nutzers; je Test anpassbar.
let ownPermissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => ownPermissions.includes(permission) }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/api-keys/api", () => ({
@@ -16,7 +22,7 @@ vi.mock("@/lib/api-keys/api", () => ({
const HINT = /Dieser Schlüssel darf lesen und buchen/;
function makeKey(id: string, scopes: ApiKeyScope[]): ApiKey {
function makeKey(id: string, scopes: ApiKeyScope[], overrides: Partial<ApiKey> = {}): ApiKey {
return {
id,
name: `Schlüssel ${id}`,
@@ -29,6 +35,7 @@ function makeKey(id: string, scopes: ApiKeyScope[]): ApiKey {
allowedIps: [],
expiresAt: null,
revokedAt: null,
...overrides,
};
}
@@ -44,47 +51,167 @@ function renderTab() {
describe("ApiKeysTab", () => {
beforeEach(() => {
vi.clearAllMocks();
ownPermissions = ["api-keys:manage", "shop-sync:read", "shop-sync:write", "erp-inventory:read"];
vi.mocked(fetchApiKeys).mockReset().mockResolvedValue([]);
vi.mocked(createApiKey).mockReset();
vi.mocked(revokeApiKey).mockReset();
});
it("shows the separation hint only for a key with both scopes", async () => {
describe("separation hint", () => {
it("shows the separation hint only for a key with both scopes", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("both", ["shop-sync:read", "shop-sync:write"]),
makeKey("read", ["shop-sync:read"]),
]);
renderTab();
const hints = await screen.findAllByRole("status");
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
expect(hints[0]).toHaveTextContent(/in Wabenhain eintragen/);
});
it("also marks a key that reads the ERP mirror and books, naming both systems", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("mixed", ["erp-inventory:read", "shop-sync:write"]),
]);
renderTab();
const hint = await screen.findByRole("status");
expect(hint).toHaveTextContent(HINT);
expect(hint).toHaveTextContent(/in ERPNext bzw. Wabenhain eintragen/);
});
it("marks an older key with several read scopes", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("reads", ["shop-sync:read", "erp-inventory:read"]),
]);
renderTab();
const hint = await screen.findByRole("status");
expect(hint).toHaveTextContent(/mehrere Rechte/);
expect(hint).not.toHaveTextContent(HINT);
});
it("shows no hint when every key has one scope", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("read", ["shop-sync:read"]),
makeKey("erp", ["erp-inventory:read"]),
]);
renderTab();
await screen.findByText("Schlüssel read");
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
});
});
describe("creating", () => {
it("creates a read key by default", async () => {
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("new", ["shop-sync:read"]),
token: "t",
});
renderTab();
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
});
it('creates a write key when "Shop buchen" is chosen', async () => {
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("new", ["shop-sync:write"]),
token: "t",
});
renderTab();
await userEvent.click(await screen.findByRole("radio", { name: "Shop buchen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
});
it("legt einen ERP-Schlüssel nur mit dem Inventar-Recht an und sagt, wohin er gehört", async () => {
vi.mocked(createApiKey).mockResolvedValueOnce({
apiKey: makeKey("erp", ["erp-inventory:read"], { name: "ERPNext" }),
// Synthetischer Wert, kein echter Schlüssel.
token: `ack_abcd1234_${"A".repeat(43)}`,
});
renderTab();
const name = screen.getByLabelText("Wofür ist der Schlüssel?");
await userEvent.clear(name);
await userEvent.type(name, "ERPNext");
await userEvent.click(screen.getByRole("radio", { name: "ERP-Inventar 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 das Lesen vor, genau ein Recht je Schlüssel", () => {
renderTab();
expect(screen.getByRole("radio", { name: "Shop lesen" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByRole("radio", { name: "Shop buchen" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
});
});
describe("only grantable scopes", () => {
it("bietet nur Rechte an, die der angemeldete Nutzer selbst hat", () => {
ownPermissions = ["api-keys:manage", "shop-sync:read", "erp-inventory:read"];
renderTab();
expect(screen.getByRole("radio", { name: "Shop lesen" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Shop buchen" })).toBeNull();
});
it("wählt ein erlaubtes Recht vor, wenn der Nutzer das Shop-Lesen nicht hat", async () => {
ownPermissions = ["api-keys:manage", "erp-inventory:read"];
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("erp", ["erp-inventory:read"]),
token: "t",
});
renderTab();
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
"aria-checked",
"true",
);
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["erp-inventory:read"]);
});
it("wählt nichts vor, was der Nutzer nicht vergeben darf, und erklärt es", () => {
ownPermissions = ["api-keys:manage"];
renderTab();
expect(screen.queryAllByRole("radio")).toHaveLength(0);
expect(screen.getByText(/keine Rechte, die ein API-Schlüssel tragen kann/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
});
});
it("zeigt in der Liste, was ein Schlüssel darf", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("both", ["shop-sync:read", "shop-sync:write"]),
makeKey("read", ["shop-sync:read"]),
makeKey("k2", ["erp-inventory:read"], { name: "ERPNext" }),
]);
renderTab();
const hints = await screen.findAllByRole("status");
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
});
it("shows no hint when every key has one scope", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([makeKey("read", ["shop-sync:read"])]);
renderTab();
await screen.findByText("Schlüssel read");
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
});
it("creates a read key by default", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:read"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
});
it('creates a write key when "Buchen" is chosen', async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:write"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("radio", { name: "Buchen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
expect(await screen.findByText("ERPNext")).toBeInTheDocument();
expect(screen.getByText("Darf:")).toBeInTheDocument();
expect(screen.getByText("Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
});
});
@@ -6,23 +6,44 @@ import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { API_KEY_SCOPE_LABELS, hasCombinedShopScopes } from "@/lib/api-keys/scopes";
import {
API_KEY_SCOPE_LABELS,
API_KEY_SCOPES,
apiKeyScopeLabel,
separationAdvice,
} from "@/lib/api-keys/scopes";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
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 { useAuth } from "@/lib/auth/auth-context";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
// Reading and booking are separate keys, so a leaked key can only do one.
// Genau ein Recht je Schlüssel: Lesen und Buchen nie zusammen, und je
// angebundenem System ein eigener Schlüssel. Vorschlag wie bisher: der Shop liest.
const DEFAULT_SCOPE: ApiKeyScope = "shop-sync:read";
const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Lesen",
"shop-sync:write": "Buchen",
"shop-sync:read": "Shop lesen",
"shop-sync:write": "Shop buchen",
"erp-inventory:read": "ERP-Inventar lesen",
};
const SCOPE_OPTIONS = choiceOptions(SCOPE_CHOICE_LABELS);
const SCOPE_LABEL_ID = "api-key-scope-label";
/** 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
? new Date(value).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })
@@ -38,16 +59,30 @@ function keyState(apiKey: ApiKey): { label: string; active: boolean } {
}
/**
* 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.
* Keys for machine access (the Wabenhain shop sync, ERPNext's inventory
* mirror) 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 { hasPermission } = useAuth();
// Der Server lässt nur Rechte zu, die der Anlegende selbst hat - das UI
// bietet deshalb auch nur diese an, statt erst beim Anlegen abzulehnen.
const grantable = API_KEY_SCOPES.filter((scope) => hasPermission(scope));
const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState("");
const [scope, setScope] = useState<ApiKeyScope>("shop-sync:read");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const [chosenScope, setScope] = useState<ApiKeyScope>(DEFAULT_SCOPE);
// Fehlt dem Nutzer das gewählte Recht, gilt das erste, das er vergeben darf.
const scope: ApiKeyScope | undefined = grantable.includes(chosenScope)
? chosenScope
: grantable[0];
const scopeOptions = choiceOptions(SCOPE_CHOICE_LABELS, grantable);
const [created, setCreated] = useState<{
name: string;
token: string;
scopes: ApiKeyScope[];
} | null>(null);
const [copied, setCopied] = useState(false);
const { data: keys, isLoading, isError } = useQuery({
@@ -58,7 +93,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"] });
},
@@ -81,6 +120,7 @@ export function ApiKeysTab() {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!scope) return;
const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({
name: name.trim(),
@@ -104,8 +144,9 @@ export function ApiKeysTab() {
<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.
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 ? (
@@ -118,8 +159,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">
@@ -148,15 +188,21 @@ export function ApiKeysTab() {
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex w-56 flex-col gap-1.5">
<div className="flex w-full flex-col gap-1.5">
<Label id={SCOPE_LABEL_ID}>Berechtigung</Label>
<ChoiceGroup
options={SCOPE_OPTIONS}
value={scope}
onChange={setScope}
labelledBy={SCOPE_LABEL_ID}
className="grid-cols-2"
/>
{scope ? (
<ChoiceGroup
options={scopeOptions}
value={scope}
onChange={setScope}
labelledBy={SCOPE_LABEL_ID}
className="grid-cols-1 sm:grid-cols-3"
/>
) : (
<p className="text-sm text-muted-foreground">
Sie haben selbst keine Rechte, die ein API-Schlüssel tragen kann.
</p>
)}
</div>
<div className="flex w-44 flex-col gap-1.5">
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
@@ -183,15 +229,20 @@ export function ApiKeysTab() {
aria-describedby="api-key-ips-hint"
/>
</div>
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
<Button
type="submit"
size="touch"
disabled={createMutation.isPending || !name.trim() || !scope}
>
<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[scope]}. Lesen und Buchen bekommen je einen eigenen Schlüssel. 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.
{scope ? `Berechtigung: ${API_KEY_SCOPE_LABELS[scope]}. ` : null}Je angebundenem System ein
eigener Schlüssel; Lesen und Buchen bekommen je einen eigenen. 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}
@@ -208,6 +259,7 @@ export function ApiKeysTab() {
<ul className="flex flex-col gap-2">
{keys.map((apiKey) => {
const state = keyState(apiKey);
const advice = state.active ? separationAdvice(apiKey.scopes) : null;
return (
<li
key={apiKey.id}
@@ -219,18 +271,17 @@ 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="flex flex-wrap gap-1.5">
<span className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground">Darf:</span>
{apiKey.scopes.map((keyScope) => (
<Badge key={keyScope} variant="secondary">
{API_KEY_SCOPE_LABELS[keyScope]}
{apiKeyScopeLabel(keyScope)}
</Badge>
))}
</span>
{state.active && hasCombinedShopScopes(apiKey) ? (
{advice ? (
<p role="status" className="text-xs text-muted-foreground">
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
Wabenhain eintragen, dann diesen widerrufen.
{advice}
</p>
) : null}
<span className="text-xs text-muted-foreground">
@@ -1,13 +1,66 @@
import { describe, expect, it } from "vitest";
import { hasCombinedShopScopes } from "./scopes";
import {
API_KEY_SCOPES,
apiKeyScopeLabel,
hasReadAndWriteScopes,
separationAdvice,
} from "./scopes";
describe("hasCombinedShopScopes", () => {
describe("hasReadAndWriteScopes", () => {
it("is true for a key that may read and book", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
});
it("treats the ERP inventory scope as reading", () => {
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:write"] })).toBe(true);
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:read"] })).toBe(false);
});
it("is false for single-scope keys", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read"] })).toBe(false);
expect(hasCombinedShopScopes({ scopes: ["shop-sync:write"] })).toBe(false);
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read"] })).toBe(false);
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:write"] })).toBe(false);
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read"] })).toBe(false);
});
});
describe("separationAdvice", () => {
it("is null for a key with one scope", () => {
expect(separationAdvice(["shop-sync:read"])).toBeNull();
expect(separationAdvice(["erp-inventory:read"])).toBeNull();
});
it("names Wabenhain for a shop key that reads and books", () => {
const advice = separationAdvice(["shop-sync:read", "shop-sync:write"]);
expect(advice).toMatch(/darf lesen und buchen/);
expect(advice).toMatch(/zwei getrennte Schlüssel/);
expect(advice).toMatch(/in Wabenhain eintragen/);
});
it("names each consuming system, not only Wabenhain", () => {
const advice = separationAdvice(["erp-inventory:read", "shop-sync:write"]);
expect(advice).toMatch(/darf lesen und buchen/);
expect(advice).toMatch(/in ERPNext bzw. Wabenhain eintragen/);
});
it("advises separate keys for several read scopes too", () => {
const advice = separationAdvice(["shop-sync:read", "erp-inventory:read"]);
expect(advice).toMatch(/mehrere Rechte/);
expect(advice).toMatch(/je Recht ein eigener Schlüssel/);
expect(advice).toMatch(/in Wabenhain bzw. ERPNext eintragen/);
});
it("falls back to a neutral wording for unknown scopes", () => {
expect(separationAdvice(["future:a", "future:b"])).toMatch(/im angebundenen System eintragen/);
});
});
describe("API key scopes", () => {
it("match the backend allowlist", () => {
expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]);
});
it("label known scopes and pass unknown ones through", () => {
expect(apiKeyScopeLabel("erp-inventory:read")).toBe("Inventar-Spiegel lesen (ERPNext)");
expect(apiKeyScopeLabel("future:scope")).toBe("future:scope");
});
});
+45 -4
View File
@@ -1,16 +1,57 @@
import type { ApiKey } from "./types";
/** 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 = "shop-sync:read" | "shop-sync:write";
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 buchen (Wabenhain)",
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
};
/** Buchende Rechte - wie API_KEY_WRITE_SCOPES im Backend; alle anderen lesen nur. */
const WRITE_SCOPES: readonly string[] = ["shop-sync:write"];
/** 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;
}
/**
* Keys issued before reading and booking were split may still carry both;
* the server no longer creates such keys, the UI nudges towards two keys.
* The ERP inventory scope counts as reading.
*/
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
return apiKey.scopes.includes("shop-sync:read") && apiKey.scopes.includes("shop-sync:write");
export function hasReadAndWriteScopes(apiKey: { scopes: readonly string[] }): boolean {
const isWrite = (scope: string) => WRITE_SCOPES.includes(scope);
return apiKey.scopes.some(isWrite) && apiKey.scopes.some((scope) => !isWrite(scope));
}
/** Welches System einen Schlüssel mit diesem Recht benutzt. */
const CONSUMING_SYSTEM: Record<ApiKeyScope, string> = {
"shop-sync:read": "Wabenhain",
"shop-sync:write": "Wabenhain",
"erp-inventory:read": "ERPNext",
};
function consumingSystems(scopes: readonly string[]): string[] {
const systems = scopes
.map((scope) => (CONSUMING_SYSTEM as Record<string, string>)[scope])
.filter((system): system is string => system !== undefined);
return [...new Set(systems)];
}
/**
* Hinweis für einen älteren Schlüssel mit mehreren Rechten (der Server legt
* nur noch Schlüssel mit genau einem Recht an), oder null. Nennt das System,
* in dem die neuen Schlüssel einzutragen sind.
*/
export function separationAdvice(scopes: readonly string[]): string | null {
if (new Set(scopes).size < 2) return null;
const systems = consumingSystems(scopes);
const where = systems.length > 0 ? `in ${systems.join(" bzw. ")}` : "im angebundenen System";
const why = hasReadAndWriteScopes({ scopes })
? "Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte Schlüssel: einer zum Lesen, einer zum Buchen."
: "Dieser Schlüssel trägt mehrere Rechte. Sicherer ist je Recht ein eigener Schlüssel.";
return `${why} Neue Schlüssel anlegen, ${where} eintragen, dann diesen widerrufen.`;
}