docs(access): add credential lifecycle contextual help

This commit is contained in:
2026-08-21 21:14:31 +02:00
parent fa0c85e03a
commit 07232d3906
11 changed files with 255 additions and 40 deletions
+6 -1
View File
@@ -52,7 +52,12 @@ through declared capabilities or metadata.
- `access.workflow.grant-user-access` covers the user, group, and role path. - `access.workflow.grant-user-access` covers the user, group, and role path.
- `access.reference.admin-access-fields` covers accounts, roles, API keys, and - `access.reference.admin-access-fields` covers accounts, roles, API keys, and
reusable credentials. their backing administration fields.
- `access.workflow.manage-api-keys` owns exact help for accountable ownership,
bounded scopes and expiry, one-time secret custody, and immediate revocation.
- `access.workflow.manage-service-account-credentials` owns exact help for the
account ceiling, activation state, credential rotation/revocation, one-time
secret custody, concurrency, and retirement consequences.
- `access.reference.external-function-role-mappings` explains the - `access.reference.external-function-role-mappings` explains the
Organizations, IDM, and Access responsibility split. Organizations, IDM, and Access responsibility split.
- Files and Mail blockers link to documentation supplied by the owning module. - Files and Mail blockers link to documentation supplied by the owning module.
+11
View File
@@ -39,3 +39,14 @@ deactivates the principal and revokes all active credentials.
Credential list responses never contain a secret. Create and rotate responses Credential list responses never contain a secret. Create and rotate responses
contain it once. Audit records include identifiers, prefixes, scopes, and the contain it once. Audit records include identifiers, prefixes, scopes, and the
new service-account revision, but never the secret or its hash. new service-account revision, but never the secret or its hash.
## Contextual help
F1 on the service-account page, its editors, scope controls, one-time secret,
rotation and revocation actions, activation state, or retirement confirmation
resolves to the Access-owned `access.workflow.manage-service-account-credentials`
topic. The German reference content distinguishes reversible deactivation from
retirement, explains immediate client impact, and states that secrets cannot be
recovered. Tenant API-key controls resolve separately to
`access.workflow.manage-api-keys`, because their effective authorization also
depends on the accountable human owner's current permissions.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.18", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.18" version = "0.1.19"
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives." description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN access platform module.""" """GovOPlaN access platform module."""
__version__ = "0.1.18" __version__ = "0.1.19"
+124 -4
View File
@@ -556,8 +556,6 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"access.admin.tenant-users", "access.admin.tenant-users",
"access.admin.tenant-groups", "access.admin.tenant-groups",
"access.admin.tenant-roles", "access.admin.tenant-roles",
"access.admin.api-keys",
"access.admin.service-accounts",
], ],
"route": "/admin", "route": "/admin",
"screen": "Admin", "screen": "Admin",
@@ -625,6 +623,85 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
], ],
}, },
), ),
DocumentationTopic(
id="access.workflow.manage-api-keys",
title="Create and revoke tenant API keys",
summary="Issue a one-time automation secret for an accountable tenant user, constrain its scope and lifetime, and revoke it when access must stop.",
body=(
"Tenant API keys are non-interactive automation credentials owned by an existing tenant user. Select an owner whose current effective permissions contain every requested scope; authorization continues to intersect the stored key scopes with that owner's current permissions, so removing the owner's access also narrows the key. Set the shortest practical expiry and grant only the scopes the client needs. "
"The secret is displayed once after creation. GovOPlaN then retains only its one-way hash and visible prefix, so administrators cannot display or recover it later. Record the value directly in an approved external secret manager and close the one-time dialog only after custody is confirmed. "
"Revocation is immediate and irreversible for that key: existing clients lose access and must be configured with a newly issued credential. Inspect and audit views expose metadata, scopes, timestamps, and the non-authenticating prefix but never secret material."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
order=32,
conditions=(
DocumentationCondition(
required_modules=("access",),
any_scopes=(
"access:api_key:read",
"access:api_key:create",
"access:api_key:revoke",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
),
),
),
links=(
DocumentationLink(label="Tenant API keys", href="/admin?section=tenant-api-keys", kind="runtime"),
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
),
translations={
"de": {
"title": "Mandanten-API-Schlüssel erstellen und widerrufen",
"summary": "Geben Sie ein einmal sichtbares Automatisierungsgeheimnis für eine verantwortliche Person aus, begrenzen Sie Umfang und Laufzeit und widerrufen Sie den Schlüssel, sobald der Zugriff enden muss.",
"body": "Mandanten-API-Schlüssel sind nicht interaktive Automatisierungszugänge einer vorhandenen Person im Mandanten. Wählen Sie eine verantwortliche Person, deren aktuelle wirksame Berechtigungen alle gewünschten Scopes enthalten. Bei jeder Nutzung werden die gespeicherten Schlüssel-Scopes weiterhin mit den aktuellen Berechtigungen dieser Person geschnitten; ein Entzug ihrer Berechtigungen schränkt daher auch den Schlüssel ein. Legen Sie die kürzeste praktikable Laufzeit fest und vergeben Sie nur die Scopes, die der Client tatsächlich benötigt. Das Geheimnis wird nach der Erstellung genau einmal angezeigt. Danach speichert GovOPlaN nur einen Einweg-Hash und das sichtbare, nicht zur Anmeldung geeignete Präfix; eine spätere Anzeige oder Wiederherstellung ist nicht möglich. Übertragen Sie den Wert unmittelbar in einen freigegebenen externen Geheimnismanager und schließen Sie den Einmal-Dialog erst nach bestätigter Verwahrung. Ein Widerruf wirkt sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden: Bestehende Clients verlieren den Zugriff und benötigen einen neu ausgegebenen Zugang. Detail- und Auditansichten zeigen Metadaten, Scopes, Zeitpunkte und das Präfix, aber niemals das Geheimnis.",
}
},
metadata={
"kind": "workflow",
"route": "/admin",
"screen": "Tenant API keys",
"help_contexts": [
"access.admin.api-keys",
"access.api-keys.action.reload",
"access.api-keys.action.create",
"access.api-keys.action.inspect",
"access.api-keys.action.revoke",
"access.api-keys.field.show-revoked",
"access.api-keys.field.name",
"access.api-keys.field.owner",
"access.api-keys.field.expiry",
"access.api-keys.field.scopes",
"access.api-keys.secret",
"access.api-keys.confirm-revoke",
],
"prerequisites": [
"The tenant permits API credentials.",
"The actor may create or revoke API keys and may delegate every selected scope.",
"An approved external secret manager and accountable owner are known.",
],
"steps": [
"Choose the accountable owner and the narrowest required scopes.",
"Set the shortest practical expiry before creating the key.",
"Transfer the one-time secret directly into the approved secret manager.",
"Revoke the key when its client, owner, or purpose is no longer valid.",
],
"outcome": "The automation client has a time-bounded credential whose effective access cannot exceed either its stored scopes or its owner's current permissions.",
"limitations": [
"A one-time secret cannot be displayed or recovered after its creation dialog closes.",
"Changing the owner, expiry, or scopes requires a replacement key.",
"Revocation does not update external clients; operators must install a replacement where needed.",
],
"consequences": [
"Revocation immediately rejects subsequent requests made with the key.",
"Removing permissions from the owner immediately narrows effective key access.",
],
"verification": "Reload the key directory, verify owner, prefix, scopes, expiry, and status, then test the intended client without copying secret material into evidence.",
},
),
DocumentationTopic( DocumentationTopic(
id="access.workflow.manage-reusable-credentials", id="access.workflow.manage-reusable-credentials",
title="Manage reusable credentials safely", title="Manage reusable credentials safely",
@@ -738,9 +815,41 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"), DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"),
DocumentationLink(label="Credential lifecycle API", href="/api/v1/admin/service-accounts/{service_account_id}/credentials", kind="api"), DocumentationLink(label="Credential lifecycle API", href="/api/v1/admin/service-accounts/{service_account_id}/credentials", kind="api"),
), ),
translations={
"de": {
"title": "Dienstkonten und ihre Zugangsdaten verwalten",
"summary": "Erstellen Sie nicht interaktive Automatisierungsidentitäten, begrenzen Sie deren aktuellen Berechtigungsrahmen und rotieren Sie einmal sichtbare Zugangsdaten ohne menschliche Anmeldung.",
"body": "Dienstkonten sind mandanteneigene Automatisierungsidentitäten ohne Passwort und ohne interaktive Sitzung. Administrierende legen zuerst den Berechtigungsrahmen des Kontos fest und erstellen danach eine oder mehrere unabhängig widerrufbare Zugangsdaten. Jede Zugangsdaten-Berechtigung muss innerhalb dieses Rahmens liegen. Bei jeder Anfrage wird sie erneut mit dem aktuellen Rahmen geschnitten; eine Verkleinerung des Rahmens oder eine Deaktivierung wirkt deshalb sofort. Das Geheimnis wird nur bei Erstellung oder Rotation einmal angezeigt. GovOPlaN speichert anschließend ausschließlich einen Einweg-Hash und das sichtbare Präfix; das Geheimnis kann weder angezeigt noch wiederhergestellt werden. Eine Rotation erzeugt in einer Transaktion den Ersatz und widerruft die vorherigen Zugangsdaten. Ein Widerruf unterbricht bestehende Clients sofort. Die Deaktivierung stoppt alle Anmeldungen des Dienstkontos, kann aber wieder aufgehoben werden. Das endgültige Stilllegen deaktiviert die zugrunde liegende Identität und widerruft sämtliche aktiven Zugangsdaten. Jede Änderung verwendet die aktuelle Revision des Dienstkontos; bei einem Konflikt muss die Ansicht neu geladen werden, damit keine parallele Änderung überschrieben wird.",
}
},
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"help_contexts": ["access.admin.service-accounts"], "help_contexts": [
"access.admin.service-accounts",
"access.service-accounts.action.reload",
"access.service-accounts.action.create",
"access.service-accounts.action.manage",
"access.service-accounts.action.edit",
"access.service-accounts.action.save",
"access.service-accounts.action.activation",
"access.service-accounts.action.retire",
"access.service-accounts.account-editor",
"access.service-accounts.field.name",
"access.service-accounts.field.description",
"access.service-accounts.field.scope-ceiling",
"access.service-accounts.field.show-revoked",
"access.service-accounts.credential-editor",
"access.service-accounts.action.create-credential",
"access.service-accounts.action.save-credential",
"access.service-accounts.action.rotate-credential",
"access.service-accounts.action.revoke-credential",
"access.service-accounts.field.credential-name",
"access.service-accounts.field.credential-expiry",
"access.service-accounts.field.credential-scopes",
"access.service-accounts.secret",
"access.service-accounts.confirm-revoke-credential",
"access.service-accounts.confirm-retire",
],
"prerequisites": [ "prerequisites": [
"The tenant permits API credentials.", "The tenant permits API credentials.",
"You have service-account write permission and may delegate every selected scope.", "You have service-account write permission and may delegate every selected scope.",
@@ -751,6 +860,17 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"Record the one-time secret in an external secret manager.", "Record the one-time secret in an external secret manager.",
"Rotate credentials before expiry and revoke credentials that are no longer used.", "Rotate credentials before expiry and revoke credentials that are no longer used.",
], ],
"outcome": "The automation principal remains non-interactive and can authenticate only through an active credential whose grant is within the account's current scope ceiling.",
"limitations": [
"One-time credential secrets cannot be displayed or recovered after the creation dialog closes.",
"Deactivation and a reduced scope ceiling affect clients immediately but do not rewrite their external configuration.",
"Retirement revokes every active credential and requires a new service account for later reuse.",
],
"consequences": [
"Rotation revokes the previous credential in the same transaction that creates its replacement.",
"Credential revocation, account deactivation, and retirement immediately reject affected client requests.",
"A stale revision is rejected so a concurrent administration change is not overwritten.",
],
"verification": "The administration table shows the expected active credential count, last-use timestamp, revision, and audit events without exposing secret material.", "verification": "The administration table shows the expected active credential count, last-use timestamp, revision, and audit events without exposing secret material.",
}, },
), ),
@@ -1134,7 +1254,7 @@ def _people_search(context: ModuleContext) -> object:
manifest = ModuleManifest( manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="0.1.18", version="0.1.19",
optional_dependencies=("identity", "organizations", "tenancy", "idm"), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
+41 -1
View File
@@ -22,8 +22,16 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
"access.admin.tenant-users", "access.admin.tenant-users",
"access.admin.tenant-groups", "access.admin.tenant-groups",
"access.admin.tenant-roles", "access.admin.tenant-roles",
},
"access.workflow.manage-api-keys": {
"access.admin.api-keys", "access.admin.api-keys",
"access.admin.service-accounts", "access.api-keys.action.create",
"access.api-keys.action.revoke",
"access.api-keys.field.owner",
"access.api-keys.field.expiry",
"access.api-keys.field.scopes",
"access.api-keys.secret",
"access.api-keys.confirm-revoke",
}, },
"access.workflow.manage-reusable-credentials": { "access.workflow.manage-reusable-credentials": {
"access.admin.system-credentials", "access.admin.system-credentials",
@@ -44,6 +52,16 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
}, },
"access.workflow.manage-service-account-credentials": { "access.workflow.manage-service-account-credentials": {
"access.admin.service-accounts", "access.admin.service-accounts",
"access.service-accounts.action.create",
"access.service-accounts.action.activation",
"access.service-accounts.action.retire",
"access.service-accounts.field.scope-ceiling",
"access.service-accounts.action.rotate-credential",
"access.service-accounts.action.revoke-credential",
"access.service-accounts.field.credential-expiry",
"access.service-accounts.field.credential-scopes",
"access.service-accounts.secret",
"access.service-accounts.confirm-retire",
}, },
"access.workflow.manage-sessions": { "access.workflow.manage-sessions": {
"access.settings.sessions", "access.settings.sessions",
@@ -71,6 +89,28 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
credential_topic.translations["de"]["body"], credential_topic.translations["de"]["body"],
) )
api_key_topic = topics["access.workflow.manage-api-keys"]
self.assertEqual(
{"title", "summary", "body"},
set(api_key_topic.translations["de"]),
)
self.assertIn(
"sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden",
api_key_topic.translations["de"]["body"],
)
service_account_topic = topics[
"access.workflow.manage-service-account-credentials"
]
self.assertEqual(
{"title", "summary", "body"},
set(service_account_topic.translations["de"]),
)
self.assertIn(
"widerruft sämtliche aktiven Zugangsdaten",
service_account_topic.translations["de"]["body"],
)
def test_access_admin_surfaces_remain_declared(self) -> None: def test_access_admin_surfaces_remain_declared(self) -> None:
surface_ids = { surface_ids = {
surface.id for surface in manifest.frontend.view_surfaces surface.id for surface in manifest.frontend.view_surfaces
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.18", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -49,6 +49,33 @@ assert.match(serviceAccounts, /rotateServiceAccountCredential/);
assert.match(serviceAccounts, /revokeServiceAccountCredential/); assert.match(serviceAccounts, /revokeServiceAccountCredential/);
assert.match(serviceAccounts, /Secrets are shown once/); assert.match(serviceAccounts, /Secrets are shown once/);
assert.match(serviceAccounts, /<ConfirmDialog[\s\S]*Retire service account/); assert.match(serviceAccounts, /<ConfirmDialog[\s\S]*Retire service account/);
for (const contextId of [
"access.api-keys.action.create",
"access.api-keys.action.revoke",
"access.api-keys.field.owner",
"access.api-keys.field.expiry",
"access.api-keys.field.scopes",
"access.api-keys.secret",
"access.api-keys.confirm-revoke"
]) {
assert.ok(apiKeys.includes(contextId), `API-key help context ${contextId} is missing`);
}
for (const contextId of [
"access.service-accounts.action.create",
"access.service-accounts.action.activation",
"access.service-accounts.action.retire",
"access.service-accounts.field.scope-ceiling",
"access.service-accounts.action.rotate-credential",
"access.service-accounts.action.revoke-credential",
"access.service-accounts.field.credential-expiry",
"access.service-accounts.field.credential-scopes",
"access.service-accounts.secret",
"access.service-accounts.confirm-retire"
]) {
assert.ok(serviceAccounts.includes(contextId), `Service-account help context ${contextId} is missing`);
}
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/); assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
assert.match(moduleSource, /access\.settings\.sessions/); assert.match(moduleSource, /access\.settings\.sessions/);
assert.match(moduleSource, /"settings\.sections": accessSettingsSections/); assert.match(moduleSource, /"settings\.sections": accessSettingsSections/);
+11 -11
View File
@@ -103,8 +103,8 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
{ 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: "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: "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: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[ { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) }, { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, helpContextId: "access.api-keys.action.inspect", helpModuleId: "access", onClick: () => setViewing(row) },
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) } { id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", helpContextId: "access.api-keys.action.revoke", helpModuleId: "access", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
]} /> }], ]} /> }],
[canRevoke]); [canRevoke]);
@@ -153,20 +153,20 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
return ( 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={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>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} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}> <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} helpContextId="access.admin.api-keys" helpModuleId="access" actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} helpContextId="access.api-keys.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} /><Button helpContextId="access.api-keys.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={openCreate} disabled={!canCreate || !users.length} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}>
<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> <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> </AdminPageLayout>
<Dialog variant="administration" size="wide" open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" onClose={() => !busy && setCreating(false)} className="" footer={<><Button onClick={() => setCreating(false)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}> <Dialog variant="administration" size="wide" open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" helpContextId="access.api-keys.action.create" helpModuleId="access" onClose={() => !busy && setCreating(false)} className="" footer={<><Button onClick={() => setCreating(false)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
<FormGrid columns={2} gap="small" collapseAt="workspace" className=""> <FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<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.name.709a2322" helpContextId="access.api-keys.field.name" helpModuleId="access"><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.owner.89ff3122" helpContextId="access.api-keys.field.owner" helpModuleId="access"><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> <FormField label="i18n:govoplan-access.expiry.ba8f571e" helpContextId="access.api-keys.field.expiry" helpModuleId="access"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
</FormGrid> </FormGrid>
<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> <div className="form-field" data-help-context-id="access.api-keys.field.scopes" data-help-module-id="access"><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>
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" helpContextId="access.api-keys.action.inspect" helpModuleId="access" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <><DescriptionList> {viewing && <><DescriptionList>
<DescriptionItem term={<>i18n:govoplan-access.name.709a2322</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.prefix.90eceb01</>}>{viewing.prefix}</DescriptionItem> <DescriptionItem term={<>i18n:govoplan-access.name.709a2322</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.prefix.90eceb01</>}>{viewing.prefix}</DescriptionItem>
<DescriptionItem term={<>i18n:govoplan-access.owner.89ff3122</>}>{viewing.user_email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</DescriptionItem> <DescriptionItem term={<>i18n:govoplan-access.owner.89ff3122</>}>{viewing.user_email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</DescriptionItem>
@@ -175,11 +175,11 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
</DescriptionList><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>} </DescriptionList><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog> </Dialog>
<Dialog variant="administration" size="large" open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}> <Dialog variant="administration" size="large" open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" helpContextId="access.api-keys.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.api-keys.secret" helpModuleId="access" 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></>} {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> </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()} /> <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} helpContextId="access.api-keys.confirm-revoke" helpModuleId="access" onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
</>); </>);
} }
@@ -214,7 +214,7 @@ export default function ServiceAccountsPanel({
resizable: false, resizable: false,
align: "right", align: "right",
render: (row) => <TableActionGroup actions={[ render: (row) => <TableActionGroup actions={[
{ id: "manage", label: `Manage ${row.name}`, icon: <Search />, onClick: () => void openManager(row) } { id: "manage", label: `Manage ${row.name}`, icon: <Search />, helpContextId: "access.service-accounts.action.manage", helpModuleId: "access", onClick: () => void openManager(row) }
]} /> ]} />
} }
], []); ], []);
@@ -274,6 +274,8 @@ export default function ServiceAccountsPanel({
id: "rotate", id: "rotate",
label: `Rotate ${row.name}`, label: `Rotate ${row.name}`,
icon: <RefreshCw />, icon: <RefreshCw />,
helpContextId: "access.service-accounts.action.rotate-credential",
helpModuleId: "access",
applicable: !row.revoked_at, applicable: !row.revoked_at,
disabled: !canWrite || !managing?.is_active, disabled: !canWrite || !managing?.is_active,
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined,
@@ -284,6 +286,8 @@ export default function ServiceAccountsPanel({
label: `Revoke ${row.name}`, label: `Revoke ${row.name}`,
icon: <Trash2 />, icon: <Trash2 />,
variant: "danger", variant: "danger",
helpContextId: "access.service-accounts.action.revoke-credential",
helpModuleId: "access",
applicable: !row.revoked_at, applicable: !row.revoked_at,
disabled: !canWrite, disabled: !canWrite,
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined,
@@ -445,10 +449,12 @@ export default function ServiceAccountsPanel({
loading={loading} loading={loading}
error={error} error={error}
success={success} success={success}
helpContextId="access.admin.service-accounts"
helpModuleId="access"
actions={<> actions={<>
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /> <DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
<Button onClick={() => void load()} disabled={loading}>Reload</Button> <Button helpContextId="access.service-accounts.action.reload" helpModuleId="access" 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} /> <AdminIconButton label="Add service account" icon={<Plus />} variant="primary" helpContextId="access.service-accounts.action.create" helpModuleId="access" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
</>} </>}
> >
<div className="admin-table-surface"> <div className="admin-table-surface">
@@ -459,15 +465,17 @@ export default function ServiceAccountsPanel({
<Dialog variant="administration" size="wide" <Dialog variant="administration" size="wide"
open={Boolean(accountEditor)} open={Boolean(accountEditor)}
title={accountEditor === "create" ? "Create service account" : "Edit service account"} title={accountEditor === "create" ? "Create service account" : "Edit service account"}
helpContextId="access.service-accounts.account-editor"
helpModuleId="access"
onClose={() => !busy && setAccountEditor(null)} onClose={() => !busy && setAccountEditor(null)}
className="" className=""
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></>} footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save" helpModuleId="access" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
> >
<FormGrid columns={2} gap="small" collapseAt="workspace" className=""> <FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<FormField label="Name"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField> <FormField label="Name" helpContextId="access.service-accounts.field.name" helpModuleId="access"><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> <FormField label="Description" helpContextId="access.service-accounts.field.description" helpModuleId="access"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
</FormGrid> </FormGrid>
<div className="form-field"> <div className="form-field" data-help-context-id="access.service-accounts.field.scope-ceiling" data-help-module-id="access">
<span className="form-label">Scope ceiling</span> <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." /> <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> </div>
@@ -476,6 +484,8 @@ export default function ServiceAccountsPanel({
<Dialog variant="administration" size="wide" <Dialog variant="administration" size="wide"
open={Boolean(managing)} open={Boolean(managing)}
title={managing?.name ?? "Service account"} title={managing?.name ?? "Service account"}
helpContextId="access.service-accounts.action.manage"
helpModuleId="access"
onClose={() => !busy && setManaging(null)} onClose={() => !busy && setManaging(null)}
className="" className=""
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>} footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
@@ -488,13 +498,13 @@ export default function ServiceAccountsPanel({
<MetricCard label="Revision" value={managing.revision} /> <MetricCard label="Revision" value={managing.revision} />
</MetricGrid> </MetricGrid>
<ActionToolbar className="admin-toolbar-row"> <ActionToolbar className="admin-toolbar-row">
<Button onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button> <Button helpContextId="access.service-accounts.action.edit" helpModuleId="access" 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 helpContextId="access.service-accounts.action.activation" helpModuleId="access" 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> <Button variant="danger" helpContextId="access.service-accounts.action.retire" helpModuleId="access" 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} /> <AdminIconButton label="Create credential" icon={<KeyRound />} variant="primary" helpContextId="access.service-accounts.action.create-credential" helpModuleId="access" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} />
</ActionToolbar> </ActionToolbar>
<ActionToolbar className="admin-toolbar-row"> <ActionToolbar className="admin-toolbar-row">
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} onChange={setShowRevoked} /> <ToggleSwitch label="Show revoked credentials" checked={showRevoked} helpContextId="access.service-accounts.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} />
</ActionToolbar> </ActionToolbar>
<div className="admin-table-surface"> <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." /> <DataGrid id="admin-service-account-credentials-v1" rows={visibleCredentials} columns={credentialColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No credentials found." />
@@ -506,27 +516,29 @@ export default function ServiceAccountsPanel({
<Dialog variant="administration" size="wide" <Dialog variant="administration" size="wide"
open={Boolean(credentialEditor)} open={Boolean(credentialEditor)}
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"} title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
helpContextId="access.service-accounts.credential-editor"
helpModuleId="access"
onClose={() => !busy && setCredentialEditor(null)} onClose={() => !busy && setCredentialEditor(null)}
className="" className=""
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></>} footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save-credential" helpModuleId="access" 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>} {credentialEditor?.mode === "rotate" && <p className="muted small-note">Rotation creates a new secret and revokes the previous credential in the same transaction.</p>}
<FormGrid columns={2} gap="small" collapseAt="workspace" className=""> <FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<FormField label="Name"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField> <FormField label="Name" helpContextId="access.service-accounts.field.credential-name" helpModuleId="access"><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> <FormField label="Expiry" helpContextId="access.service-accounts.field.credential-expiry" helpModuleId="access"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
</FormGrid> </FormGrid>
<div className="form-field"> <div className="form-field" data-help-context-id="access.service-accounts.field.credential-scopes" data-help-module-id="access">
<span className="form-label">Credential scopes</span> <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." /> <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> </div>
</Dialog> </Dialog>
<Dialog variant="administration" size="large" open={Boolean(secret)} title="Service-account secret" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}> <Dialog variant="administration" size="large" open={Boolean(secret)} title="Service-account secret" helpContextId="access.service-accounts.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.service-accounts.secret" helpModuleId="access" 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></>} {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> </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={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} helpContextId="access.service-accounts.confirm-revoke-credential" helpModuleId="access" 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()} /> <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} helpContextId="access.service-accounts.confirm-retire" helpModuleId="access" onCancel={() => setRetiring(false)} onConfirm={() => void retire()} />
</> </>
); );
} }