Files
govoplan-access/webui/src/features/admin/ApiKeysPanel.tsx

196 lines
14 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { DateTimeField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
function defaultDraft(userId: string) {
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
}
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canRevoke: boolean;}) {
const [keys, setKeys] = useState<ApiKeyAdminItem[]>([]);
const [users, setUsers] = useState<UserAdminItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const keysRef = useRef<ApiKeyAdminItem[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [showRevoked, setShowRevoked] = useState(false);
const [creating, setCreating] = useState(false);
const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null);
const [draft, setDraft] = useState(() => defaultDraft(auth.user.id));
const [savedDraftKey, setSavedDraftKey] = useState(() => draftKey(defaultDraft(auth.user.id)));
const [secret, setSecret] = useState<{name: string;value: string;} | null>(null);
const [revoking, setRevoking] = useState<ApiKeyAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = creating && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeCreate
});
async function load() {
setLoading(true);
setError("");
try {
const keyScope = showRevoked ? "all" : "active";
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([
loadDeltaRows(
keysRef.current,
`access:api-keys:${keyScope}`,
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchApiKeysDelta(settings, showRevoked, { since }),
(response) => response.api_keys,
(key) => key.id,
"access_api_key",
sortApiKeys
),
loadDeltaRows(
usersRef.current,
"access:api-keys:users",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchUsersDelta(settings, { since }),
(response) => response.users,
(user) => user.id,
"access_user",
sortUsers
),
fetchPermissionCatalog(settings)
]);
keysRef.current = nextKeys;
usersRef.current = nextUsers;
setKeys(nextKeys);
setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active));
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => {
keysRef.current = [];
usersRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked, resetDeltaWatermark]);
const selectedUser = users.find((user) => user.id === draft.userId);
const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope)));
const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [
{ id: "name", header: "i18n:govoplan-access.name.709a2322", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}</div></div> },
{ id: "owner", header: "i18n:govoplan-access.owner.89ff3122", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
{ id: "scopes", header: "i18n:govoplan-access.scopes.c23540e5", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} disabled />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
</div> }],
[canRevoke]);
function openCreate() {
const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0];
const nextDraft = defaultDraft(defaultUser?.id || "");
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setCreating(true);
setError("");
}
function closeCreate() {
setCreating(false);
const nextDraft = defaultDraft(auth.user.id);
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null });
setSecret({ name: created.name, value: created.secret });
setCreating(false);
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name }));
await load();
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function revoke() {
if (!revoking) return;
setBusy(true);
setError("");
try {
await revokeApiKey(settings, revoking.id);
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_revoked.b4431f0e", { value0: revoking.name }));
setRevoking(null);
await load();
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
return (
<>
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> i18n:govoplan-access.show_revoked.b4265807</label><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
</AdminPageLayout>
<Dialog open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.owner.89ff3122"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} {user.email}</option>)}</select></FormField>
<FormField label="i18n:govoplan-access.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
</div>
<div className="form-field"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
</Dialog>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>i18n:govoplan-access.name.709a2322</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.prefix.90eceb01</dt><dd>{viewing.prefix}</dd></div>
<div><dt>i18n:govoplan-access.owner.89ff3122</dt><dd>{viewing.user_email}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</dd></div>
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.last_used.f1109d3d</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
<div><dt>i18n:govoplan-access.expires.a99be3da</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</dd></div><div><dt>i18n:govoplan-access.revoked.85f17ac0</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
</dl><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog>
<Dialog open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
</Dialog>
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
</>);
}
function draftKey(draft: ReturnType<typeof defaultDraft>): string {
return JSON.stringify(draft);
}
function sortApiKeys(left: ApiKeyAdminItem, right: ApiKeyAdminItem): number {
return left.name.localeCompare(right.name) || left.prefix.localeCompare(right.prefix);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}