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 { 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)}
|
||||
|
||||
Reference in new issue
Block a user