546 lines
21 KiB
TypeScript
546 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
KeyRound,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
ShieldOff,
|
|
Trash2
|
|
} from "lucide-react";
|
|
import {
|
|
AdminIconButton,
|
|
AdminPageLayout,
|
|
AdminSelectionList,
|
|
Button,
|
|
ConfirmDialog,
|
|
DataGrid,
|
|
DateTimeField,
|
|
Dialog,
|
|
DocumentationHelpLink,
|
|
FormField,
|
|
MetricCard,
|
|
StatusBadge,
|
|
TableActionGroup,
|
|
ToggleSwitch,
|
|
adminErrorMessage,
|
|
formatAdminDateTime as formatDateTime,
|
|
hasScope,
|
|
scopeGrants,
|
|
type ApiSettings,
|
|
type AuthInfo,
|
|
type DataGridColumn,
|
|
type PermissionItem
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
createServiceAccount,
|
|
createServiceAccountCredential,
|
|
fetchPermissionCatalog,
|
|
fetchServiceAccountCredentials,
|
|
fetchServiceAccounts,
|
|
retireServiceAccount,
|
|
revokeServiceAccountCredential,
|
|
rotateServiceAccountCredential,
|
|
updateServiceAccount,
|
|
type ServiceAccountCredentialItem,
|
|
type ServiceAccountItem
|
|
} from "../../api/admin";
|
|
import {
|
|
ACCESS_INTERFACE_I18N,
|
|
ACCESS_REFERENCE_DOCUMENTATION
|
|
} from "./interfacePatterns";
|
|
|
|
type AccountDraft = {
|
|
name: string;
|
|
description: string;
|
|
scopes: string[];
|
|
};
|
|
|
|
type CredentialDraft = {
|
|
name: string;
|
|
scopes: string[];
|
|
expiresAt: string;
|
|
};
|
|
|
|
type CredentialEditor = {
|
|
mode: "create" | "rotate";
|
|
credential?: ServiceAccountCredentialItem;
|
|
};
|
|
|
|
export default function ServiceAccountsPanel({
|
|
settings,
|
|
auth,
|
|
canWrite
|
|
}: {
|
|
settings: ApiSettings;
|
|
auth: AuthInfo;
|
|
canWrite: boolean;
|
|
}) {
|
|
const [accounts, setAccounts] = useState<ServiceAccountItem[]>([]);
|
|
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
|
const [managing, setManaging] = useState<ServiceAccountItem | null>(null);
|
|
const [credentials, setCredentials] = useState<ServiceAccountCredentialItem[]>([]);
|
|
const [showRevoked, setShowRevoked] = useState(true);
|
|
const [accountEditor, setAccountEditor] = useState<"create" | "edit" | null>(null);
|
|
const [accountDraft, setAccountDraft] = useState<AccountDraft>(emptyAccountDraft());
|
|
const [credentialEditor, setCredentialEditor] = useState<CredentialEditor | null>(null);
|
|
const [credentialDraft, setCredentialDraft] = useState<CredentialDraft>(emptyCredentialDraft());
|
|
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
|
|
const [revoking, setRevoking] = useState<ServiceAccountCredentialItem | null>(null);
|
|
const [retiring, setRetiring] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState("");
|
|
|
|
const grantablePermissions = useMemo(
|
|
() => permissions.filter((permission) => permission.level === "tenant" && hasScope(auth, permission.scope)),
|
|
[auth, permissions]
|
|
);
|
|
const credentialPermissions = useMemo(
|
|
() => grantablePermissions.filter((permission) => managing?.scope_ceiling.some((scope) => scopeGrants(scope, permission.scope))),
|
|
[grantablePermissions, managing]
|
|
);
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [nextAccounts, nextPermissions] = await Promise.all([
|
|
fetchServiceAccounts(settings),
|
|
fetchPermissionCatalog(settings)
|
|
]);
|
|
setAccounts(nextAccounts);
|
|
setPermissions(nextPermissions);
|
|
if (managing) {
|
|
const refreshed = nextAccounts.find((item) => item.id === managing.id) ?? null;
|
|
setManaging(refreshed);
|
|
}
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function openManager(account: ServiceAccountItem) {
|
|
setManaging(account);
|
|
setCredentials([]);
|
|
setError("");
|
|
try {
|
|
const response = await fetchServiceAccountCredentials(settings, account.id, true);
|
|
setCredentials(response.items);
|
|
setManaging({ ...account, revision: response.service_account_revision });
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
}
|
|
}
|
|
|
|
async function refreshManaged(serviceAccountId: string) {
|
|
const [nextAccounts, response] = await Promise.all([
|
|
fetchServiceAccounts(settings),
|
|
fetchServiceAccountCredentials(settings, serviceAccountId, true)
|
|
]);
|
|
const selected = nextAccounts.find((item) => item.id === serviceAccountId) ?? null;
|
|
setAccounts(nextAccounts);
|
|
setCredentials(response.items);
|
|
setManaging(selected ? { ...selected, revision: response.service_account_revision } : null);
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
|
|
|
const accountColumns = useMemo<DataGridColumn<ServiceAccountItem>[]>(() => [
|
|
{
|
|
id: "name",
|
|
header: "Name",
|
|
width: "minmax(220px, 1fr)",
|
|
minWidth: 190,
|
|
resizable: true,
|
|
fill: true,
|
|
sticky: "start",
|
|
sortable: true,
|
|
filterable: true,
|
|
value: (row) => row.name,
|
|
render: (row) => <div><strong>{row.name}</strong>{row.description && <div className="muted small-note">{row.description}</div>}</div>
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
width: 120,
|
|
resizable: false,
|
|
sortable: true,
|
|
filterable: true,
|
|
value: (row) => row.is_active ? "active" : "inactive",
|
|
render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} />
|
|
},
|
|
{
|
|
id: "scope_ceiling",
|
|
header: "Scope ceiling",
|
|
width: 140,
|
|
resizable: false,
|
|
sortable: true,
|
|
filterable: true,
|
|
filterType: "integer",
|
|
value: (row) => row.scope_ceiling.length,
|
|
render: (row) => String(row.scope_ceiling.length)
|
|
},
|
|
{
|
|
id: "credentials",
|
|
header: "Credentials",
|
|
width: 150,
|
|
resizable: false,
|
|
sortable: true,
|
|
value: (row) => row.active_credential_count,
|
|
render: (row) => `${row.active_credential_count} active / ${row.credential_count}`
|
|
},
|
|
{
|
|
id: "last_used",
|
|
header: "Last used",
|
|
width: 180,
|
|
minWidth: 150,
|
|
resizable: true,
|
|
sortable: true,
|
|
value: (row) => row.last_credential_used_at || "",
|
|
render: (row) => formatDateTime(row.last_credential_used_at)
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
width: 108,
|
|
sticky: "end",
|
|
resizable: false,
|
|
align: "right",
|
|
render: (row) => <TableActionGroup actions={[
|
|
{ id: "manage", label: `Manage ${row.name}`, icon: <Search />, onClick: () => void openManager(row) }
|
|
]} />
|
|
}
|
|
], []);
|
|
|
|
const credentialColumns = useMemo<DataGridColumn<ServiceAccountCredentialItem>[]>(() => [
|
|
{
|
|
id: "name",
|
|
header: "Name",
|
|
width: "minmax(190px, 1fr)",
|
|
minWidth: 170,
|
|
fill: true,
|
|
resizable: true,
|
|
value: (row) => row.name,
|
|
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}...</div></div>
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
width: 120,
|
|
resizable: false,
|
|
value: credentialStatus,
|
|
render: (row) => <StatusBadge status={credentialStatus(row)} />
|
|
},
|
|
{
|
|
id: "scopes",
|
|
header: "Scopes",
|
|
width: 100,
|
|
resizable: false,
|
|
value: (row) => row.scopes.length,
|
|
render: (row) => String(row.scopes.length)
|
|
},
|
|
{
|
|
id: "last_used",
|
|
header: "Last used",
|
|
width: 170,
|
|
resizable: true,
|
|
value: (row) => row.last_used_at || "",
|
|
render: (row) => formatDateTime(row.last_used_at)
|
|
},
|
|
{
|
|
id: "expires",
|
|
header: "Expires",
|
|
width: 170,
|
|
resizable: true,
|
|
value: (row) => row.expires_at || "",
|
|
render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry"
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
width: 108,
|
|
sticky: "end",
|
|
resizable: false,
|
|
align: "right",
|
|
render: (row) => <TableActionGroup actions={[
|
|
{
|
|
id: "rotate",
|
|
label: `Rotate ${row.name}`,
|
|
icon: <RefreshCw />,
|
|
applicable: !row.revoked_at,
|
|
disabled: !canWrite || !managing?.is_active,
|
|
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined,
|
|
onClick: () => openCredentialEditor("rotate", row)
|
|
},
|
|
{
|
|
id: "revoke",
|
|
label: `Revoke ${row.name}`,
|
|
icon: <Trash2 />,
|
|
variant: "danger",
|
|
applicable: !row.revoked_at,
|
|
disabled: !canWrite,
|
|
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined,
|
|
onClick: () => setRevoking(row)
|
|
}
|
|
]} />
|
|
}
|
|
], [canWrite, managing]);
|
|
|
|
function openCreateAccount() {
|
|
setAccountDraft(emptyAccountDraft());
|
|
setAccountEditor("create");
|
|
}
|
|
|
|
function openEditAccount() {
|
|
if (!managing) return;
|
|
setAccountDraft({
|
|
name: managing.name,
|
|
description: managing.description ?? "",
|
|
scopes: [...managing.scope_ceiling]
|
|
});
|
|
setAccountEditor("edit");
|
|
}
|
|
|
|
async function saveAccount() {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
if (accountEditor === "create") {
|
|
const created = await createServiceAccount(settings, {
|
|
name: accountDraft.name,
|
|
description: accountDraft.description || null,
|
|
scope_ceiling: accountDraft.scopes
|
|
});
|
|
setSuccess(`Service account ${created.name} created.`);
|
|
} else if (managing) {
|
|
await updateServiceAccount(settings, managing.id, {
|
|
expected_revision: managing.revision,
|
|
name: accountDraft.name,
|
|
description: accountDraft.description || null,
|
|
scope_ceiling: accountDraft.scopes
|
|
});
|
|
setSuccess(`Service account ${accountDraft.name} updated.`);
|
|
await refreshManaged(managing.id);
|
|
}
|
|
setAccountEditor(null);
|
|
if (accountEditor === "create") await load();
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
if (managing) await refreshAfterConflict(managing.id);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function setActive(active: boolean) {
|
|
if (!managing) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await updateServiceAccount(settings, managing.id, {
|
|
expected_revision: managing.revision,
|
|
is_active: active
|
|
});
|
|
setSuccess(`${managing.name} ${active ? "activated" : "deactivated"}.`);
|
|
await refreshManaged(managing.id);
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
await refreshAfterConflict(managing.id);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function retire() {
|
|
if (!managing) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await retireServiceAccount(settings, managing.id, managing.revision);
|
|
setSuccess(`${managing.name} retired and its credentials revoked.`);
|
|
setRetiring(false);
|
|
await refreshManaged(managing.id);
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
await refreshAfterConflict(managing.id);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openCredentialEditor(mode: "create" | "rotate", credential?: ServiceAccountCredentialItem) {
|
|
setCredentialDraft(credential ? {
|
|
name: credential.name,
|
|
scopes: [...credential.scopes],
|
|
expiresAt: ""
|
|
} : emptyCredentialDraft());
|
|
setCredentialEditor({ mode, credential });
|
|
}
|
|
|
|
async function saveCredential() {
|
|
if (!managing || !credentialEditor) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const payload = {
|
|
expected_revision: managing.revision,
|
|
name: credentialDraft.name,
|
|
scopes: credentialDraft.scopes,
|
|
expires_at: credentialDraft.expiresAt ? new Date(credentialDraft.expiresAt).toISOString() : null
|
|
};
|
|
const response = credentialEditor.mode === "create"
|
|
? await createServiceAccountCredential(settings, managing.id, payload)
|
|
: await rotateServiceAccountCredential(settings, managing.id, credentialEditor.credential!.id, payload);
|
|
setSecret({ name: response.credential.name, value: response.secret });
|
|
setSuccess(credentialEditor.mode === "create" ? "Credential created." : "Credential rotated; the previous credential is revoked.");
|
|
setCredentialEditor(null);
|
|
await refreshManaged(managing.id);
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
await refreshAfterConflict(managing.id);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function revokeCredential() {
|
|
if (!managing || !revoking) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await revokeServiceAccountCredential(settings, managing.id, revoking.id, managing.revision);
|
|
setSuccess(`Credential ${revoking.name} revoked.`);
|
|
setRevoking(null);
|
|
await refreshManaged(managing.id);
|
|
} catch (err) {
|
|
setError(adminErrorMessage(err));
|
|
await refreshAfterConflict(managing.id);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function refreshAfterConflict(serviceAccountId: string) {
|
|
try {
|
|
await refreshManaged(serviceAccountId);
|
|
} catch {
|
|
await load();
|
|
}
|
|
}
|
|
|
|
const visibleCredentials = showRevoked ? credentials : credentials.filter((item) => !item.revoked_at);
|
|
|
|
return (
|
|
<>
|
|
<AdminPageLayout
|
|
title="Service accounts"
|
|
description="Manage non-login automation principals and their independently rotatable, scope-bounded credentials."
|
|
loading={loading}
|
|
error={error}
|
|
success={success}
|
|
actions={<>
|
|
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
|
|
<Button onClick={() => void load()} disabled={loading}>Reload</Button>
|
|
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
|
|
</>}
|
|
>
|
|
<div className="admin-table-surface">
|
|
<DataGrid id="admin-service-accounts-v1" rows={accounts} columns={accountColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No service accounts found." />
|
|
</div>
|
|
</AdminPageLayout>
|
|
|
|
<Dialog
|
|
open={Boolean(accountEditor)}
|
|
title={accountEditor === "create" ? "Create service account" : "Edit service account"}
|
|
onClose={() => !busy && setAccountEditor(null)}
|
|
className="admin-dialog admin-dialog-wide"
|
|
footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
|
|
>
|
|
<div className="admin-form-grid two-columns">
|
|
<FormField label="Name"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField>
|
|
<FormField label="Description"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
|
|
</div>
|
|
<div className="form-field">
|
|
<span className="form-label">Scope ceiling</span>
|
|
<AdminSelectionList options={grantablePermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={accountDraft.scopes} onChange={(scopes) => setAccountDraft({ ...accountDraft, scopes })} emptyText="No tenant scopes can be delegated by your current account." />
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(managing)}
|
|
title={managing?.name ?? "Service account"}
|
|
onClose={() => !busy && setManaging(null)}
|
|
className="admin-dialog admin-dialog-wide"
|
|
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
|
|
>
|
|
{managing && <>
|
|
<div className="metric-grid compact">
|
|
<MetricCard label="Status" value={managing.is_active ? "Active" : "Inactive"} tone={managing.is_active ? "good" : "warning"} />
|
|
<MetricCard label="Active credentials" value={managing.active_credential_count} />
|
|
<MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} />
|
|
<MetricCard label="Revision" value={managing.revision} />
|
|
</div>
|
|
<div className="admin-toolbar-row">
|
|
<Button onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button>
|
|
<Button onClick={() => void setActive(!managing.is_active)} disabled={!canWrite || busy}>{managing.is_active ? <ShieldOff aria-hidden="true" /> : <RefreshCw aria-hidden="true" />} {managing.is_active ? "Deactivate" : "Activate"}</Button>
|
|
<Button variant="danger" onClick={() => setRetiring(true)} disabled={!canWrite || busy || !managing.is_active}><Trash2 aria-hidden="true" /> Retire</Button>
|
|
<AdminIconButton label="Create credential" icon={<KeyRound />} variant="primary" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} />
|
|
</div>
|
|
<div className="admin-toolbar-row">
|
|
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} onChange={setShowRevoked} />
|
|
</div>
|
|
<div className="admin-table-surface">
|
|
<DataGrid id="admin-service-account-credentials-v1" rows={visibleCredentials} columns={credentialColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No credentials found." />
|
|
</div>
|
|
<p className="muted small-note">Secrets are shown once. Authentication always intersects a credential grant with this account's current scope ceiling, so reducing the ceiling takes effect immediately.</p>
|
|
</>}
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(credentialEditor)}
|
|
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
|
|
onClose={() => !busy && setCredentialEditor(null)}
|
|
className="admin-dialog admin-dialog-wide"
|
|
footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveCredential()} disabled={!canWrite || busy || !credentialDraft.name.trim() || credentialDraft.scopes.length === 0}>{busy ? "Saving..." : credentialEditor?.mode === "rotate" ? "Rotate" : "Create"}</Button></>}
|
|
>
|
|
{credentialEditor?.mode === "rotate" && <p className="muted small-note">Rotation creates a new secret and revokes the previous credential in the same transaction.</p>}
|
|
<div className="admin-form-grid two-columns">
|
|
<FormField label="Name"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField>
|
|
<FormField label="Expiry"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
|
|
</div>
|
|
<div className="form-field">
|
|
<span className="form-label">Credential scopes</span>
|
|
<AdminSelectionList options={credentialPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={credentialDraft.scopes} onChange={(scopes) => setCredentialDraft({ ...credentialDraft, scopes })} emptyText="The service account has no credential scopes available." />
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog open={Boolean(secret)} title="Service-account 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. GovOPlaN retains only a one-way hash and the visible prefix.</p></>}
|
|
</Dialog>
|
|
|
|
<ConfirmDialog open={Boolean(revoking)} title="Revoke credential" message={`Revoke ${revoking?.name ?? "this credential"}? Existing clients using it will immediately lose access.`} confirmLabel="Revoke credential" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revokeCredential()} />
|
|
<ConfirmDialog open={retiring} title="Retire service account" message={`Retire ${managing?.name ?? "this service account"} and revoke all ${managing?.active_credential_count ?? 0} active credentials?`} confirmLabel="Retire and revoke" tone="danger" busy={busy} onCancel={() => setRetiring(false)} onConfirm={() => void retire()} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function emptyAccountDraft(): AccountDraft {
|
|
return { name: "", description: "", scopes: [] };
|
|
}
|
|
|
|
function emptyCredentialDraft(): CredentialDraft {
|
|
return { name: "", scopes: [], expiresAt: "" };
|
|
}
|
|
|
|
function credentialStatus(item: ServiceAccountCredentialItem): string {
|
|
if (item.revoked_at) return "revoked";
|
|
if (item.expires_at && new Date(item.expires_at).getTime() <= Date.now()) return "expired";
|
|
return "active";
|
|
}
|