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([]); const [permissions, setPermissions] = useState([]); const [managing, setManaging] = useState(null); const [credentials, setCredentials] = useState([]); const [showRevoked, setShowRevoked] = useState(true); const [accountEditor, setAccountEditor] = useState<"create" | "edit" | null>(null); const [accountDraft, setAccountDraft] = useState(emptyAccountDraft()); const [credentialEditor, setCredentialEditor] = useState(null); const [credentialDraft, setCredentialDraft] = useState(emptyCredentialDraft()); const [secret, setSecret] = useState<{ name: string; value: string } | null>(null); const [revoking, setRevoking] = useState(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[]>(() => [ { 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) =>
{row.name}{row.description &&
{row.description}
}
}, { id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => }, { 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) => , onClick: () => void openManager(row) } ]} /> } ], []); const credentialColumns = useMemo[]>(() => [ { id: "name", header: "Name", width: "minmax(190px, 1fr)", minWidth: 170, fill: true, resizable: true, value: (row) => row.name, render: (row) =>
{row.name}
{row.prefix}...
}, { id: "status", header: "Status", width: 120, resizable: false, value: credentialStatus, render: (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) => , 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: , 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 ( <> } variant="primary" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} /> } >
row.id} emptyText="No service accounts found." />
!busy && setAccountEditor(null)} className="admin-dialog admin-dialog-wide" footer={<>} >
setAccountDraft({ ...accountDraft, name: event.target.value })} /> setAccountDraft({ ...accountDraft, description: event.target.value })} />
Scope ceiling ({ 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." />
!busy && setManaging(null)} className="admin-dialog admin-dialog-wide" footer={} > {managing && <>
} 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} />
row.id} emptyText="No credentials found." />

Secrets are shown once. Authentication always intersects a credential grant with this account's current scope ceiling, so reducing the ceiling takes effect immediately.

}
!busy && setCredentialEditor(null)} className="admin-dialog admin-dialog-wide" footer={<>} > {credentialEditor?.mode === "rotate" &&

Rotation creates a new secret and revokes the previous credential in the same transaction.

}
setCredentialDraft({ ...credentialDraft, name: event.target.value })} /> setCredentialDraft({ ...credentialDraft, expiresAt: value })} />
Credential scopes ({ 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." />
setSecret(null)} className="admin-dialog" footer={}> {secret && <>

The secret for {secret.name} is shown once.

{secret.value}

Store it in a secret manager. GovOPlaN retains only a one-way hash and the visible prefix.

}
setRevoking(null)} onConfirm={() => void revokeCredential()} /> 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"; }