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:
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 { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
|
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||||
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
|
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 {
|
function formatDate(value: string | null): string {
|
||||||
return value
|
return value
|
||||||
@@ -38,7 +59,12 @@ export function ApiKeysTab() {
|
|||||||
const [name, setName] = useState("Wabenhain-Shop");
|
const [name, setName] = useState("Wabenhain-Shop");
|
||||||
const [expiresInDays, setExpiresInDays] = useState("");
|
const [expiresInDays, setExpiresInDays] = useState("");
|
||||||
const [allowedIps, setAllowedIps] = 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 [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const { data: keys, isLoading, isError } = useQuery({
|
const { data: keys, isLoading, isError } = useQuery({
|
||||||
@@ -49,7 +75,11 @@ export function ApiKeysTab() {
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: createApiKey,
|
mutationFn: createApiKey,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setCreated({ name: result.apiKey.name, token: result.token });
|
setCreated({
|
||||||
|
name: result.apiKey.name,
|
||||||
|
token: result.token,
|
||||||
|
scopes: result.apiKey.scopes,
|
||||||
|
});
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
|
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
|
||||||
},
|
},
|
||||||
@@ -75,12 +105,18 @@ export function ApiKeysTab() {
|
|||||||
const days = expiresInDays ? Number(expiresInDays) : undefined;
|
const days = expiresInDays ? Number(expiresInDays) : undefined;
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
scopes: DEFAULT_SCOPES,
|
scopes,
|
||||||
expiresInDays: days,
|
expiresInDays: days,
|
||||||
allowedIps: parseAllowlist(allowedIps),
|
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() {
|
async function copyToken() {
|
||||||
if (!created) return;
|
if (!created) return;
|
||||||
try {
|
try {
|
||||||
@@ -94,9 +130,10 @@ export function ApiKeysTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<p className="max-w-prose text-sm text-muted-foreground">
|
<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,
|
Ein API-Schlüssel erlaubt einem anderen System genau das, was angekreuzt ist – etwa dem
|
||||||
die freigegebenen Shop-Daten zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und
|
Wabenhain-Shop, die freigegebenen Shop-Daten zu lesen, oder dem Buchhaltungssystem, den
|
||||||
lässt sich jederzeit widerrufen.
|
Inventar-Spiegel zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und lässt sich
|
||||||
|
jederzeit widerrufen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{created ? (
|
{created ? (
|
||||||
@@ -109,8 +146,7 @@ export function ApiKeysTab() {
|
|||||||
Schlüssel „{created.name}“ angelegt
|
Schlüssel „{created.name}“ angelegt
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Jetzt kopieren und in Wabenhain als <code className="font-mono">APICORE_API_KEY</code>{" "}
|
{pasteHint(created.scopes)} Er wird <strong>nur dieses eine Mal</strong> angezeigt.
|
||||||
eintragen. Er wird <strong>nur dieses eine Mal</strong> angezeigt.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
<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"
|
aria-describedby="api-key-ips-hint"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 />
|
<KeyRound aria-hidden />
|
||||||
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
|
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
|
<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
|
Je angebundenem System ein eigener Schlüssel mit nur den nötigen Rechten. IP-Adressen mit
|
||||||
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
|
Komma trennen; leer = von überall. Unbekannt? Leer lassen, einmal abrufen lassen, dann hier
|
||||||
die zuletzt benutzte Adresse lässt sich übernehmen.
|
„Adressen ändern“ – die zuletzt benutzte Adresse lässt sich übernehmen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{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>
|
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</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">
|
<span className="text-xs text-muted-foreground">
|
||||||
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
|
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
|
||||||
{formatDate(apiKey.lastUsedAt)}
|
{formatDate(apiKey.lastUsedAt)}
|
||||||
|
|||||||
@@ -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> = {
|
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||||
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
|
"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 {
|
export interface ApiKey {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in new issue
Block a user