feat: add access module boundary migrations
This commit is contained in:
@@ -1,80 +1,139 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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, fetchApiKeys, fetchPermissionCatalog, fetchUsers, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||
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 } from "@govoplan/core-webui";
|
||||
import { scopeGrants } 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";
|
||||
|
||||
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { settings: ApiSettings; auth: AuthInfo; canCreate: boolean; canRevoke: boolean }) {
|
||||
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({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" });
|
||||
const [secret, setSecret] = useState<{ name: string; value: string } | 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 [nextKeys, nextUsers, nextPermissions] = await Promise.all([fetchApiKeys(settings, showRevoked), fetchUsers(settings), fetchPermissionCatalog(settings)]);
|
||||
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); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked]);
|
||||
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: "Name", 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: "Owner", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
|
||||
{ id: "scopes", header: "Scopes", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
|
||||
{ id: "status", header: "Status", 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: "Last used", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||
{ id: "expires", header: "Expires", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} disabled />
|
||||
<AdminIconButton label={`Revoke ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
|
||||
</div> }
|
||||
], [canRevoke]);
|
||||
{ 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];
|
||||
setDraft({ name: "", userId: defaultUser?.id || "", scopes: ["campaign:read"], expiresAt: "" });
|
||||
const nextDraft = defaultDraft(defaultUser?.id || "");
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setCreating(true);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
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(`API key ${created.name} created.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name }));
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
@@ -83,42 +142,54 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
||||
setError("");
|
||||
try {
|
||||
await revokeApiKey(settings, revoking.id);
|
||||
setSuccess(`API key ${revoking.name} revoked.`);
|
||||
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); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant API keys" description="Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited." loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> Show revoked</label><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add API key" 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="No API keys found." /></div>
|
||||
<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="Create API key" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "Creating…" : "Create key"}</Button></>}>
|
||||
<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="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Owner"><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="">Select user</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||
<FormField label="Expiry"><input type="datetime-local" value={draft.expiresAt} onChange={(event) => setDraft({ ...draft, expiresAt: event.target.value })} /></FormField>
|
||||
<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">Allowed scopes</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} — ${permission.description}` }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="The selected user has no tenant permissions available to an API key." /></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="API key details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
<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>Name</dt><dd>{viewing.name}</dd></div><div><dt>Prefix</dt><dd>{viewing.prefix}…</dd></div>
|
||||
<div><dt>Owner</dt><dd>{viewing.user_email}</dd></div><div><dt>Status</dt><dd>{viewing.revoked_at ? "Revoked" : "Active"}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Last used</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
|
||||
<div><dt>Expires</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}</dd></div><div><dt>Revoked</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
|
||||
</dl><h3>Scopes</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
<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="API key secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}>
|
||||
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.</p></>}
|
||||
<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="Revoke API key" message={`Revoke ${revoking?.name}? Existing clients will immediately lose access.`} confirmLabel="Revoke key" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
||||
</>
|
||||
);
|
||||
<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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user