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:
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">
|
||||
|
||||
Reference in new issue
Block a user