2 Commits
Author SHA1 Message Date
zemion 0f8a05f8b9 feat: contribute tenant erasure for access data
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 15:57:20 +02:00
zemion e55434f406 feat(access): document consequential credential controls
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:36:30 +02:00
12 changed files with 493 additions and 12 deletions
+3
View File
@@ -66,6 +66,9 @@ This module will own:
capabilities, including the bounded `access.governanceProjection.v1` bulk
reconciliation contract used by Admin for idempotent per-assignment outcomes
- access-owned migrations
- a provider-neutral tenant-erasure contribution that removes tenant-scoped
credentials and authorization projections while preserving shared global
accounts and identities
The governance-template routes under `/admin/system/governance-templates` are
contributed by `govoplan-admin`; access must not register those routes.
+19
View File
@@ -25,6 +25,9 @@ contracts.
- tenant owner provisioning and default access bootstrap
- materializing governance templates into access-owned groups and roles
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
- the `tenancy.erasure_provider.access` contribution, which previews and
idempotently removes only target-tenant credentials and authorization rows
while retaining global accounts and identities shared with other tenants
The active access tables use the `access_*` namespace while the model classes
live in this module: `access_accounts`, `access_users`, `access_groups`,
@@ -64,6 +67,22 @@ Access declares tenancy as an optional module integration. It uses the
core-owned `core_scopes` table as the scope table, but it must not import
`govoplan_tenancy` or require the tenancy package to start.
## Tenant-Erasure Boundary
Access implements the Core tenant-erasure provider contract without importing
Tenancy. Its preview counts every Access table with a tenant boundary. The
first destructive step removes target-tenant sessions and API keys; the second
removes service accounts, memberships, groups, tenant roles, organization
units, functions, assignments, and delegations in dependency-safe order.
Both steps are database-transactional and idempotent, so reconciliation can
repeat them after an interrupted response.
Global accounts, system-role assignments, identities, and identity-account
links are intentionally retained: they are installation-wide facts and may be
used by another tenant. Provider previews and receipts contain counts and
stable references only, never password hashes, session tokens, API-key hashes,
email addresses, or other credential material.
## Core-Only Startup Contract
A core-only installation must be able to start far enough to expose process
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/access-webui",
"version": "0.1.22",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-access"
version = "0.1.22"
version = "0.1.24"
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
"govoplan-core>=0.1.43",
"redis>=5,<6",
"SQLAlchemy>=2,<3",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN access platform module."""
__version__ = "0.1.22"
__version__ = "0.1.24"
+122 -1
View File
@@ -9,6 +9,10 @@ from govoplan_access.backend.configuration_provider import (
ACCESS_CONFIGURATION_CAPABILITY,
)
from govoplan_access.backend.dsar_provider import ACCESS_DSAR_CAPABILITY
from govoplan_access.backend.tenant_erasure_provider import (
ACCESS_TENANT_ERASURE_CAPABILITY,
AccessTenantErasureProvider,
)
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.access import (
@@ -75,6 +79,10 @@ def _permission(
)
def _tenant_erasure_provider(_context: ModuleContext) -> AccessTenantErasureProvider:
return AccessTenantErasureProvider()
ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission(
"access:tenant:read",
@@ -104,6 +112,13 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
"Access",
"system",
),
_permission(
"access:tenant:erase",
"Erase tenants",
"Preview, approve, execute, and reconcile governed destructive tenant erasure.",
"Access",
"system",
),
_permission(
"access:account:read",
"View accounts",
@@ -440,6 +455,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:tenant:create",
"access:tenant:update",
"access:tenant:suspend",
"access:tenant:erase",
"access:account:read",
"access:account:create",
"access:account:update",
@@ -862,6 +878,44 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
],
},
),
DocumentationTopic(
id="access.reference.authentication-fields",
title="Authentication and password fields",
summary="Understand which credentials are used for interactive sign-in, initial account enrollment, administrative re-authorization, and automation.",
body=(
"The sign-in email identifies the account and the password authenticates only that account. Initial passwords entered by administrators are transmitted only for account creation; leaving the field empty asks the server to generate a one-time temporary password. Requiring a password change prevents that temporary credential from becoming the long-term credential. Current-password prompts re-authorize a sensitive action and never target the selected user's password. The automation API key in local settings is used only when no interactive browser session token is available; it should be a narrowly scoped, revocable key and must not be shared with other users. Generated passwords are not applied until Use password is selected."
),
layer="always",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "access_admin", "operator"),
order=29,
conditions=(DocumentationCondition(required_modules=("access",)),),
links=(
DocumentationLink(label="Sign in", href="/", kind="runtime"),
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
DocumentationLink(label="Local connection settings", href="/settings", kind="runtime"),
),
translations={
"de": {
"title": "Authentifizierungs- und Passwortfelder",
"summary": "Einordnen, welche Zugangsdaten für die interaktive Anmeldung, die erste Kontoeinrichtung, die erneute administrative Autorisierung und Automatisierung verwendet werden.",
"body": (
"Die Anmelde-E-Mail identifiziert das Konto; das Passwort authentifiziert ausschließlich dieses Konto. Von Administrierenden eingegebene Anfangspasswörter werden nur zur Kontoerstellung übertragen. Bleibt das Feld leer, erzeugt der Server ein einmaliges temporäres Passwort. Die Pflicht zum Passwortwechsel verhindert, dass diese temporäre Zugangsdaten dauerhaft verwendet werden. Die Abfrage des aktuellen Passworts autorisiert eine sensible Aktion erneut und meint niemals das Passwort der ausgewählten Person. Der Automatisierungs-API-Schlüssel in den lokalen Einstellungen wird nur verwendet, wenn kein interaktives Browser-Sitzungstoken verfügbar ist; er sollte eng begrenzt, widerrufbar und nicht mit anderen Personen geteilt sein. Generierte Passwörter werden erst mit „Passwort verwenden“ übernommen."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
"access.authentication.email",
"access.authentication.password",
"access.settings.automation-api-key",
"access.admin.system-users.initial-password",
"access.admin.tenant-users.initial-password",
"access.admin.tenant-users.require-password-change",
],
},
),
DocumentationTopic(
id="access.workflow.grant-user-access",
title="Grant a person access",
@@ -1531,6 +1585,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
"access.sessions.action.revoke",
"access.sessions.action.revoke-others",
"access.admin.user-sessions",
"access.sessions.field.current-password",
],
"api_paths": [
"/api/v1/auth/sessions",
@@ -1716,6 +1771,62 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
],
},
),
DocumentationTopic(
id="access.reference.tenant-erasure-boundary",
title="Understand Access behavior during tenant erasure",
summary=(
"Access contributes a bounded, idempotent erasure plan for tenant-scoped "
"credentials and authorization records without deleting shared identities."
),
body=(
"The tenant-erasure preview counts sessions, API keys, memberships, service "
"accounts, groups, roles, organization units, functions, assignments, and "
"delegations owned by the target tenant. Execution first revokes tenant "
"credentials and then removes the remaining tenant-scoped authorization "
"projection. Global accounts, system roles, identities, and identity links are "
"retained because the same login identity may serve another tenant. Each step "
"uses the orchestration idempotency key and can be reconciled safely after a "
"database rollback or interrupted response. No credential secret is included "
"in previews, receipts, checkpoints, or audit evidence."
),
layer="configured",
documentation_types=("admin",),
audience=("system_admin", "operator", "security_reviewer"),
order=36,
conditions=(
DocumentationCondition(
required_modules=("access", "tenancy"),
any_scopes=("system:tenants:erase",),
),
),
related_modules=("audit", "tenancy"),
translations={
"de": {
"title": "Access-Grenze bei der Mandantenlöschung verstehen",
"summary": (
"Access trägt einen begrenzten, idempotenten Löschplan für "
"mandantenbezogene Anmeldedaten und Berechtigungsdatensätze bei, "
"ohne gemeinsam verwendete Identitäten zu löschen."
),
"body": (
"Die Vorschau der Mandantenlöschung zählt Sitzungen, API-Schlüssel, "
"Mitgliedschaften, Servicekonten, Gruppen, Rollen, Organisationseinheiten, "
"Funktionen, Zuweisungen und Delegationen des Zielmandanten. Die Ausführung "
"widerruft zuerst mandantenbezogene Anmeldedaten und entfernt danach die "
"übrige Berechtigungsprojektion. Globale Konten, Systemrollen, Identitäten "
"und Identitätsverknüpfungen bleiben erhalten, weil dieselbe Login-Identität "
"einem weiteren Mandanten dienen kann. Jeder Schritt nutzt den "
"Idempotenzschlüssel der Orchestrierung und kann nach einem Datenbank-Rollback "
"oder einer unterbrochenen Antwort sicher abgeglichen werden. Vorschauen, "
"Belege, Prüfpunkte und Auditnachweise enthalten keine geheimen Anmeldedaten."
),
}
},
metadata={
"kind": "reference",
"help_contexts": ["tenancy.admin.tenant-erasure"],
},
),
)
@@ -1921,7 +2032,7 @@ def _people_search(context: ModuleContext) -> object:
manifest = ModuleManifest(
id="access",
name="Access",
version="0.1.22",
version="0.1.24",
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
@@ -2117,6 +2228,7 @@ manifest = ModuleManifest(
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
ACCESS_DSAR_CAPABILITY: _dsar_provider,
ACCESS_TENANT_ERASURE_CAPABILITY: _tenant_erasure_provider,
},
capability_documentation={
ACCESS_DSAR_CAPABILITY: CapabilityDocumentation(
@@ -2127,6 +2239,15 @@ manifest = ModuleManifest(
),
contract_version="0.1.0",
),
ACCESS_TENANT_ERASURE_CAPABILITY: CapabilityDocumentation(
label="Access tenant-erasure provider",
summary=(
"Previews and idempotently removes tenant-scoped sessions, keys, "
"memberships, roles, groups, functions, and assignments while "
"retaining shared global accounts and identities."
),
contract_version="0.1.0",
),
},
documentation=ACCESS_DOCUMENTATION,
architecture=declared_module_architecture(
@@ -0,0 +1,189 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import (
ApiKey,
AuthSession,
ExternalFunctionRoleAssignment,
Function,
FunctionAssignment,
FunctionDelegation,
FunctionRoleAssignment,
Group,
GroupRoleAssignment,
OrganizationUnit,
Role,
ServiceAccount,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_core.core.tenant_erasure import (
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
TenantErasurePreview,
TenantErasureResource,
TenantErasureStep,
TenantErasureStepResult,
)
ACCESS_TENANT_ERASURE_CAPABILITY = (
f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}access"
)
_CREDENTIAL_MODELS = (AuthSession, ApiKey)
_TENANT_ACCESS_MODELS = (
ServiceAccount,
FunctionDelegation,
ExternalFunctionRoleAssignment,
FunctionRoleAssignment,
UserGroupMembership,
UserRoleAssignment,
GroupRoleAssignment,
FunctionAssignment,
Function,
OrganizationUnit,
User,
Group,
Role,
)
_ALL_MODELS = _CREDENTIAL_MODELS + _TENANT_ACCESS_MODELS
def _counts(session: Session, tenant_id: str) -> dict[str, int]:
return {
model.__tablename__: session.query(model)
.filter(model.tenant_id == tenant_id)
.count()
for model in _ALL_MODELS
}
def _delete_models(
session: Session,
tenant_id: str,
models: tuple[type, ...],
) -> int:
deleted = 0
for model in models:
deleted += (
session.query(model)
.filter(model.tenant_id == tenant_id)
.delete(synchronize_session=False)
)
return deleted
class AccessTenantErasureProvider:
module_id = "access"
def preview_tenant_erasure(
self,
session: object,
tenant_id: str,
) -> TenantErasurePreview:
if not isinstance(session, Session):
raise TypeError("Access tenant erasure requires a database session.")
counts = _counts(session, tenant_id)
credential_count = sum(
counts[model.__tablename__] for model in _CREDENTIAL_MODELS
)
access_count = sum(
counts[model.__tablename__] for model in _TENANT_ACCESS_MODELS
)
resources = tuple(
TenantErasureResource(
resource_type=table_name,
count=count,
disposition="erase",
summary=f"{count} tenant-scoped Access records will be erased.",
)
for table_name, count in sorted(counts.items())
)
steps: list[TenantErasureStep] = []
if credential_count:
steps.append(
TenantErasureStep(
step_id="revoke-tenant-credentials",
kind="erase",
summary="Revoke tenant sessions and erase tenant API keys.",
destructive=True,
irreversible=True,
)
)
if access_count:
steps.append(
TenantErasureStep(
step_id="erase-tenant-access",
kind="erase",
summary=(
"Erase tenant memberships, service accounts, groups, roles, "
"organization units, functions, assignments, and delegations."
),
destructive=True,
irreversible=True,
depends_on=(
("revoke-tenant-credentials",) if credential_count else ()
),
)
)
return TenantErasurePreview(
module_id=self.module_id,
complete=True,
resources=resources,
steps=tuple(steps),
warnings=(
"Global accounts and identity links are retained because they may belong to other tenants.",
),
provider_revision="access-tenant-erasure-v1",
)
def execute_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
if not isinstance(session, Session):
raise TypeError("Access tenant erasure requires a database session.")
if not idempotency_key.strip():
raise ValueError("Access tenant erasure requires an idempotency key.")
if step_id == "revoke-tenant-credentials":
deleted = _delete_models(session, tenant_id, _CREDENTIAL_MODELS)
summary = "Tenant sessions and API keys were erased."
elif step_id == "erase-tenant-access":
deleted = _delete_models(session, tenant_id, _TENANT_ACCESS_MODELS)
summary = "Tenant-scoped Access records were erased."
else:
return TenantErasureStepResult(
state="blocked",
summary="Access tenant erasure step is unknown.",
)
return TenantErasureStepResult(
state="completed",
summary=summary,
receipt_ref=f"access:tenant-erasure:{tenant_id}:{step_id}",
metrics={"deleted": deleted},
)
def reconcile_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
return self.execute_tenant_erasure_step(
session,
tenant_id,
step_id,
idempotency_key,
)
__all__ = [
"ACCESS_TENANT_ERASURE_CAPABILITY",
"AccessTenantErasureProvider",
]
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import (
Account,
ApiKey,
AuthSession,
Group,
Role,
User,
)
from govoplan_access.backend.tenant_erasure_provider import (
AccessTenantErasureProvider,
)
def test_access_erasure_is_tenant_bounded_and_retains_global_account() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
AccessBase.metadata.create_all(engine)
now = datetime.now(UTC)
with Session(engine) as session:
account = Account(
email="shared@example.test",
normalized_email="shared@example.test",
is_active=True,
auth_provider="local",
)
session.add(account)
session.flush()
first = User(
tenant_id="tenant-1",
account_id=account.id,
email="shared@example.test",
is_active=True,
is_tenant_admin=True,
auth_provider="local",
)
second = User(
tenant_id="tenant-2",
account_id=account.id,
email="shared@example.test",
is_active=True,
is_tenant_admin=False,
auth_provider="local",
)
session.add_all(
[
first,
second,
Group(tenant_id="tenant-1", slug="group", name="Group"),
Group(tenant_id="tenant-2", slug="group", name="Group"),
Role(tenant_id="tenant-1", slug="role", name="Role"),
Role(tenant_id="tenant-2", slug="role", name="Role"),
]
)
session.flush()
session.add_all(
[
ApiKey(
tenant_id="tenant-1",
user_id=first.id,
name="key",
prefix="prefix",
key_hash="hash",
scopes=[],
),
AuthSession(
tenant_id="tenant-1",
user_id=first.id,
account_id=account.id,
token_hash="token-hash",
expires_at=now + timedelta(hours=1),
),
]
)
session.commit()
provider = AccessTenantErasureProvider()
preview = provider.preview_tenant_erasure(session, "tenant-1")
assert preview.allowed
assert [step.step_id for step in preview.steps] == [
"revoke-tenant-credentials",
"erase-tenant-access",
]
assert "revoke-tenant-credentials" in preview.steps[1].depends_on
revoked = provider.execute_tenant_erasure_step(
session,
"tenant-1",
"revoke-tenant-credentials",
"operation:access:credentials",
)
erased = provider.execute_tenant_erasure_step(
session,
"tenant-1",
"erase-tenant-access",
"operation:access:tenant",
)
session.commit()
assert revoked.state == "completed"
assert erased.state == "completed"
assert provider.preview_tenant_erasure(session, "tenant-1").steps == ()
assert session.scalar(select(Account).where(Account.id == account.id)) is not None
assert session.scalar(select(User).where(User.tenant_id == "tenant-2")) is not None
assert session.scalar(select(Group).where(Group.tenant_id == "tenant-2")) is not None
assert session.scalar(select(Role).where(Role.tenant_id == "tenant-2")) is not None
def test_access_erasure_replay_is_idempotent() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
AccessBase.metadata.create_all(engine)
provider = AccessTenantErasureProvider()
with Session(engine) as session:
first = provider.execute_tenant_erasure_step(
session,
"tenant-1",
"erase-tenant-access",
"operation:access:tenant",
)
second = provider.reconcile_tenant_erasure_step(
session,
"tenant-1",
"erase-tenant-access",
"operation:access:tenant",
)
assert first.metrics == {"deleted": 0}
assert second.metrics == {"deleted": 0}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/access-webui",
"version": "0.1.22",
"version": "0.1.24",
"private": true,
"type": "module",
"scripts": {
@@ -239,8 +239,10 @@ export default function SystemUsersPanel({
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
<PasswordField
helpContextId="access.admin.system-users.initial-password"
helpModuleId="access"
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
+9 -5
View File
@@ -249,6 +249,8 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
id: "revoke-session",
label: "i18n:govoplan-access.revoke_session.5e551007",
variant: "danger",
helpContextId: "access.sessions.action.revoke",
helpModuleId: "access",
applicable: !row.current,
disabled: busy || !canRevokeSessions,
disabledReason: !canRevokeSessions ? "i18n:govoplan-access.session_revocation_permission_required.5e551016" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined,
@@ -283,8 +285,10 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.tenant-users.initial-password" helpModuleId="access">
<PasswordField
helpContextId="access.admin.tenant-users.initial-password"
helpModuleId="access"
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
@@ -294,7 +298,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
}
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
</FormGrid>
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} helpContextId="access.admin.tenant-users.require-password-change" helpModuleId="access" onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
<ContentGrid columns={2} spacing="block" collapseAt="wide">
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
@@ -320,11 +324,11 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
</>}
</Dialog>
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" onClick={() => void revokeSelectedSession()} disabled={busy || !reauthorizationPassword} disabledReason={!reauthorizationPassword ? "i18n:govoplan-access.current_password_required.5e551019" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.revoke_session.5e551007</Button></>}>
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClick={() => void revokeSelectedSession()} disabled={busy || !reauthorizationPassword} disabledReason={!reauthorizationPassword ? "i18n:govoplan-access.current_password_required.5e551019" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.revoke_session.5e551007</Button></>}>
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
<p>i18n:govoplan-access.admin_session_revocation_confirmation.5e551020</p>
<FormField label="i18n:govoplan-access.current_password.5e551021">
<PasswordField value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.sessions.field.current-password" helpModuleId="access">
<PasswordField helpContextId="access.sessions.field.current-password" helpModuleId="access" value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
</FormField>
</Dialog>
@@ -132,6 +132,8 @@ export default function SessionSettingsPanel({
id: "revoke",
label: "i18n:govoplan-access.revoke_session.5e551007",
variant: "danger",
helpContextId: "access.sessions.action.revoke",
helpModuleId: "access",
applicable: !row.current,
disabled: busy,
disabledReason: busy
@@ -208,6 +210,8 @@ export default function SessionSettingsPanel({
destructiveActions={
<Button
variant="danger"
helpContextId="access.sessions.action.revoke-others"
helpModuleId="access"
disabled={busy || sessions.filter((item) => !item.current).length === 0}
disabledReason={
busy
@@ -243,6 +247,8 @@ export default function SessionSettingsPanel({
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
tone="danger"
busy={busy}
helpContextId="access.sessions.action.revoke"
helpModuleId="access"
onCancel={() => setRevoking(null)}
onConfirm={() => void revokeOne()}
/>
@@ -253,6 +259,8 @@ export default function SessionSettingsPanel({
confirmLabel="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
tone="danger"
busy={busy}
helpContextId="access.sessions.action.revoke-others"
helpModuleId="access"
onCancel={() => setRevokingOthers(false)}
onConfirm={() => void revokeOthers()}
/>