fix(ui): API-Schlüssel nur mit Rechten anbieten, die der Nutzer selbst hat

Der Server lehnt Rechte ab, die der Anlegende nicht selbst hat. Das UI
bietet jetzt auch nur diese an (z. B. kein shop-sync:write ohne eigenes
Recht) und erklärt, wenn gar keines übrig bleibt.

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 12:00:03 +02:00
1 parent fe7b61c3c1
commit 6505411761
2 files changed
+36 -2

No files matched your search

@@ -7,6 +7,12 @@ import { ApiKeysTab } from "./api-keys-tab";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import type { ApiKey } 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("@/lib/api-keys/api", () => ({
createApiKey: vi.fn(),
fetchApiKeys: vi.fn(),
@@ -38,6 +44,7 @@ function key(overrides: Partial<ApiKey>): ApiKey {
describe("ApiKeysTab", () => {
beforeEach(() => {
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();
@@ -94,4 +101,20 @@ describe("ApiKeysTab", () => {
expect(await screen.findByText("ERPNext")).toBeInTheDocument();
expect(screen.getByText("Darf: Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
});
it("bietet nur Rechte an, die der angemeldete Nutzer selbst hat", () => {
ownPermissions = ["api-keys:manage", "shop-sync:read", "erp-inventory:read"];
renderWithClient(<ApiKeysTab />);
expect(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ })).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /Verkäufe und Rückbuchungen/ })).toBeNull();
});
it("wählt nichts vor, was der Nutzer nicht vergeben darf, und erklärt es", () => {
ownPermissions = ["api-keys:manage"];
renderWithClient(<ApiKeysTab />);
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
expect(screen.getByText(/keine Rechte, die ein API-Schlüssel tragen kann/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
});
});
@@ -18,6 +18,7 @@ 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 { useAuth } from "@/lib/auth/auth-context";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
// Vorschlag wie bisher: der Wabenhain-Shop liest. Alles andere wird bewusst angekreuzt.
@@ -56,10 +57,15 @@ function keyState(apiKey: ApiKey): { label: string; active: boolean } {
*/
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 [scopes, setScopes] = useState<ApiKeyScope[]>([...DEFAULT_SCOPES]);
const [chosenScopes, setScopes] = useState<ApiKeyScope[]>([...DEFAULT_SCOPES]);
const scopes = chosenScopes.filter((scope) => grantable.includes(scope));
const [created, setCreated] = useState<{
name: string;
token: string;
@@ -202,8 +208,13 @@ export function ApiKeysTab() {
</div>
<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>
{grantable.length === 0 ? (
<p className="text-sm text-muted-foreground">
Sie haben selbst keine Rechte, die ein API-Schlüssel tragen kann.
</p>
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{API_KEY_SCOPES.map((scope) => (
{grantable.map((scope) => (
<ToggleChip
key={scope}
label={API_KEY_SCOPE_LABELS[scope]}