25 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
zemion a889071b71 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:30 +02:00
zemion 8a43b9b676 docs(access): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:11:45 +02:00
zemion 206873b62a Release Access v0.1.20 configuration package context
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 18:04:33 +02:00
zemion 2d1b1e356e docs(access): add credential lifecycle contextual help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 21:14:31 +02:00
zemion fa0c85e03a docs(access): describe DSAR capability 2026-08-21 13:59:45 +02:00
zemion 94b604a3af feat: reconcile governance assignments in bulk 2026-08-20 19:46:57 +02:00
zemion d3daf42bd9 feat: enforce custom appearance policy 2026-08-20 10:50:55 +02:00
zemion 6052dde760 feat: enforce effective appearance policy 2026-08-20 07:03:28 +02:00
zemion 566b3b83ad feat: secure resource explanation subjects 2026-08-20 06:16:43 +02:00
zemion e4bae0121d chore(access): update FastAPI status constant 2026-08-20 02:39:41 +02:00
zemion 5c6f446cd5 feat(access): persist personal navigation preferences 2026-08-20 01:47:04 +02:00
zemion 2be1dbc132 feat(access): add governed session management 2026-08-19 22:50:15 +02:00
zemion 38fc22c06b docs(access): describe acting context enforcement 2026-08-19 22:16:18 +02:00
zemion 3661fdd370 Adopt semantic page action layout 2026-08-19 14:26:25 +02:00
zemion 3966c7f33c Adopt shared WebUI structural primitives 2026-08-18 13:17:22 +02:00
zemion 9ba69286f7 Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion 55d87a7812 Adopt shared WebUI layout primitives 2026-08-18 10:42:50 +02:00
zemion a861338b9a docs: add credential lifecycle guidance 2026-08-17 19:52:11 +02:00
zemion 0b9e3751c2 feat: provide access DSAR integration 2026-08-07 14:53:24 +02:00
zemion e04671034f feat: pass deployment receipts to configuration providers 2026-08-07 11:15:49 +02:00
zemion 44799b15e5 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:43 +02:00
zemion 367ffc4564 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:57 +02:00
zemion a7ed28f6a3 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 15s
2026-08-05 19:51:58 +02:00
44 changed files with 5468 additions and 377 deletions
+5 -1
View File
@@ -63,8 +63,12 @@ This module will own:
- compatibility FastAPI auth dependency API at `govoplan_access.auth` - compatibility FastAPI auth dependency API at `govoplan_access.auth`
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth` - provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
- access administration, tenant provisioning, and governance materializer - access administration, tenant provisioning, and governance materializer
capabilities capabilities, including the bounded `access.governanceProjection.v1` bulk
reconciliation contract used by Admin for idempotent per-assignment outcomes
- access-owned migrations - 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 The governance-template routes under `/admin/system/governance-templates` are
contributed by `govoplan-admin`; access must not register those routes. contributed by `govoplan-admin`; access must not register those routes.
+30 -3
View File
@@ -25,6 +25,9 @@ contracts.
- tenant owner provisioning and default access bootstrap - tenant owner provisioning and default access bootstrap
- materializing governance templates into access-owned groups and roles - materializing governance templates into access-owned groups and roles
- access-owned SQLAlchemy metadata and migrations for `access_*` tables - 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 The active access tables use the `access_*` namespace while the model classes
live in this module: `access_accounts`, `access_users`, `access_groups`, 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 core-owned `core_scopes` table as the scope table, but it must not import
`govoplan_tenancy` or require the tenancy package to start. `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 ## Core-Only Startup Contract
A core-only installation must be able to start far enough to expose process A core-only installation must be able to start far enough to expose process
@@ -146,8 +165,11 @@ The backend foundation exposes these administration routes:
- `/api/v1/admin/function-assignments` - `/api/v1/admin/function-assignments`
- `/api/v1/admin/function-delegations` - `/api/v1/admin/function-delegations`
Dedicated WebUI management panels and explicit acting-in-place context Dedicated WebUI management panels remain follow-up work on top of these routes.
selection are still follow-up work on top of these routes. Interactive acting-in-place selection is available through
`/api/v1/auth/acting-contexts` and `/api/v1/auth/switch-acting-context`; it
persists the exact selected assignment and represented account on the session,
audits each switch, and fails closed when the assignment is no longer effective.
## Removed Compatibility Paths ## Removed Compatibility Paths
@@ -177,7 +199,12 @@ admin routers as base routers.
Governance-template metadata CRUD is not access-owned. It is contributed by Governance-template metadata CRUD is not access-owned. It is contributed by
`govoplan-admin`; access only materializes those templates into access-owned `govoplan-admin`; access only materializes those templates into access-owned
groups and roles through the `access.governanceMaterializer` capability. groups and roles. The compatibility `access.governanceMaterializer` capability
remains available for single-assignment callers. New Admin orchestration uses
`access.governanceProjection.v1`: a bounded request of stable template and
assignment DTOs that bulk-loads managed rows and assignment blockers, applies
idempotent create/update/remove reconciliation, and returns one provenance-rich
outcome per assignment. Admin never imports Access ORM models.
The configuration-package Admin routes remain in Access as a compatibility The configuration-package Admin routes remain in Access as a compatibility
surface. Their preflight context is assembled from the active Core registry, surface. Their preflight context is assembled from the active Core registry,
+5 -4
View File
@@ -161,6 +161,10 @@ Implemented backend foundation:
delegation identifiers when those facts exist. delegation identifiers when those facts exist.
- The access manifest registers `access.semanticDirectory` and - The access manifest registers `access.semanticDirectory` and
`access.explanation` capabilities. `access.explanation` capabilities.
- Interactive sessions can list `/api/v1/auth/acting-contexts` and explicitly
select or clear one with `/api/v1/auth/switch-acting-context`. Every switch is
audited. API keys cannot select an acting context, and a stale, expired,
revoked, or account-mismatched assignment fails closed.
Remaining rollout: Remaining rollout:
@@ -169,9 +173,6 @@ Remaining rollout:
projection until migration is complete. projection until migration is complete.
2. Add dedicated WebUI management panels for identities, organization units, 2. Add dedicated WebUI management panels for identities, organization units,
functions, assignments, and delegations. functions, assignments, and delegations.
3. Add explicit acting-in-place context selection; `act_in_place` delegation 3. Retrofit postbox, workflow, portal, and audit consumers to use identity,
facts are stored now but do not silently grant permissions without a selected
acting context.
4. Retrofit postbox, workflow, portal, and audit consumers to use identity,
organization, and access explanation capabilities rather than local access organization, and access explanation capabilities rather than local access
assumptions. assumptions.
+12 -2
View File
@@ -31,9 +31,14 @@ through declared capabilities or metadata.
## Interaction evidence ## Interaction evidence
- `AdminPageLayout`, `TreeSubnav`, `DataGrid`, `Dialog`, `ConfirmDialog`, - `WorkspaceLayout`, headerless `PageLayout`, `AdminPageLayout`, `TreeSubnav`,
`DataGrid`, `Dialog`, `ConfirmDialog`,
`TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and `TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and
`DocumentationHelpLink` come from Core. `DocumentationHelpLink` come from Core.
- The administration tree and its contributed panels now share Core-owned pane
sizing, scrolling, content inset, responsive collapse, region labels, and
contextual-help identity; Access no longer carries a raw workspace or page
frame exception.
- Dialog focus trapping and restoration, disabled-action tooltips, keyboard - Dialog focus trapping and restoration, disabled-action tooltips, keyboard
ordering, responsive grid overflow, and alert semantics therefore inherit ordering, responsive grid overflow, and alert semantics therefore inherit
the tested Core behavior. the tested Core behavior.
@@ -47,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.
+23
View File
@@ -0,0 +1,23 @@
# Session And Device Management
Authenticated users can inspect their active browser sessions under **Settings
> Sessions and devices**. Each row exposes only a stable session identifier,
current-session marker, bounded user-agent label, creation time, last activity,
expiry, and lifecycle state. Session tokens, token and CSRF hashes, cookies, IP
addresses, and unrelated request metadata are never returned.
Users may revoke one other session or all other active sessions. The current
session is deliberately protected by these operations; use normal logout to end
it. Repeating a revocation is safe. Revoked sessions fail authentication on the
next request, including when a principal summary was previously cached.
Tenant administrators may list sessions only for a membership in their governed
tenant and may revoke only a session belonging to that membership and tenant.
The mutation requires both the central membership-update permission and an
interactive-session password re-authorization. API-key administration and
cross-tenant session disclosure fail closed.
Audit events retain the actor, target session or account, action, and revoked
count where applicable. They do not copy client labels, network addresses, or
credentials. Expired and revoked sessions are retained according to Access data
retention and are omitted from the active-session list.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.15", "version": "0.1.24",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.15", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.15" version = "0.1.24"
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"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.15", "govoplan-core>=0.1.43",
"redis>=5,<6", "redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN access platform module.""" """GovOPlaN access platform module."""
__version__ = "0.1.15" __version__ = "0.1.24"
@@ -6,6 +6,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
@@ -36,12 +37,43 @@ class AdminOverviewResponse(BaseModel):
capabilities: list[str] = Field(default_factory=list) capabilities: list[str] = Field(default_factory=list)
class AdminSessionItem(BaseModel):
id: str
tenant_id: str
current: bool
status: Literal["active", "expired", "revoked"]
created_at: datetime
last_seen_at: datetime | None = None
expires_at: datetime
revoked_at: datetime | None = None
client: str | None = None
class AdminSessionListResponse(BaseModel):
sessions: list[AdminSessionItem] = Field(default_factory=list)
class AdminSessionRevocationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
current_password: str = Field(min_length=1, max_length=1024)
class AdminSessionRevocationResponse(BaseModel):
session: AdminSessionItem
revoked: bool
class TenantAdminItem(BaseModel): class TenantAdminItem(BaseModel):
id: str id: str
slug: str = Field(min_length=1, max_length=100) slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
description: str | None = None description: str | None = None
default_locale: str = Field(default="en", min_length=1, max_length=20) default_locale: str = Field(
default=REFERENCE_LANGUAGE_CODE,
min_length=1,
max_length=20,
)
settings: dict[str, Any] = Field(default_factory=dict) settings: dict[str, Any] = Field(default_factory=dict)
allow_custom_groups: bool | None = None allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None allow_custom_roles: bool | None = None
@@ -81,7 +113,7 @@ class TenantCreateRequest(BaseModel):
name: str name: str
owner_account_id: str | None = None owner_account_id: str | None = None
description: str | None = None description: str | None = None
default_locale: str = "en" default_locale: str = REFERENCE_LANGUAGE_CODE
settings: dict[str, Any] = Field(default_factory=dict) settings: dict[str, Any] = Field(default_factory=dict)
allow_custom_groups: bool | None = None allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None allow_custom_roles: bool | None = None
@@ -105,7 +137,11 @@ class TenantSettingsItem(BaseModel):
id: str id: str
slug: str slug: str
name: str name: str
default_locale: str = Field(default="en", min_length=1, max_length=20) default_locale: str = Field(
default=REFERENCE_LANGUAGE_CODE,
min_length=1,
max_length=20,
)
available_languages: list[dict[str, Any]] = Field(default_factory=list) available_languages: list[dict[str, Any]] = Field(default_factory=list)
system_enabled_language_codes: list[str] = Field(default_factory=list) system_enabled_language_codes: list[str] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
@@ -505,6 +541,21 @@ class ResourceAccessExplanationResponse(BaseModel):
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list) provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
class ResourceAccessExplanationSubjectItem(BaseModel):
id: str
email: str | None = None
display_name: str | None = None
class ResourceAccessExplanationSubjectsResponse(BaseModel):
mode: Literal["current_user", "cross_user"]
can_select_other_users: bool
reason: str
source: str
required_scope: str | None = None
users: list[ResourceAccessExplanationSubjectItem] = Field(default_factory=list)
class UserListResponse(PagedListResponse): class UserListResponse(PagedListResponse):
users: list[UserAdminItem] users: list[UserAdminItem]
@@ -799,6 +850,7 @@ class ConfigurationPackageApplyResponse(BaseModel):
diagnostics: list[dict[str, Any]] = Field(default_factory=list) diagnostics: list[dict[str, Any]] = Field(default_factory=list)
created_refs: dict[str, str] = Field(default_factory=dict) created_refs: dict[str, str] = Field(default_factory=dict)
updated_refs: dict[str, str] = Field(default_factory=dict) updated_refs: dict[str, str] = Field(default_factory=dict)
rollback: dict[str, Any] | None = None
class ConfigurationPackageExportRequest(BaseModel): class ConfigurationPackageExportRequest(BaseModel):
@@ -814,10 +866,11 @@ class ConfigurationPackageExportResponse(BaseModel):
fragments: list[dict[str, Any]] = Field(default_factory=list) fragments: list[dict[str, Any]] = Field(default_factory=list)
data_requirements: list[dict[str, Any]] = Field(default_factory=list) data_requirements: list[dict[str, Any]] = Field(default_factory=list)
diagnostics: list[dict[str, Any]] = Field(default_factory=list) diagnostics: list[dict[str, Any]] = Field(default_factory=list)
provenance: dict[str, Any] | None = None
class SystemSettingsItem(BaseModel): class SystemSettingsItem(BaseModel):
default_locale: str = "en" default_locale: str = REFERENCE_LANGUAGE_CODE
allow_tenant_custom_groups: bool = True allow_tenant_custom_groups: bool = True
allow_tenant_custom_roles: bool = True allow_tenant_custom_roles: bool = True
allow_tenant_api_keys: bool = True allow_tenant_api_keys: bool = True
+194 -4
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
from functools import lru_cache from functools import lru_cache
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,6 +16,7 @@ from govoplan_core.api.v1.schemas import (
AuthShellResponse, AuthShellResponse,
AuthSessionResponse, AuthSessionResponse,
AuthSessionUserInfo, AuthSessionUserInfo,
EffectiveAppearanceInfo,
GroupInfo, GroupInfo,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
@@ -30,6 +32,7 @@ from govoplan_core.api.v1.schemas import (
UserUiPreferences, UserUiPreferences,
) )
from govoplan_core.core.access import AuthMethod, PrincipalRef from govoplan_core.core.access import AuthMethod, PrincipalRef
from govoplan_core.core.appearance import resolve_effective_appearance
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.principal_cache import invalidate_auth_principals from govoplan_core.core.principal_cache import invalidate_auth_principals
@@ -38,7 +41,7 @@ from govoplan_core.core.idm import (
IdmDirectory, IdmDirectory,
OrganizationFunctionAssignmentRef, OrganizationFunctionAssignmentRef,
) )
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, require_scope
from govoplan_core.admin.settings import get_system_settings from govoplan_core.admin.settings import get_system_settings
from govoplan_core.audit.logging import audit_event from govoplan_core.audit.logging import audit_event
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
@@ -78,6 +81,13 @@ from govoplan_access.backend.security.sessions import (
create_auth_session, create_auth_session,
verify_auth_session_csrf, verify_auth_session_csrf,
) )
from govoplan_access.backend.session_management import (
SessionSummary,
list_account_sessions,
revoke_account_session,
revoke_other_account_sessions,
session_summary,
)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -96,6 +106,44 @@ class ActingContextListResponse(BaseModel):
active_assignment_id: str | None = None active_assignment_id: str | None = None
class AccountSessionInfo(BaseModel):
id: str
tenant_id: str
current: bool
status: Literal["active", "expired", "revoked"]
created_at: datetime
last_seen_at: datetime | None = None
expires_at: datetime
revoked_at: datetime | None = None
client: str | None = None
class AccountSessionListResponse(BaseModel):
sessions: list[AccountSessionInfo] = Field(default_factory=list)
class AccountSessionRevocationResponse(BaseModel):
session: AccountSessionInfo
revoked: bool
class OtherSessionRevocationResponse(BaseModel):
revoked_count: int
def _account_session_info(item: SessionSummary) -> AccountSessionInfo:
return AccountSessionInfo(**asdict(item))
def _interactive_session(principal: ApiPrincipal) -> AuthSession:
if principal.auth_session is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session management requires an interactive browser session.",
)
return principal.auth_session
def _acting_assignments( def _acting_assignments(
request: Request, request: Request,
*, *,
@@ -209,12 +257,23 @@ def _user_ui_preferences(settings_payload: object) -> UserUiPreferences:
return UserUiPreferences() return UserUiPreferences()
def _effective_appearance_info(session: Session, *, tenant: Tenant, user: User) -> EffectiveAppearanceInfo:
system_item = get_system_settings(session)
decision = resolve_effective_appearance(
system_settings=system_item.settings,
tenant_settings=tenant.settings,
user_settings=user.settings,
)
return EffectiveAppearanceInfo.model_validate(decision.as_dict())
def _user_info( def _user_info(
user: User, user: User,
account: Account, account: Account,
*, *,
preferred_language: str | None = None, preferred_language: str | None = None,
enabled_language_codes: list[str] | None = None, enabled_language_codes: list[str] | None = None,
appearance: EffectiveAppearanceInfo | None = None,
) -> UserInfo: ) -> UserInfo:
return UserInfo( return UserInfo(
id=user.id, id=user.id,
@@ -227,6 +286,7 @@ def _user_info(
preferred_language=preferred_language, preferred_language=preferred_language,
enabled_language_codes=enabled_language_codes or [], enabled_language_codes=enabled_language_codes or [],
ui_preferences=_user_ui_preferences(user.settings), ui_preferences=_user_ui_preferences(user.settings),
appearance=appearance or EffectiveAppearanceInfo(),
) )
@@ -547,7 +607,7 @@ def _shell_response(
] ]
) )
return AuthShellResponse( return AuthShellResponse(
user=_user_info(user, account), user=_user_info(user, account, appearance=_effective_appearance_info(session, tenant=tenant, user=user)),
tenant=active_tenant, tenant=active_tenant,
active_tenant=active_tenant, active_tenant=active_tenant,
tenants=memberships, tenants=memberships,
@@ -623,6 +683,7 @@ def _profile_response(session: Session, context: AuthContext) -> AuthProfileResp
context.account, context.account,
preferred_language=preferred_language, preferred_language=preferred_language,
enabled_language_codes=user_enabled, enabled_language_codes=user_enabled,
appearance=_effective_appearance_info(session, tenant=context.tenant, user=context.user),
), ),
tenant=active_tenant, tenant=active_tenant,
active_tenant=active_tenant, active_tenant=active_tenant,
@@ -737,7 +798,13 @@ def _me_response(
) )
] ]
return MeResponse( return MeResponse(
user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled), user=_user_info(
user,
account,
preferred_language=preferred_language,
enabled_language_codes=user_enabled,
appearance=_effective_appearance_info(session, tenant=tenant, user=user),
),
tenant=active_tenant, tenant=active_tenant,
active_tenant=active_tenant, active_tenant=active_tenant,
tenants=memberships, tenants=memberships,
@@ -945,6 +1012,37 @@ def update_profile(
if payload.ui_preferences is None: if payload.ui_preferences is None:
next_settings["ui"] = UserUiPreferences().model_dump() next_settings["ui"] = UserUiPreferences().model_dump()
else: else:
appearance = _effective_appearance_info(session, tenant=context.tenant, user=context.user)
stored_preferences = _user_ui_preferences(context.user.settings)
stored_palette = stored_preferences.palette
if (
appearance.locked
and payload.ui_preferences.palette is not None
and payload.ui_preferences.palette != stored_palette
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="The effective appearance policy locks the palette.",
)
requested_overrides = payload.ui_preferences.appearance_overrides
if (
"appearance_overrides" in payload.ui_preferences.model_fields_set
and not appearance.custom_overrides_allowed
and requested_overrides is not None
and requested_overrides != stored_preferences.appearance_overrides
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="The effective appearance policy does not allow personal custom overrides.",
)
if (
payload.ui_preferences.navigation is not None
and payload.ui_preferences.navigation.locked
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Personal navigation preferences cannot lock entries.",
)
next_ui = _user_ui_preferences(next_settings).model_dump() next_ui = _user_ui_preferences(next_settings).model_dump()
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True)) next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump() next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
@@ -1002,6 +1100,98 @@ def switch_tenant(
) )
@router.get("/sessions", response_model=AccountSessionListResponse)
def list_own_sessions(
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
session: Session = Depends(get_session),
) -> AccountSessionListResponse:
current = _interactive_session(principal)
items = list_account_sessions(
session,
account_id=principal.account_id,
current_session_id=current.id,
)
return AccountSessionListResponse(
sessions=[_account_session_info(item) for item in items]
)
@router.post(
"/sessions/revoke-others",
response_model=OtherSessionRevocationResponse,
)
def revoke_own_other_sessions(
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
session: Session = Depends(get_session),
) -> OtherSessionRevocationResponse:
current = _interactive_session(principal)
revoked_ids = revoke_other_account_sessions(
session,
account_id=principal.account_id,
current_session_id=current.id,
)
if revoked_ids:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
action="access.sessions.other_sessions_revoked",
object_type="access_account",
object_id=principal.account_id,
details={"revoked_count": len(revoked_ids)},
)
session.commit()
principal_summary_cache.clear()
return OtherSessionRevocationResponse(revoked_count=len(revoked_ids))
@router.post(
"/sessions/{session_id}/revoke",
response_model=AccountSessionRevocationResponse,
)
def revoke_own_session(
session_id: str,
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
session: Session = Depends(get_session),
) -> AccountSessionRevocationResponse:
current = _interactive_session(principal)
try:
item, changed = revoke_account_session(
session,
account_id=principal.account_id,
session_id=session_id,
protected_session_id=current.id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found.",
)
if changed:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
action="access.session.revoked",
object_type="access_auth_session",
object_id=item.id,
details={"actor_kind": "self"},
)
session.commit()
principal_summary_cache.clear()
return AccountSessionRevocationResponse(
session=_account_session_info(
session_summary(item, current_session_id=current.id)
),
revoked=changed,
)
@router.get("/acting-contexts", response_model=ActingContextListResponse) @router.get("/acting-contexts", response_model=ActingContextListResponse)
def list_acting_contexts( def list_acting_contexts(
request: Request, request: Request,
+281 -1
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import asdict
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.admin.governance import ( from govoplan_access.backend.admin.governance import (
@@ -62,6 +64,10 @@ from govoplan_access.backend.api.v1.admin_common import (
_user_item, _user_item,
) )
from govoplan_access.backend.api.v1.admin_schemas import ( from govoplan_access.backend.api.v1.admin_schemas import (
AdminSessionItem,
AdminSessionListResponse,
AdminSessionRevocationRequest,
AdminSessionRevocationResponse,
AdminApiKeyCreateRequest, AdminApiKeyCreateRequest,
AdminApiKeyCreateResponse, AdminApiKeyCreateResponse,
ApiKeyAdminItem, ApiKeyAdminItem,
@@ -120,6 +126,8 @@ from govoplan_access.backend.api.v1.admin_schemas import (
RoleSummary, RoleSummary,
RoleUpdateRequest, RoleUpdateRequest,
ResourceAccessExplanationResponse, ResourceAccessExplanationResponse,
ResourceAccessExplanationSubjectItem,
ResourceAccessExplanationSubjectsResponse,
SystemAccountCreateRequest, SystemAccountCreateRequest,
SystemAccountCreateResponse, SystemAccountCreateResponse,
SystemAccountItem, SystemAccountItem,
@@ -137,6 +145,14 @@ from govoplan_access.backend.api.v1.admin_schemas import (
UserUpdateRequest, UserUpdateRequest,
) )
from govoplan_access.backend.security.api_keys import create_api_key from govoplan_access.backend.security.api_keys import create_api_key
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.session_management import (
SessionSummary,
list_account_sessions,
revoke_account_session,
session_summary,
)
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_event, audit_from_principal from govoplan_core.audit.logging import audit_event, audit_from_principal
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
@@ -152,6 +168,10 @@ from govoplan_core.core.configuration_packages import (
export_configuration_package, export_configuration_package,
validate_configuration_package_catalog, validate_configuration_package_catalog,
) )
from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapabilityReceiptError,
load_infrastructure_capability_receipt,
)
from govoplan_core.core.configuration_control import ( from govoplan_core.core.configuration_control import (
CONFIGURATION_CHANGE_RECORD_RESOURCE, CONFIGURATION_CHANGE_RECORD_RESOURCE,
CONFIGURATION_CHANGE_REQUEST_RESOURCE, CONFIGURATION_CHANGE_REQUEST_RESOURCE,
@@ -169,7 +189,15 @@ from govoplan_core.core.provider_governance import (
ExternalProviderStateContext, ExternalProviderStateContext,
collect_external_provider_states, collect_external_provider_states,
) )
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef from govoplan_core.core.access import (
CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
AccessDecisionProvenance,
AccessExplanationService,
AccessExplanationSubjectDecision,
AccessExplanationSubjectPolicy,
PrincipalRef,
)
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
@@ -656,6 +684,40 @@ def _access_explanation_service_or_error() -> AccessExplanationService:
) )
def _access_explanation_subject_decision(
session: Session,
principal: ApiPrincipal,
*,
tenant_id: str,
) -> AccessExplanationSubjectDecision:
registry = get_registry()
if registry is None or not registry.has_capability(
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
):
return AccessExplanationSubjectDecision(
allow_other_users=False,
reason="Access explanations are limited to the signed-in user because no subject policy is active.",
source="access.safe_default",
provenance={"tenant_id": tenant_id, "mode": "current_user"},
)
capability = registry.require_capability(
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
)
if not isinstance(capability, AccessExplanationSubjectPolicy):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
"Invalid capability: "
f"{CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS}"
),
)
return capability.decide_subject_selection(
session,
principal.principal,
tenant_id=tenant_id,
)
def _idm_assignments_for_user( def _idm_assignments_for_user(
idm_directory: IdmDirectory | None, idm_directory: IdmDirectory | None,
user: User, user: User,
@@ -974,6 +1036,7 @@ def configuration_package_apply_endpoint(
diagnostics=[item.to_dict() for item in result.diagnostics], diagnostics=[item.to_dict() for item in result.diagnostics],
created_refs=dict(result.created_refs), created_refs=dict(result.created_refs),
updated_refs=dict(result.updated_refs), updated_refs=dict(result.updated_refs),
rollback=result.rollback.to_dict() if result.rollback is not None else None,
) )
@@ -994,6 +1057,7 @@ def configuration_package_export_endpoint(
fragments=[item.to_dict() for item in result.fragments], fragments=[item.to_dict() for item in result.fragments],
data_requirements=[item.to_dict() for item in result.data_requirements], data_requirements=[item.to_dict() for item in result.data_requirements],
diagnostics=[item.to_dict() for item in result.diagnostics], diagnostics=[item.to_dict() for item in result.diagnostics],
provenance=result.provenance.to_dict() if result.provenance is not None else None,
) )
@@ -1023,6 +1087,12 @@ def _configuration_context(
capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY} capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY}
external_provider_declarations: dict[str, dict[str, object]] = {} external_provider_declarations: dict[str, dict[str, object]] = {}
external_provider_states: dict[str, dict[str, object]] = {} external_provider_states: dict[str, dict[str, object]] = {}
infrastructure_receipt = None
infrastructure_receipt_error = None
try:
infrastructure_receipt = load_infrastructure_capability_receipt()
except InfrastructureCapabilityReceiptError as exc:
infrastructure_receipt_error = str(exc)
if registry is not None and hasattr(registry, "manifests"): if registry is not None and hasattr(registry, "manifests"):
manifests = registry.manifests() manifests = registry.manifests()
installed_modules = {manifest.id: manifest.version for manifest in manifests} installed_modules = {manifest.id: manifest.version for manifest in manifests}
@@ -1048,11 +1118,14 @@ def _configuration_context(
return ConfigurationPreflightContext( return ConfigurationPreflightContext(
tenant_id=tenant_id or principal.tenant_id, tenant_id=tenant_id or principal.tenant_id,
operator_user_id=principal.user.id, operator_user_id=principal.user.id,
operator_scopes=frozenset(getattr(principal, "scopes", ())),
supplied_data=supplied_data or {}, supplied_data=supplied_data or {},
installed_modules=installed_modules, installed_modules=installed_modules,
capabilities=frozenset(capabilities), capabilities=frozenset(capabilities),
external_provider_declarations=external_provider_declarations, external_provider_declarations=external_provider_declarations,
external_provider_states=external_provider_states, external_provider_states=external_provider_states,
infrastructure_receipt=infrastructure_receipt,
infrastructure_receipt_error=infrastructure_receipt_error,
) )
@@ -2367,6 +2440,188 @@ def get_user_access_explanation(
) )
def _admin_session_item(item: SessionSummary) -> AdminSessionItem:
return AdminSessionItem(**asdict(item))
def _require_session_admin_reauthorization(
principal: ApiPrincipal,
current_password: str,
) -> None:
if principal.auth_session is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Administrative session revocation requires an interactive session.",
)
if not verify_password(current_password, principal.account.password_hash):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Current password re-authorization failed.",
)
@router.get(
"/users/{user_id}/sessions",
response_model=AdminSessionListResponse,
)
def list_user_sessions(
user_id: str,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("admin:users:read", "access:membership:read")
),
) -> AdminSessionListResponse:
tenant = _resolve_tenant(session, principal, tenant_id)
user = (
session.query(User)
.filter(User.id == user_id, User.tenant_id == tenant.id)
.one_or_none()
)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
items = list_account_sessions(
session,
account_id=user.account_id,
tenant_id=tenant.id,
current_session_id=principal.session_id,
)
return AdminSessionListResponse(
sessions=[_admin_session_item(item) for item in items]
)
@router.post(
"/users/{user_id}/sessions/{session_id}/revoke",
response_model=AdminSessionRevocationResponse,
)
def revoke_user_session(
user_id: str,
session_id: str,
payload: AdminSessionRevocationRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("admin:users:update", "access:membership:update")
),
) -> AdminSessionRevocationResponse:
_require_session_admin_reauthorization(principal, payload.current_password)
tenant = _resolve_tenant(session, principal, tenant_id)
user = (
session.query(User)
.filter(User.id == user_id, User.tenant_id == tenant.id)
.one_or_none()
)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
try:
item, changed = revoke_account_session(
session,
account_id=user.account_id,
session_id=session_id,
tenant_id=tenant.id,
protected_session_id=principal.session_id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
if changed:
audit_from_principal(
session,
principal,
action="access.session.revoked_by_administrator",
scope="tenant",
object_type="access_auth_session",
object_id=item.id,
details={"target_membership_id": user.id},
)
session.commit()
principal_summary_cache.clear()
return AdminSessionRevocationResponse(
session=_admin_session_item(
session_summary(item, current_session_id=principal.session_id)
),
revoked=changed,
)
@router.get(
"/access/resource-explanation/subjects",
response_model=ResourceAccessExplanationSubjectsResponse,
)
def get_resource_access_explanation_subjects(
tenant_id: str | None = Query(default=None),
query: str | None = Query(default=None, max_length=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
"admin:users:read",
"admin:roles:read",
"access:membership:read",
"access:role:read",
)
),
) -> ResourceAccessExplanationSubjectsResponse:
tenant = _resolve_tenant(session, principal, tenant_id)
decision = _access_explanation_subject_decision(
session,
principal,
tenant_id=tenant.id,
)
users_query = session.query(User).filter(
User.tenant_id == tenant.id,
User.is_active.is_(True),
)
if not decision.allow_other_users:
users_query = users_query.filter(User.id == principal.membership_id)
elif query and query.strip():
pattern = f"%{query.strip()}%"
users_query = users_query.filter(
or_(User.display_name.ilike(pattern), User.email.ilike(pattern))
)
users = users_query.order_by(User.display_name.asc(), User.email.asc()).limit(100).all()
if decision.allow_other_users and not query:
current_user = (
session.query(User)
.filter(
User.id == principal.membership_id,
User.tenant_id == tenant.id,
User.is_active.is_(True),
)
.one_or_none()
)
if current_user is not None and all(user.id != current_user.id for user in users):
users = [current_user, *users[:99]]
return ResourceAccessExplanationSubjectsResponse(
mode="cross_user" if decision.allow_other_users else "current_user",
can_select_other_users=decision.allow_other_users,
reason=decision.reason,
source=decision.source,
required_scope=decision.required_scope,
users=[
ResourceAccessExplanationSubjectItem(
id=user.id,
email=user.email,
display_name=user.display_name,
)
for user in users
],
)
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse) @router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
def get_resource_access_explanation( def get_resource_access_explanation(
user_id: str = Query(...), user_id: str = Query(...),
@@ -2378,6 +2633,16 @@ def get_resource_access_explanation(
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")), principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
decision = _access_explanation_subject_decision(
session,
principal,
tenant_id=tenant.id,
)
if user_id != principal.membership_id and not decision.allow_other_users:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=decision.reason,
)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none() user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
@@ -2397,6 +2662,21 @@ def get_resource_access_explanation(
resource_id=resource_id, resource_id=resource_id,
action=action, action=action,
) )
if user_id != principal.membership_id:
audit_from_principal(
session,
principal,
action="access.resource_explanation.selected_user_viewed",
scope="tenant",
object_type=resource_type,
object_id=resource_id,
details={
"target_membership_id": user.id,
"requested_action": action,
"policy_source": decision.source,
},
)
session.commit()
return ResourceAccessExplanationResponse( return ResourceAccessExplanationResponse(
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory), user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
resource_type=resource_type, resource_type=resource_type,
@@ -0,0 +1,601 @@
from __future__ import annotations
import hashlib
from collections.abc import Sequence
from datetime import datetime, timezone
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import (
Account,
ApiKey,
AuthSession,
Function,
FunctionAssignment,
Group,
Identity,
IdentityAccountLink,
OrganizationUnit,
Role,
SystemRoleAssignment,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
)
ACCESS_DSAR_CAPABILITY = "privacy.dsar.access"
class AccessDsarProvider:
provider_id = "access"
module_id = "access"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
users = _subject_users(db, tenant_id=tenant_id, subject=subject)
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
def append(record: DsarRecordRef) -> None:
key = (record.resource_type, record.resource_id)
if key not in seen:
seen.add(key)
records.append(record)
for user in users:
append(
_record(
"membership",
user.id,
"profile",
user.display_name or user.email,
{
"account_id": user.account_id,
"email": user.email,
"display_name": user.display_name,
"is_active": user.is_active,
"auth_provider": user.auth_provider,
"last_login_at": _iso(user.last_login_at),
"created_at": _iso(user.created_at),
"updated_at": _iso(user.updated_at),
},
observed_at=user.updated_at,
source_path=f"/admin?section=tenant-users&user={user.id}",
)
)
account = db.get(Account, user.account_id)
if account is not None:
append(
_record(
"account",
account.id,
"global_identity",
account.display_name or account.email,
{
"email": account.email,
"display_name": account.display_name,
"is_active": account.is_active,
"auth_provider": account.auth_provider,
"last_login_at": _iso(account.last_login_at),
"created_at": _iso(account.created_at),
},
observed_at=account.updated_at,
source_path=f"/admin?section=system-users&account={account.id}",
)
)
_append_identity_records(db, append, account)
_append_system_role_records(db, append, account)
_append_api_key_records(db, append, user)
_append_session_records(db, append, user)
_append_group_records(db, append, user)
_append_role_records(db, append, user)
_append_function_records(db, append, user)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del session, subject
actions: list[DsarErasureActionRef] = []
for record in records:
if record.resource_type == "membership":
actions.append(
_action(
f"access:anonymize:membership:{record.resource_id}",
"anonymize",
record,
"Anonymize and deactivate the tenant membership",
"Tenant-local profile data can be removed without deleting stable evidence identifiers.",
executable=True,
irreversible=True,
metadata={"tenant_id": tenant_id},
)
)
elif record.resource_type == "api_key" and record.data.get("active"):
actions.append(
_action(
f"access:revoke:api-key:{record.resource_id}",
"revoke",
record,
"Revoke API key",
"An active credential associated with the data subject must no longer authenticate.",
executable=True,
)
)
elif record.resource_type == "auth_session" and record.data.get("active"):
actions.append(
_action(
f"access:revoke:session:{record.resource_id}",
"revoke",
record,
"Revoke login session",
"An active session associated with the data subject must no longer authenticate.",
executable=True,
)
)
elif record.resource_type in {"account", "identity"}:
actions.append(
_action(
f"access:review:{record.resource_type}:{record.resource_id}",
"manual_review",
record,
f"Review global {record.resource_type}",
"Global identities may serve other tenants or legal obligations and require a system-level decision.",
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del subject
db = _session(session)
now = datetime.now(timezone.utc)
results: list[DsarExecutionResultRef] = []
for action in actions:
if action.action_id.startswith("access:anonymize:membership:"):
row = db.get(User, action.resource_id)
if row is None or row.tenant_id != tenant_id:
results.append(_blocked(action, "Tenant membership is no longer available."))
continue
replacement = _erased_email(tenant_id, row.id)
unchanged = (
row.email == replacement
and row.display_name == "Erased data subject"
and not row.is_active
)
row.email = replacement
row.display_name = "Erased data subject"
row.is_active = False
row.is_tenant_admin = False
row.password_hash = None
row.last_login_at = None
row.settings = {}
row.mail_profile_policy = {}
results.append(
_result(
action,
"unchanged" if unchanged else "executed",
"Tenant membership was already anonymized."
if unchanged
else "Tenant membership was anonymized and deactivated.",
{"request_id": request_id, "replacement_email": replacement},
)
)
elif action.action_id.startswith("access:revoke:api-key:"):
row = db.get(ApiKey, action.resource_id)
if row is None or row.tenant_id != tenant_id:
results.append(_blocked(action, "API key is no longer available."))
continue
unchanged = row.revoked_at is not None
if row.revoked_at is None:
row.revoked_at = now
results.append(
_result(
action,
"unchanged" if unchanged else "executed",
"API key was already revoked." if unchanged else "API key was revoked.",
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
)
)
elif action.action_id.startswith("access:revoke:session:"):
row = db.get(AuthSession, action.resource_id)
if row is None or row.tenant_id != tenant_id:
results.append(_blocked(action, "Login session is no longer available."))
continue
unchanged = row.revoked_at is not None and not row.ip_address and not row.user_agent
if row.revoked_at is None:
row.revoked_at = now
row.ip_address = None
row.user_agent = None
row.csrf_token_hash = None
results.append(
_result(
action,
"unchanged" if unchanged else "executed",
"Login session was already revoked and redacted."
if unchanged
else "Login session was revoked and client metadata was redacted.",
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
)
)
else:
results.append(_blocked(action, "Access does not execute this action kind."))
db.flush()
return tuple(results)
def _subject_users(
session: Session,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> tuple[User, ...]:
candidate_sets: list[set[str]] = []
if subject.membership_id:
candidate_sets.append({
row[0]
for row in session.query(User.id).filter(
User.tenant_id == tenant_id,
User.id == subject.membership_id,
)
})
if subject.account_id:
candidate_sets.append({
row[0]
for row in session.query(User.id).filter(
User.tenant_id == tenant_id,
User.account_id == subject.account_id,
)
})
if subject.identity_id:
account_ids = {
row[0]
for row in session.query(IdentityAccountLink.account_id).filter(
IdentityAccountLink.identity_id == subject.identity_id
)
}
candidate_sets.append(
{
row[0]
for row in session.query(User.id).filter(
User.tenant_id == tenant_id,
User.account_id.in_(account_ids),
)
}
if account_ids
else set()
)
for key, value in subject.external_references.items():
if key in {"access.account", "account_id"}:
candidate_sets.append({
row[0]
for row in session.query(User.id).filter(
User.tenant_id == tenant_id,
User.account_id == value,
)
})
elif key in {"access.membership", "membership_id"}:
candidate_sets.append({
row[0]
for row in session.query(User.id).filter(
User.tenant_id == tenant_id,
User.id == value,
)
})
if subject.email:
normalized = subject.email.strip().casefold()
matching_accounts = {
row[0]
for row in session.query(Account.id).filter(
Account.normalized_email == normalized
)
}
email_matches = {
row[0]
for row in session.query(User.id).filter(
User.tenant_id == tenant_id,
func.lower(User.email) == normalized,
)
}
if matching_accounts:
email_matches.update(
row[0]
for row in session.query(User.id).filter(
User.tenant_id == tenant_id,
User.account_id.in_(matching_accounts),
)
)
candidate_sets.append(email_matches)
if not candidate_sets:
return ()
user_ids = set.intersection(*candidate_sets)
if not user_ids:
return ()
return tuple(
session.query(User)
.filter(User.tenant_id == tenant_id, User.id.in_(user_ids))
.order_by(User.id)
.all()
)
def _append_identity_records(session: Session, append: object, account: Account) -> None:
for link, identity in (
session.query(IdentityAccountLink, Identity)
.join(Identity, Identity.id == IdentityAccountLink.identity_id)
.filter(IdentityAccountLink.account_id == account.id)
.all()
):
append( # type: ignore[operator]
_record(
"identity",
identity.id,
"global_identity",
identity.display_name or identity.id,
{
"display_name": identity.display_name,
"external_subject": identity.external_subject,
"source": identity.source,
"is_active": identity.is_active,
"is_primary_link": link.is_primary,
},
observed_at=identity.updated_at,
source_path=f"/admin?section=system-users&identity={identity.id}",
)
)
def _append_system_role_records(session: Session, append: object, account: Account) -> None:
for assignment, role in (
session.query(SystemRoleAssignment, Role)
.join(Role, Role.id == SystemRoleAssignment.role_id)
.filter(SystemRoleAssignment.account_id == account.id)
.all()
):
append( # type: ignore[operator]
_record(
"system_role_assignment",
assignment.id,
"governance_evidence",
f"System role: {role.name}",
{"role_id": role.id, "role_name": role.name},
observed_at=assignment.updated_at,
immutable=True,
retention_reason="System authorization history is institutional evidence.",
)
)
def _append_api_key_records(session: Session, append: object, user: User) -> None:
for item in session.query(ApiKey).filter(ApiKey.user_id == user.id).all():
append( # type: ignore[operator]
_record(
"api_key",
item.id,
"credential",
item.name,
{
"prefix": item.prefix,
"scopes": list(item.scopes or ()),
"active": item.revoked_at is None,
"expires_at": _iso(item.expires_at),
"last_used_at": _iso(item.last_used_at),
"revoked_at": _iso(item.revoked_at),
},
observed_at=item.updated_at,
source_path="/admin?section=tenant-api-keys",
)
)
def _append_session_records(session: Session, append: object, user: User) -> None:
for item in session.query(AuthSession).filter(AuthSession.user_id == user.id).all():
append( # type: ignore[operator]
_record(
"auth_session",
item.id,
"authentication",
f"Login session {item.id[:8]}",
{
"active": item.revoked_at is None,
"expires_at": _iso(item.expires_at),
"last_seen_at": _iso(item.last_seen_at),
"revoked_at": _iso(item.revoked_at),
},
observed_at=item.updated_at,
)
)
def _append_group_records(session: Session, append: object, user: User) -> None:
for assignment, group in (
session.query(UserGroupMembership, Group)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(UserGroupMembership.user_id == user.id)
.all()
):
append( # type: ignore[operator]
_record(
"group_membership",
assignment.id,
"governance_evidence",
f"Group: {group.name}",
{"group_id": group.id, "group_name": group.name},
observed_at=assignment.updated_at,
immutable=True,
retention_reason="Group assignment history is institutional access evidence.",
)
)
def _append_role_records(session: Session, append: object, user: User) -> None:
for assignment, role in (
session.query(UserRoleAssignment, Role)
.join(Role, Role.id == UserRoleAssignment.role_id)
.filter(UserRoleAssignment.user_id == user.id)
.all()
):
append( # type: ignore[operator]
_record(
"role_assignment",
assignment.id,
"governance_evidence",
f"Role: {role.name}",
{"role_id": role.id, "role_name": role.name},
observed_at=assignment.updated_at,
immutable=True,
retention_reason="Role assignment history is institutional access evidence.",
)
)
def _append_function_records(session: Session, append: object, user: User) -> None:
rows = (
session.query(FunctionAssignment, Function, OrganizationUnit)
.join(Function, Function.id == FunctionAssignment.function_id)
.join(OrganizationUnit, OrganizationUnit.id == FunctionAssignment.organization_unit_id)
.filter(
FunctionAssignment.tenant_id == user.tenant_id,
FunctionAssignment.account_id == user.account_id,
)
.all()
)
for assignment, function, unit in rows:
append( # type: ignore[operator]
_record(
"function_assignment",
assignment.id,
"governance_evidence",
f"{function.name} in {unit.name}",
{
"function_id": function.id,
"function_name": function.name,
"organization_unit_id": unit.id,
"organization_unit_name": unit.name,
"source": assignment.source,
"valid_from": _iso(assignment.valid_from),
"valid_until": _iso(assignment.valid_until),
"is_active": assignment.is_active,
},
observed_at=assignment.updated_at,
immutable=True,
retention_reason="Function incumbency is effective-dated institutional evidence.",
source_path="/admin?section=tenant-function-role-mappings",
)
)
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: dict[str, object],
*,
observed_at: datetime | None = None,
immutable: bool = False,
retention_reason: str | None = None,
source_path: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="access",
module_id="access",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path=source_path,
)
def _action(
action_id: str,
kind: str,
record: DsarRecordRef,
title: str,
rationale: str,
*,
executable: bool,
irreversible: bool = False,
metadata: dict[str, object] | None = None,
) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=action_id,
provider_id="access",
module_id="access",
kind=kind, # type: ignore[arg-type]
resource_type=record.resource_type,
resource_id=record.resource_id,
title=title,
rationale=rationale,
executable=executable,
irreversible=irreversible,
metadata=metadata or {},
)
def _result(
action: DsarErasureActionRef,
result_status: str,
summary: str,
evidence: dict[str, object] | None = None,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status=result_status, # type: ignore[arg-type]
summary=summary,
evidence=evidence or {},
)
def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef:
return _result(action, "blocked", summary)
def _erased_email(tenant_id: str, membership_id: str) -> str:
digest = hashlib.sha256(f"{tenant_id}\0{membership_id}".encode()).hexdigest()[:24]
return f"erased+{digest}@invalid.govoplan"
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Access DSAR provider requires a SQLAlchemy session.")
return value
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
__all__ = ["ACCESS_DSAR_CAPABILITY", "AccessDsarProvider"]
@@ -0,0 +1,326 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'access.reference.admin-access-fields': {'fields': [{'admin_description': 'Auf dem Konto und den '
'Mitglieder-Payloads '
'gespeichert. Es muss '
'normalisiert und '
'eindeutig für das '
'entsprechende '
'Login-Konto sein.',
'api_field': 'email',
'api_path': '/api/v1/admin/users',
'field_id': 'access.user.email',
'label': 'E-Mail',
'permission_scope': 'access:membership:create',
'provenance': 'Mandantenmitgliedschaft oder '
'Kontosuche.',
'user_description': 'Die Adresse, die '
'verwendet wird, um die '
'Person zu '
'identifizieren, wenn '
'sie sich anmelden.',
'validation': 'Muss eine gültige '
'E-Mail-Adresse sein.'},
{'admin_description': 'Wird, sofern verfügbar, '
'in Benutzer- und '
'Kontoantworten als '
'display_name ausgegeben.',
'api_field': 'display_name',
'api_path': '/api/v1/admin/users',
'field_id': 'access.user.display_name',
'label': 'Anzeigename',
'permission_scope': 'access:membership:update',
'provenance': 'Profil der '
'Mandantenmitgliedschaft.',
'user_description': 'Der lesbare Name, der '
'in Benutzerlisten und '
'Bewertungsbildschirmen '
'angezeigt wird.',
'validation': 'Menschenlesbarer Text; Halten '
'Sie ihn für Administratoren '
'erkennbar.'},
{'admin_description': 'Wird beim Aktualisieren '
'einer Benutzer- oder '
'Gruppenmitgliedschaft '
'als group_ids übertragen.',
'api_field': 'group_ids',
'api_path': '/api/v1/admin/users/{user_id}',
'field_id': 'access.user.groups',
'label': 'Gruppen',
'permission_scope': 'access:group:manage_members',
'provenance': 'Benutzergruppenmitgliedschaftszeilen.',
'user_description': 'Gemeinsame Zugriffsbündel, '
'die Rollen für viele '
'Personen gleichzeitig '
'hinzufügen können.',
'validation': 'Gruppen müssen zum gleichen '
'Mandant gehören.'},
{'admin_description': 'Wird bei '
'Aktualisierungsanforderungen '
'für Benutzer- und '
'Gruppenrollen als role_ids '
'übertragen.',
'api_field': 'role_ids',
'api_path': '/api/v1/admin/users/{user_id}',
'field_id': 'access.user.roles',
'label': 'Rollen',
'permission_scope': 'access:role:assign',
'provenance': 'Direkte Benutzerrollen plus '
'Gruppenrollenvererbung.',
'user_description': 'Direktzugangszuschüsse, '
'die einer Person '
'zugewiesen oder von '
'Gruppen geerbt wurden.',
'validation': 'Rollen müssen zuordenbar sein '
'und dürfen das '
'Delegationslimit der '
'handelnden Person nicht '
'überschreiten.'},
{'admin_description': 'Bildet Scopes beim '
'Erstellen eines '
'API-Schlüssel zu und '
'wird mit den aktuellen '
'Berechtigungen des '
'Besitzers geschnitten.',
'api_field': 'scopes',
'api_path': '/api/v1/admin/api-keys',
'field_id': 'access.api_key.scopes',
'label': 'Anwendungsbereiche',
'permission_scope': 'access:api_key:create',
'provenance': 'API-Schlüssel Grant plus '
'Eigentümerdelegation.',
'user_description': 'Die Aktionen, die ein '
'API-Schlüssel ausführen '
'kann.',
'validation': 'Verwenden Sie möglichst enge '
'Berechtigungsbereiche.'}]},
'access.reference.personal-navigation': {'outcome': 'Die Seitenschiene des Benutzers spiegelt die '
'persönlichen Präferenzen wider, während '
'verschlossene und unzugängliche Einträge '
'durch übergeordnete Richtlinien geregelt '
'bleiben.'},
'access.workflow.configuration-packages': {'limitations': ['Die Paketübernahme installiert keine '
'fehlenden Module.',
'Die anbieterübergreifende Übernahme ist keine atomar '
'verteilte Transaktion.',
'Generisches Rollback hängt von einem '
'beibehaltenen '
'vor der Übernahme erstellten Datenbank-Snapshot ab.'],
'operational_consequences': ['Ein abgestandener oder '
'blockierter Preflight '
'muss vor der Anwendung '
'erneut durchgeführt '
'werden.',
'Eine teilweise Anwendung '
'erfordert eine '
'Wiederherstellung, bevor '
'das Paket erneut '
'getestet wird.',
'Geheimwerte bleiben '
'außerhalb tragbarer '
'Fragmente und '
'Herkunft.']},
'access.workflow.data-subject-request': {'limitations': ['Module ohne DSAR-Anbieter werden als '
'Deckungslücken gemeldet.',
'Globale Konten und Identitäten werden '
'nicht automatisch gelöscht.']},
'access.workflow.grant-user-access': {'outcome': 'Eine Person kann sich beim Mandant anmelden und '
'erhält den beabsichtigten Zugang durch Gruppen '
'und Rollen.',
'prerequisites': ['Sie können Admin öffnen.',
'Sie können Benutzer, Gruppen und Rollen '
'lesen.',
'Schreib- oder Zuweisungsaktionen '
'erfordern übereinstimmende '
'Verwaltungsberechtigungen.'],
'result': 'Die Mitgliedschaft hat die beabsichtigten '
'effektiven Berechtigungen und keine breiteren '
'Rollen als nötig.',
'steps': ['Öffnen Sie Admin und gehen Sie zu Benutzern.',
'Finden Sie die bestehende Person oder erstellen '
'Sie eine Mitgliedschaft mit ihrer E-Mail-Adresse '
'und dem Anzeigenamen.',
'Überprüfen Sie aktuelle Gruppen und direkte '
'Rollen, bevor Sie etwas ändern.',
'Fügen Sie die Person der kleinsten Gruppe hinzu, '
'die den erforderlichen gemeinsamen Zugriff '
'gewährt.',
'Weisen Sie direkte Rollen nur zu, wenn eine '
'Gruppe nicht mit dem Fall übereinstimmt.',
'Speichern und überprüfen Sie eine '
'Blockernachricht, bevor Sie einen System- oder '
'Mandantbesitzer um Hilfe bitten.'],
'verification': 'Öffnen Sie den Benutzer erneut und '
'vergleichen Sie Gruppen, direkte Rollen '
'und effektive Berechtigungen mit der '
'Anforderung.'},
'access.workflow.manage-api-keys': {'consequences': ['Der Widerruf lehnt nachfolgende Anfragen, '
'die mit dem Schlüssel gestellt wurden, '
'sofort ab.',
'Durch das Entfernen von Berechtigungen vom '
'Besitzer wird der effektive '
'Schlüsselzugriff sofort eingeschränkt.'],
'limitations': ['Ein einmaliges Geheimnis kann nach dem '
'Schließen des Erstellungsdialogs nicht '
'angezeigt oder wiederhergestellt werden.',
'Ändern des Besitzers, Ablauf oder Scopes '
'erfordert einen Ersatzschlüssel.',
'Der Widerruf aktualisiert keine externen '
'Clients; die Betreiber müssen bei Bedarf '
'einen Ersatz installieren.'],
'outcome': 'Der Automatisierungsclient verfügt über einen '
'zeitlich begrenzten Berechtigungsnachweis, dessen '
'effektiver Zugriff weder seine gespeicherten '
'Berechtigungsbereiche noch die aktuellen '
'Berechtigungen seines Besitzers überschreiten '
'kann.',
'prerequisites': ['Der Mandant erlaubt '
'API-Anmeldeinformationen.',
'Die handelnde Person kann API-Schlüssel '
'erstellen oder widerrufen und jeden '
'ausgewählten Bereich delegieren.',
'Ein zugelassener externer Geheimmanager '
'und rechenschaftspflichtiger Eigentümer '
'sind bekannt.'],
'steps': ['Wählen Sie den verantwortlichen Eigentümer und die '
'engsten erforderlichen Berechtigungsbereiche.',
'Legen Sie den kürzesten praktischen Ablauf fest, '
'bevor Sie den Schlüssel erstellen.',
'Übertragen Sie das einmalige Geheimnis direkt in '
'den genehmigten Geheimmanager.',
'Widerrufen Sie den Schlüssel, wenn sein Client, '
'Eigentümer oder Zweck nicht mehr gültig ist.'],
'verification': 'Laden Sie das Schlüsselverzeichnis neu, '
'überprüfen Sie Eigentümer, Präfix, '
'Berechtigungsumfang, Ablauf und Status und '
'testen Sie dann den beabsichtigten Client, '
'ohne geheimes Material in Nachweise zu '
'kopieren.'},
'access.workflow.manage-reusable-credentials': {'limitations': ['GovOPlaN kann ein konfiguriertes '
'Geheimnis nicht anzeigen oder '
'wiederherstellen.',
'Eine leere Modul- oder '
'Serverbeschränkung bedeutet '
'jeden Wert, der nach '
'Berechtigungsumfang zulässig '
'ist.',
'Das Löschen oder Leeren eines '
'Geheimnisses schreibt keine '
'abhängigen Verbindungsreferenzen '
'neu.'],
'outcome': 'Die Zugangsdaten bleiben '
'schreibgeschützt und sind nur '
'innerhalb seines aktiven '
'Berechtigungsumfangs, Moduls, Servers '
'und Autorisierungsgrenzen verwendbar.',
'prerequisites': ['Der beabsichtigte '
'Berechtigungsinhaber wird '
'ausgewählt.',
'Die handelnde Person kann '
'Anmeldeinformationen lesen und '
'hat Schreibautorität für '
'Mutationen.',
'Der externe '
'Secret-Manager-Eigentümer und '
'abhängige Verbindungen sind '
'bekannt.'],
'steps': ['Wählen Sie den engsten Besitzumfang '
'und Anmeldetyp.',
'Beschränken Sie Module und Server '
'explizit, wenn eine breite Nutzung '
'nicht beabsichtigt ist.',
'Speichern Sie ein neues oder '
'Ersatzgeheimnis, ohne zu erwarten, '
'dass es erneut angezeigt wird.',
'Überprüfen Sie abhängige Verbindungen '
'vor der Deaktivierung, geheimen '
'Löschung oder Löschung.'],
'verification': 'Laden Sie die Liste der '
'Zugangsdaten neu, bestätigen Sie '
'deren Berechtigungsumfang und '
'Verfügbarkeit und testen Sie '
'dann jede beabsichtigte '
'abhängige Verbindung, ohne das '
'Geheimnis zu enthüllen.'},
'access.workflow.manage-service-account-credentials': {'consequences': ['Rotation widerruft den '
'vorherigen Nachweis in '
'der gleichen '
'Transaktion, die seinen '
'Ersatz schafft.',
'Der Widerruf, die '
'Deaktivierung des Kontos '
'und der Ruhestand lehnen '
'betroffene '
'Kundenanfragen sofort '
'ab.',
'Eine veraltete Revision '
'wird abgelehnt, so dass '
'ein gleichzeitiger '
'Verwaltungswechsel nicht '
'überschrieben wird.'],
'limitations': ['Einmalige '
'Anmeldegeheimnisse können '
'nach dem Schließen des '
'Erstellungsdialogs nicht '
'angezeigt oder '
'wiederhergestellt werden.',
'Deaktivierung und eine '
'reduzierte '
'Berechtigungsumfangsobergrenze '
'betreffen Clients sofort, '
'schreiben ihre externe '
'Konfiguration jedoch '
'nicht neu.',
'Der Ruhestand widerruft '
'alle aktiven '
'Anmeldeinformationen und '
'erfordert ein neues '
'Servicekonto für die '
'spätere '
'Wiederverwendung.'],
'outcome': 'Der Automatisierungsprinzipal '
'bleibt nicht interaktiv und '
'kann sich nur durch einen '
'aktiven Berechtigungsnachweis '
'authentifizieren, dessen '
'Gewährung innerhalb der '
'aktuellen '
'Berechtigungsumfangsobergrenze '
'des Kontos liegt.',
'prerequisites': ['Der Mandant erlaubt '
'API-Anmeldeinformationen.',
'Sie haben eine '
'Service-Account-Schreibberechtigung '
'und können jeden '
'ausgewählten Bereich '
'delegieren.'],
'steps': ['Erstellen Sie ein Servicekonto '
'und definieren Sie die engste '
'Nutzumfangsobergrenze.',
'Öffnen Sie das Konto und '
'erstellen Sie einen '
'Berechtigungsnachweis mit einem '
'gleichen oder engeren '
'Berechtigungsumfang.',
'Notieren Sie das einmalige '
'Geheimnis in einem externen '
'Geheimmanager.',
'Anmeldeinformationen vor Ablauf '
'drehen und Anmeldeinformationen '
'widerrufen, die nicht mehr '
'verwendet werden.'],
'verification': 'Die Verwaltungstabelle '
'zeigt die erwartete '
'Anzahl der aktiven '
'Anmeldeinformationen, den '
'Zeitstempel für die '
'letzte Verwendung, die '
'Revision und die '
'Audit-Ereignisse, ohne '
'geheimes Material '
'preiszugeben.'}}
@@ -1,26 +1,186 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment from govoplan_access.backend.db.models import (
Group,
GroupRoleAssignment,
Role,
UserGroupMembership,
UserRoleAssignment,
new_uuid,
)
from govoplan_core.admin.common import AdminConflictError from govoplan_core.admin.common import AdminConflictError
from govoplan_core.core.access import AccessGovernanceMaterializer, GovernanceTemplateMaterialization from govoplan_core.core.access import (
AccessGovernanceMaterializer,
AccessGovernanceProjectionV1,
GovernanceProjectionBatch,
GovernanceProjectionCommand,
GovernanceProjectionOutcome,
GovernanceProjectionResult,
GovernanceTemplateMaterialization,
)
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer): class SqlAccessGovernanceMaterializer(
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None: AccessGovernanceMaterializer,
AccessGovernanceProjectionV1,
):
"""Reconcile governance projections with a constant number of bulk reads."""
def reconcile(
self,
session: object,
batch: GovernanceProjectionBatch,
) -> GovernanceProjectionResult:
db = _session(session) db = _session(session)
if template.kind == "group": commands = tuple(batch.commands)
group = ( group_commands = tuple(item for item in commands if item.template.kind == "group")
db.query(Group) role_commands = tuple(item for item in commands if item.template.kind == "role")
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
.first() groups, duplicate_group_keys = _managed_groups(db, group_commands)
roles, duplicate_role_keys = _managed_roles(db, role_commands)
used_group_slugs = _used_slugs(db, Group, group_commands)
used_role_slugs = _used_slugs(db, Role, role_commands)
group_ids = {item.id for item in groups.values()}
role_ids = {item.id for item in roles.values()}
group_memberships = _assignment_counts(db, UserGroupMembership, UserGroupMembership.group_id, group_ids)
group_role_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.group_id, group_ids)
role_user_links = _assignment_counts(db, UserRoleAssignment, UserRoleAssignment.role_id, role_ids)
role_group_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.role_id, role_ids)
outcomes: list[GovernanceProjectionOutcome] = []
for command in commands:
key = (command.template.tenant_id, command.template.template_id)
if command.template.kind == "group":
outcome = self._reconcile_group(
db,
command,
groups,
duplicate_group_keys,
used_group_slugs,
group_memberships,
group_role_links,
dry_run=batch.dry_run,
)
else:
outcome = self._reconcile_role(
db,
command,
roles,
duplicate_role_keys,
used_role_slugs,
role_user_links,
role_group_links,
dry_run=batch.dry_run,
)
outcomes.append(outcome)
if outcome.status in {"removed", "absent"}:
groups.pop(key, None)
roles.pop(key, None)
if not batch.dry_run:
db.flush()
return GovernanceProjectionResult(
operation_id=batch.operation_id,
outcomes=tuple(outcomes),
dry_run=batch.dry_run,
)
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
self._legacy_reconcile(session, template, operation="upsert")
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
self._legacy_reconcile(session, template, operation="remove")
def _legacy_reconcile(
self,
session: object,
template: GovernanceTemplateMaterialization,
*,
operation: str,
) -> None:
command = GovernanceProjectionCommand(
assignment_id=f"legacy:{template.kind}:{template.template_id}:{template.tenant_id}",
operation=operation, # type: ignore[arg-type]
template=template,
provenance={"contract": "access.governanceMaterializer"},
)
result = self.reconcile(
session,
GovernanceProjectionBatch(
operation_id=command.assignment_id,
commands=(command,),
),
)
if result.blocked:
raise AdminConflictError(result.blocked[0].message or "Governance projection was blocked.")
def _reconcile_group(
self,
db: Session,
command: GovernanceProjectionCommand,
existing: dict[tuple[str, str], Group],
duplicate_keys: set[tuple[str, str]],
used_slugs: dict[str, set[str]],
membership_counts: dict[str, int],
role_counts: dict[str, int],
*,
dry_run: bool,
) -> GovernanceProjectionOutcome:
template = command.template
key = (template.tenant_id, template.template_id)
group = existing.get(key)
if key in duplicate_keys:
return _outcome(
command,
status="failed",
blocker_codes=("duplicate_managed_projection",),
message="Multiple managed groups exist for this template and tenant.",
) )
if command.operation == "remove":
if group is None: if group is None:
return _outcome(command, status="absent")
blockers: list[str] = []
if membership_counts.get(group.id, 0):
blockers.append("group_has_members")
if role_counts.get(group.id, 0):
blockers.append("group_has_roles")
if blockers:
return _outcome(
command,
status="blocked",
resource_id=group.id,
blocker_codes=tuple(blockers),
message=f"Cannot remove {template.name!r} while its managed group has members or roles.",
)
if not dry_run:
try:
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
except AdminConflictError as exc:
return _outcome(
command,
status="blocked",
resource_id=group.id,
blocker_codes=("module_delete_veto",),
message=str(exc),
)
db.delete(group)
return _outcome(command, status="removed", resource_id=group.id)
if group is None:
resource_id = new_uuid()
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
if not dry_run:
group = Group( group = Group(
id=resource_id,
tenant_id=template.tenant_id, tenant_id=template.tenant_id,
slug=_available_slug(db, Group, template.tenant_id, template.slug), slug=slug,
name=template.name, name=template.name,
description=template.description, description=template.description,
is_active=template.is_active, is_active=template.is_active,
@@ -28,78 +188,199 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
system_required=template.required, system_required=template.required,
) )
db.add(group) db.add(group)
else: existing[key] = group
group.name = template.name return _outcome(command, status="created", resource_id=resource_id)
group.description = template.description
group.system_required = template.required
if template.required:
group.is_active = template.is_active
db.flush()
return
role = ( changes = {
db.query(Role) "name": template.name,
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id) "description": template.description,
.first() "system_required": template.required,
) }
if role is None: if template.required:
role = Role( changes["is_active"] = template.is_active
tenant_id=template.tenant_id, changed = any(getattr(group, field) != value for field, value in changes.items())
slug=_available_slug(db, Role, template.tenant_id, template.slug), if changed and not dry_run:
name=template.name, for field, value in changes.items():
description=template.description, setattr(group, field, value)
permissions=list(template.permissions), return _outcome(command, status="updated" if changed else "unchanged", resource_id=group.id)
is_builtin=False,
is_assignable=template.is_active,
system_template_id=template.template_id,
system_required=template.required,
)
db.add(role)
else:
role.name = template.name
role.description = template.description
role.permissions = list(template.permissions)
role.system_required = template.required
if template.required:
role.is_assignable = template.is_active
db.flush()
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None: def _reconcile_role(
db = _session(session) self,
if template.kind == "group": db: Session,
group = ( command: GovernanceProjectionCommand,
db.query(Group) existing: dict[tuple[str, str], Role],
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id) duplicate_keys: set[tuple[str, str]],
.first() used_slugs: dict[str, set[str]],
user_counts: dict[str, int],
group_counts: dict[str, int],
*,
dry_run: bool,
) -> GovernanceProjectionOutcome:
template = command.template
key = (template.tenant_id, template.template_id)
role = existing.get(key)
if key in duplicate_keys:
return _outcome(
command,
status="failed",
blocker_codes=("duplicate_managed_projection",),
message="Multiple managed roles exist for this template and tenant.",
) )
if group is None: if command.operation == "remove":
return if role is None:
membership_count = db.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count() return _outcome(command, status="absent")
role_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count() blockers: list[str] = []
if membership_count or role_count: if user_counts.get(role.id, 0):
raise AdminConflictError( blockers.append("role_has_users")
f"Cannot remove {template.name!r} from the tenant while its managed group has members or roles." if group_counts.get(role.id, 0):
blockers.append("role_has_groups")
if blockers:
return _outcome(
command,
status="blocked",
resource_id=role.id,
blocker_codes=tuple(blockers),
message=f"Cannot remove {template.name!r} while its managed role is assigned to users or groups.",
) )
_run_delete_vetoes(db, "group", template.tenant_id, group.id) if not dry_run:
db.delete(group) db.delete(role)
db.flush() return _outcome(command, status="removed", resource_id=role.id)
return
role = (
db.query(Role)
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
.first()
)
if role is None: if role is None:
return resource_id = new_uuid()
user_count = db.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count() slug = _available_slug(used_slugs[template.tenant_id], template.slug)
group_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count() if not dry_run:
if user_count or group_count: role = Role(
raise AdminConflictError( id=resource_id,
f"Cannot remove {template.name!r} from the tenant while its managed role is assigned to users or groups." tenant_id=template.tenant_id,
) slug=slug,
db.delete(role) name=template.name,
db.flush() description=template.description,
permissions=list(template.permissions),
is_builtin=False,
is_assignable=template.is_active,
system_template_id=template.template_id,
system_required=template.required,
)
db.add(role)
existing[key] = role
return _outcome(command, status="created", resource_id=resource_id)
changes: dict[str, object] = {
"name": template.name,
"description": template.description,
"permissions": list(template.permissions),
"system_required": template.required,
}
if template.required:
changes["is_assignable"] = template.is_active
changed = any(getattr(role, field) != value for field, value in changes.items())
if changed and not dry_run:
for field, value in changes.items():
setattr(role, field, value)
return _outcome(command, status="updated" if changed else "unchanged", resource_id=role.id)
def _managed_groups(
session: Session,
commands: tuple[GovernanceProjectionCommand, ...],
) -> tuple[dict[tuple[str, str], Group], set[tuple[str, str]]]:
if not commands:
return {}, set()
tenants = {item.template.tenant_id for item in commands}
templates = {item.template.template_id for item in commands}
rows = session.query(Group).filter(
Group.tenant_id.in_(tenants),
Group.system_template_id.in_(templates),
).all()
return _indexed_managed(rows)
def _managed_roles(
session: Session,
commands: tuple[GovernanceProjectionCommand, ...],
) -> tuple[dict[tuple[str, str], Role], set[tuple[str, str]]]:
if not commands:
return {}, set()
tenants = {item.template.tenant_id for item in commands}
templates = {item.template.template_id for item in commands}
rows = session.query(Role).filter(
Role.tenant_id.in_(tenants),
Role.system_template_id.in_(templates),
).all()
return _indexed_managed(rows)
def _indexed_managed(rows):
indexed = {}
duplicates = set()
for row in rows:
key = (row.tenant_id, row.system_template_id)
if key in indexed:
duplicates.add(key)
else:
indexed[key] = row
return indexed, duplicates
def _used_slugs(
session: Session,
model: type[Group] | type[Role],
commands: tuple[GovernanceProjectionCommand, ...],
) -> dict[str, set[str]]:
used: dict[str, set[str]] = defaultdict(set)
tenants = {item.template.tenant_id for item in commands}
if tenants:
for tenant_id, slug in session.query(model.tenant_id, model.slug).filter(model.tenant_id.in_(tenants)).all():
used[str(tenant_id)].add(slug)
for tenant_id in tenants:
used[tenant_id]
return used
def _assignment_counts(session: Session, model, column, resource_ids: set[str]) -> dict[str, int]:
if not resource_ids:
return {}
return {
resource_id: count
for resource_id, count in session.query(column, func.count(model.id))
.filter(column.in_(resource_ids))
.group_by(column)
.all()
}
def _available_slug(used: set[str], base: str) -> str:
candidate = base
suffix = 2
while candidate in used:
candidate = f"{base}-{suffix}"
suffix += 1
used.add(candidate)
return candidate
def _outcome(
command: GovernanceProjectionCommand,
*,
status: str,
resource_id: str | None = None,
blocker_codes: tuple[str, ...] = (),
message: str | None = None,
) -> GovernanceProjectionOutcome:
template = command.template
return GovernanceProjectionOutcome(
assignment_id=command.assignment_id,
template_id=template.template_id,
tenant_id=template.tenant_id,
kind=template.kind,
operation=command.operation,
status=status, # type: ignore[arg-type]
resource_id=resource_id,
blocker_codes=blocker_codes,
message=message,
provenance=dict(command.provenance),
)
def _session(session: object) -> Session: def _session(session: object) -> Session:
@@ -119,12 +400,3 @@ def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, res
raise raise
except Exception as exc: except Exception as exc:
raise AdminConflictError(str(exc)) from exc raise AdminConflictError(str(exc)) from exc
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
candidate = base
suffix = 2
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ from govoplan_core.security.time import ensure_aware_utc, utc_now
API_KEY_PREFIX_LENGTH = 12 API_KEY_PREFIX_LENGTH = 12
API_KEY_RANDOM_BYTES = 32 API_KEY_RANDOM_BYTES = 32
API_KEY_SECRET_PREFIX = "gpn_"
@dataclass(slots=True) @dataclass(slots=True)
@@ -28,7 +29,7 @@ def verify_api_key(secret: str, expected_hash: str) -> bool:
def generate_api_key_secret() -> str: def generate_api_key_secret() -> str:
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES) return generate_secret(API_KEY_SECRET_PREFIX, random_bytes=API_KEY_RANDOM_BYTES)
def api_key_prefix(secret: str) -> str: def api_key_prefix(secret: str) -> str:
@@ -0,0 +1,155 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import AuthSession
from govoplan_core.security.time import ensure_aware_utc, utc_now
MAX_SESSION_LIST_ITEMS = 100
MAX_CLIENT_LABEL_LENGTH = 160
@dataclass(frozen=True, slots=True)
class SessionSummary:
id: str
tenant_id: str
current: bool
status: str
created_at: datetime
last_seen_at: datetime | None
expires_at: datetime
revoked_at: datetime | None
client: str | None
def session_summary(
item: AuthSession,
*,
current_session_id: str | None,
now: datetime | None = None,
) -> SessionSummary:
effective_at = now or utc_now()
expires_at = ensure_aware_utc(item.expires_at)
revoked_at = ensure_aware_utc(item.revoked_at)
if revoked_at is not None:
status = "revoked"
elif expires_at is None or expires_at <= effective_at:
status = "expired"
else:
status = "active"
return SessionSummary(
id=item.id,
tenant_id=item.tenant_id,
current=item.id == current_session_id,
status=status,
created_at=item.created_at,
last_seen_at=item.last_seen_at,
expires_at=item.expires_at,
revoked_at=item.revoked_at,
client=_bounded_client(item.user_agent),
)
def list_account_sessions(
session: Session,
*,
account_id: str,
current_session_id: str | None,
tenant_id: str | None = None,
include_inactive: bool = False,
limit: int = MAX_SESSION_LIST_ITEMS,
now: datetime | None = None,
) -> tuple[SessionSummary, ...]:
effective_at = now or utc_now()
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
if tenant_id is not None:
query = query.filter(AuthSession.tenant_id == tenant_id)
rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all()
summaries = tuple(
session_summary(
item,
current_session_id=current_session_id,
now=effective_at,
)
for item in rows
)
if not include_inactive:
summaries = tuple(item for item in summaries if item.status == "active")
return summaries[: max(1, min(limit, MAX_SESSION_LIST_ITEMS))]
def revoke_account_session(
session: Session,
*,
account_id: str,
session_id: str,
tenant_id: str | None = None,
protected_session_id: str | None = None,
now: datetime | None = None,
) -> tuple[AuthSession | None, bool]:
query = session.query(AuthSession).filter(
AuthSession.id == session_id,
AuthSession.account_id == account_id,
)
if tenant_id is not None:
query = query.filter(AuthSession.tenant_id == tenant_id)
item = query.one_or_none()
if item is None:
return None, False
if protected_session_id is not None and item.id == protected_session_id:
raise ValueError("The current session cannot be revoked through session management.")
if item.revoked_at is not None:
return item, False
item.revoked_at = now or utc_now()
session.add(item)
return item, True
def revoke_other_account_sessions(
session: Session,
*,
account_id: str,
current_session_id: str,
now: datetime | None = None,
) -> tuple[str, ...]:
effective_at = now or utc_now()
rows = (
session.query(AuthSession)
.filter(
AuthSession.account_id == account_id,
AuthSession.id != current_session_id,
AuthSession.revoked_at.is_(None),
)
.all()
)
revoked: list[str] = []
for item in rows:
expires_at = ensure_aware_utc(item.expires_at)
if expires_at is None or expires_at <= effective_at:
continue
item.revoked_at = effective_at
session.add(item)
revoked.append(item.id)
return tuple(sorted(revoked))
def _bounded_client(value: str | None) -> str | None:
normalized = " ".join(str(value or "").split())
if not normalized:
return None
return normalized[:MAX_CLIENT_LABEL_LENGTH]
__all__ = [
"MAX_CLIENT_LABEL_LENGTH",
"MAX_SESSION_LIST_ITEMS",
"SessionSummary",
"list_account_sessions",
"revoke_account_session",
"revoke_other_account_sessions",
"session_summary",
]
@@ -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",
]
@@ -5,7 +5,14 @@ from types import SimpleNamespace
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from govoplan_access.backend.api.v1.admin_schemas import (
ConfigurationPackageApplyResponse,
ConfigurationPackageExportResponse,
)
from govoplan_access.backend.api.v1.routes import _configuration_context from govoplan_access.backend.api.v1.routes import _configuration_context
from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapabilityReceiptError,
)
from govoplan_core.core.provider_governance import ( from govoplan_core.core.provider_governance import (
ExternalProviderRuntimeState, ExternalProviderRuntimeState,
ExternalProviderStateProviderRegistration, ExternalProviderStateProviderRegistration,
@@ -13,6 +20,87 @@ from govoplan_core.core.provider_governance import (
class ConfigurationPackageContextTests(unittest.TestCase): class ConfigurationPackageContextTests(unittest.TestCase):
def test_api_responses_preserve_rollback_and_redacted_export_provenance(self) -> None:
applied = ConfigurationPackageApplyResponse(
rollback={
"status": "database_restore_required",
"summary": "Snapshot is the generic rollback boundary.",
"recovery_action": "Retain the snapshot.",
}
)
exported = ConfigurationPackageExportResponse(
provenance={
"exported_at": "2026-08-22T12:00:00+00:00",
"source_core_version": "0.1.35",
"module_versions": {"forms": "0.1.20"},
"tenant_id": "tenant-1",
"exporter_id": "user-1",
"selection": {
"scopes": ["tenant"],
"module_ids": ["forms"],
"object_refs": [],
},
"redacted_secret_keys": ["credential_ref"],
}
)
self.assertEqual(
"database_restore_required",
applied.model_dump()["rollback"]["status"],
)
self.assertEqual(
["credential_ref"],
exported.model_dump()["provenance"]["redacted_secret_keys"],
)
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
receipt = SimpleNamespace(installation_id="deployment-1")
principal = SimpleNamespace(
tenant_id="tenant-1",
user=SimpleNamespace(id="user-1"),
scopes=frozenset({"system:settings:write"}),
)
with (
patch(
"govoplan_access.backend.api.v1.routes.get_registry",
return_value=None,
),
patch(
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
return_value=receipt,
),
):
context = _configuration_context(principal)
self.assertIs(receipt, context.infrastructure_receipt)
self.assertEqual(
frozenset({"system:settings:write"}),
context.operator_scopes,
)
def test_context_preserves_invalid_receipt_as_fail_closed_provider_state(self) -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",
user=SimpleNamespace(id="user-1"),
scopes=frozenset(),
)
with (
patch(
"govoplan_access.backend.api.v1.routes.get_registry",
return_value=None,
),
patch(
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
side_effect=InfrastructureCapabilityReceiptError("invalid receipt"),
),
):
context = _configuration_context(principal)
self.assertIsNone(context.infrastructure_receipt)
self.assertEqual("invalid receipt", context.infrastructure_receipt_error)
def test_context_projects_installed_external_provider_declarations(self) -> None: def test_context_projects_installed_external_provider_declarations(self) -> None:
declaration = SimpleNamespace( declaration = SimpleNamespace(
id="connectors.example", id="connectors.example",
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
from govoplan_access.backend.dsar_provider import AccessDsarProvider
from govoplan_core.core.dsar import DsarSubjectRef
class AccessDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
AccessBase.metadata.create_all(bind=self.engine)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
self.account = Account(
id="account-1",
email="ada@example.test",
normalized_email="ada@example.test",
display_name="Ada Example",
password_hash="secret-hash",
)
self.user = User(
id="membership-1",
tenant_id="tenant-1",
account_id=self.account.id,
email="ada@example.test",
display_name="Ada Example",
password_hash="tenant-secret-hash",
settings={"locale": "de"},
mail_profile_policy={"profile": "one"},
)
self.key = ApiKey(
id="key-1",
tenant_id="tenant-1",
user_id=self.user.id,
name="Automation",
prefix="gpn_example",
key_hash="do-not-export",
scopes=["files:read"],
)
self.auth_session = AuthSession(
id="session-1",
tenant_id="tenant-1",
user_id=self.user.id,
account_id=self.account.id,
token_hash="do-not-export",
csrf_token_hash="do-not-export",
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
user_agent="Browser fingerprint",
ip_address="192.0.2.10",
)
self.session.add_all([self.account, self.user, self.key, self.auth_session])
self.session.commit()
self.provider = AccessDsarProvider()
def tearDown(self) -> None:
self.session.close()
AccessBase.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_search_omits_secret_and_client_fingerprint_material(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="ADA@example.test"),
)
serialized = repr([record.to_dict() for record in records])
self.assertIn("membership-1", serialized)
self.assertNotIn("do-not-export", serialized)
self.assertNotIn("Browser fingerprint", serialized)
self.assertNotIn("192.0.2.10", serialized)
def test_multiple_subject_selectors_must_identify_the_same_membership(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id=self.user.id,
email="different@example.test",
),
)
self.assertEqual((), records)
def test_plan_and_execution_anonymize_membership_and_revoke_credentials(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id=self.account.id),
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id=self.account.id),
records=records,
)
executable = tuple(action for action in actions if action.executable)
self.assertEqual(3, len(executable))
self.assertTrue(any(action.kind == "manual_review" for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id=self.account.id),
actions=executable,
request_id="dsar-1",
)
self.assertEqual({"executed"}, {result.status for result in results})
self.assertTrue(self.user.email.endswith("@invalid.govoplan"))
self.assertFalse(self.user.is_active)
self.assertEqual({}, self.user.settings)
self.assertIsNotNone(self.key.revoked_at)
self.assertIsNotNone(self.auth_session.revoked_at)
self.assertIsNone(self.auth_session.user_agent)
self.assertIsNone(self.auth_session.ip_address)
self.assertEqual("ada@example.test", self.account.email)
repeated = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id=self.account.id),
actions=executable,
request_id="dsar-1",
)
self.assertEqual({"unchanged"}, {result.status for result in repeated})
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, Role, User, UserRoleAssignment
from govoplan_access.backend.governance_materializer import SqlAccessGovernanceMaterializer
from govoplan_core.core.access import (
GovernanceProjectionBatch,
GovernanceProjectionCommand,
GovernanceTemplateMaterialization,
)
from govoplan_core.db.base import Base
def _command(index: int, *, kind: str = "role", operation: str = "upsert") -> GovernanceProjectionCommand:
return GovernanceProjectionCommand(
assignment_id=f"assignment-{kind}-{index}",
operation=operation, # type: ignore[arg-type]
template=GovernanceTemplateMaterialization(
template_id=f"template-{kind}",
kind=kind, # type: ignore[arg-type]
tenant_id=f"tenant-{index}",
slug=f"managed-{kind}",
name=f"Managed {kind}",
permissions=("access:role:read",) if kind == "role" else (),
required=True,
),
provenance={"source": "test", "assignment_mode": "required"},
)
class GovernanceProjectionTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.materializer = SqlAccessGovernanceMaterializer()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_bulk_projection_is_idempotent_and_returns_per_assignment_outcomes(self) -> None:
commands = tuple(_command(index) for index in range(5))
first = self.materializer.reconcile(
self.session,
GovernanceProjectionBatch(operation_id="first", commands=commands),
)
second = self.materializer.reconcile(
self.session,
GovernanceProjectionBatch(operation_id="second", commands=commands),
)
self.assertEqual(["created"] * 5, [item.status for item in first.outcomes])
self.assertEqual(["unchanged"] * 5, [item.status for item in second.outcomes])
self.assertEqual(5, self.session.query(Role).count())
self.assertEqual(
{item.assignment_id for item in commands},
{item.assignment_id for item in second.outcomes},
)
self.assertTrue(all(item.provenance["source"] == "test" for item in second.outcomes))
def test_removal_isolated_blocker_preserves_other_batch_outcomes(self) -> None:
first, second = _command(1), _command(2)
created = self.materializer.reconcile(
self.session,
GovernanceProjectionBatch(operation_id="create", commands=(first, second)),
)
roles = {item.tenant_id: item.resource_id for item in created.outcomes}
account = Account(
id="account-1",
email="assigned@example.test",
normalized_email="assigned@example.test",
)
user = User(
id="user-1",
tenant_id="tenant-1",
account_id=account.id,
email=account.email,
)
self.session.add_all([account, user])
self.session.flush()
self.session.add(
UserRoleAssignment(
tenant_id="tenant-1",
user_id=user.id,
role_id=roles["tenant-1"],
)
)
self.session.flush()
removals = tuple(
GovernanceProjectionCommand(
assignment_id=item.assignment_id,
operation="remove",
template=item.template,
provenance=item.provenance,
)
for item in (first, second)
)
result = self.materializer.reconcile(
self.session,
GovernanceProjectionBatch(operation_id="remove", commands=removals),
)
self.assertEqual(["blocked", "removed"], [item.status for item in result.outcomes])
self.assertEqual(("role_has_users",), result.outcomes[0].blocker_codes)
self.assertIsNotNone(self.session.get(Role, roles["tenant-1"]))
self.assertIsNone(self.session.get(Role, roles["tenant-2"]))
def test_dry_run_does_not_mutate(self) -> None:
result = self.materializer.reconcile(
self.session,
GovernanceProjectionBatch(
operation_id="preview",
commands=(_command(1, kind="group"),),
dry_run=True,
),
)
self.assertEqual("created", result.outcomes[0].status)
self.assertEqual(0, self.session.query(Group).count())
def test_bulk_read_query_count_does_not_grow_per_assignment(self) -> None:
def select_count(size: int) -> int:
count = 0
def record_select(_conn, _cursor, statement, _parameters, _context, _executemany):
nonlocal count
if statement.lstrip().upper().startswith("SELECT"):
count += 1
event.listen(self.engine, "before_cursor_execute", record_select)
try:
self.materializer.reconcile(
self.session,
GovernanceProjectionBatch(
operation_id=f"preview-{size}",
commands=tuple(
_command(index, kind="group" if index % 2 else "role")
for index in range(size)
),
dry_run=True,
),
)
finally:
event.remove(self.engine, "before_cursor_execute", record_select)
return count
small = select_count(2)
large = select_count(200)
self.assertEqual(small, large)
self.assertLessEqual(large, 4)
if __name__ == "__main__":
unittest.main()
+83 -4
View File
@@ -6,6 +6,18 @@ from govoplan_access.backend.manifest import manifest
class InterfaceDocumentationContractTests(unittest.TestCase): class InterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual(
{"title", "summary", "body"},
set(german),
topic.id,
)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_access_admin_topics_publish_stable_help_contexts(self) -> None: def test_access_admin_topics_publish_stable_help_contexts(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation} topics = {topic.id: topic for topic in manifest.documentation}
@@ -22,9 +34,29 @@ 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.admin.system-credentials",
"access.admin.tenant-credentials",
"access.admin.group-credentials",
"access.admin.user-credentials",
"access.settings.credentials",
"access.credentials", "access.credentials",
"access.credentials.field.secret",
"access.credentials.field.clear-secret",
"access.credentials.field.inherit-to-lower-scopes",
"access.credentials.action.delete",
"access.credentials.confirm-delete",
}, },
"access.reference.external-function-role-mappings": { "access.reference.external-function-role-mappings": {
"access.admin.function-mappings", "access.admin.function-mappings",
@@ -32,6 +64,22 @@ 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.settings.sessions",
"access.sessions.action.revoke",
"access.sessions.action.revoke-others",
"access.admin.user-sessions",
}, },
} }
@@ -43,10 +91,40 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
topic_id, topic_id,
) )
credential_topic = topics["access.workflow.manage-reusable-credentials"]
self.assertEqual(
{"title", "summary", "body"},
set(credential_topic.translations["de"]),
)
self.assertIn(
"nicht rückgängig gemacht",
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
}
self.assertTrue( self.assertTrue(
{ {
"access.admin.system-roles", "access.admin.system-roles",
@@ -62,6 +140,7 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
"access.admin.group-credentials", "access.admin.group-credentials",
"access.admin.user-credentials", "access.admin.user-credentials",
"access.settings.credentials", "access.settings.credentials",
"access.settings.sessions",
}.issubset(surface_ids) }.issubset(surface_ids)
) )
+14 -1
View File
@@ -5,17 +5,30 @@ import pathlib
import tomllib import tomllib
import unittest import unittest
from govoplan_access.backend.api.v1.admin_schemas import (
SystemSettingsItem,
TenantCreateRequest,
)
ROOT = pathlib.Path(__file__).resolve().parents[1] ROOT = pathlib.Path(__file__).resolve().parents[1]
class OptionalTenancyContractTests(unittest.TestCase): class OptionalTenancyContractTests(unittest.TestCase):
def test_compatibility_admin_schemas_use_german_reference_default(self) -> None:
tenant = TenantCreateRequest(slug="example", name="Example")
self.assertEqual("de", tenant.default_locale)
self.assertEqual("de", SystemSettingsItem().default_locale)
def test_access_package_does_not_require_tenancy_to_install(self) -> None: def test_access_package_does_not_require_tenancy_to_install(self) -> None:
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.11", dependencies) self.assertTrue(
any(item.startswith("govoplan-core>=") for item in dependencies)
)
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies) self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
@@ -0,0 +1,109 @@
from __future__ import annotations
import pathlib
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import (
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
AccessExplanationSubjectDecision,
PrincipalRef,
)
from govoplan_access.backend.api.v1.routes import (
_access_explanation_subject_decision,
)
ROOT = pathlib.Path(__file__).resolve().parents[1]
def _principal() -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
class _SubjectPolicy:
def decide_subject_selection(
self,
session: object,
principal: PrincipalRef,
*,
tenant_id: str,
) -> AccessExplanationSubjectDecision:
del session, principal, tenant_id
return AccessExplanationSubjectDecision(
allow_other_users=True,
reason="Permitted by test policy.",
source="test.policy",
)
class _Registry:
def __init__(self, provider: object | None = None) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return (
name == CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
and self.provider is not None
)
def require_capability(self, name: str) -> object:
if not self.has_capability(name):
raise KeyError(name)
return self.provider
class ResourceAccessExplanationSubjectTests(unittest.TestCase):
def test_missing_policy_defaults_to_current_user(self) -> None:
with patch(
"govoplan_access.backend.api.v1.routes.get_registry",
return_value=None,
):
decision = _access_explanation_subject_decision(
object(), # type: ignore[arg-type]
_principal(),
tenant_id="tenant-1",
)
self.assertFalse(decision.allow_other_users)
self.assertEqual("access.safe_default", decision.source)
def test_policy_capability_controls_cross_user_selection(self) -> None:
with patch(
"govoplan_access.backend.api.v1.routes.get_registry",
return_value=_Registry(_SubjectPolicy()),
):
decision = _access_explanation_subject_decision(
object(), # type: ignore[arg-type]
_principal(),
tenant_id="tenant-1",
)
self.assertTrue(decision.allow_other_users)
self.assertEqual("test.policy", decision.source)
def test_route_contract_is_tenant_bounded_and_audited(self) -> None:
source = (
ROOT / "src/govoplan_access/backend/api/v1/routes.py"
).read_text(encoding="utf-8")
self.assertIn('User.tenant_id == tenant.id', source)
self.assertIn('User.id == principal.membership_id', source)
self.assertIn(
'action="access.resource_explanation.selected_user_viewed"',
source,
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -225,7 +225,7 @@ class ServiceAccountTests(unittest.TestCase):
expires_at=None, expires_at=None,
) )
self.assertEqual(2, item.revision) self.assertEqual(2, item.revision)
self.assertTrue(first.secret.startswith("mm_")) self.assertTrue(first.secret.startswith("gpn_"))
self.assertNotEqual(first.secret, first.model.key_hash) self.assertNotEqual(first.secret, first.model.key_hash)
item, previous, replacement = rotate_service_account_credential( item, previous, replacement = rotate_service_account_credential(
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import Account, AuthSession, User
from govoplan_access.backend.security.sessions import authenticate_session_token, hash_session_token
from govoplan_access.backend.security.passwords import hash_password
from govoplan_access.backend.api.v1.admin_schemas import AdminSessionItem
from govoplan_access.backend.api.v1.auth import AccountSessionInfo
from govoplan_access.backend.api.v1.routes import _require_session_admin_reauthorization
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from fastapi import HTTPException
from govoplan_access.backend.session_management import (
MAX_CLIENT_LABEL_LENGTH,
list_account_sessions,
revoke_account_session,
revoke_other_account_sessions,
)
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
class SessionManagementTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
AccessBase.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.now = datetime(2026, 8, 19, 20, 0, tzinfo=timezone.utc)
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
other_tenant = Tenant(id="tenant-2", slug="tenant-2", name="Tenant 2")
self.account = Account(
id="account-1",
email="person@example.test",
normalized_email="person@example.test",
)
other_account = Account(
id="account-2",
email="other@example.test",
normalized_email="other@example.test",
)
user = User(
id="user-1",
tenant_id=tenant.id,
account_id=self.account.id,
email=self.account.email,
)
other_user = User(
id="user-2",
tenant_id=tenant.id,
account_id=other_account.id,
email=other_account.email,
)
self.session.add_all((tenant, other_tenant, self.account, other_account, user, other_user))
self.session.flush()
self.tokens = {
"current": "ms_current",
"other": "ms_other",
"expired": "ms_expired",
"revoked": "ms_revoked",
"other-account": "ms_other_account",
}
self.session.add_all(
(
self._auth_session("current", "tenant-1", "user-1", "account-1"),
self._auth_session("other", "tenant-2", "user-1", "account-1"),
self._auth_session("expired", "tenant-1", "user-1", "account-1", expires=-1),
self._auth_session("revoked", "tenant-1", "user-1", "account-1", revoked=True),
self._auth_session("other-account", "tenant-1", "user-2", "account-2"),
)
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
AccessBase.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _auth_session(
self,
name: str,
tenant_id: str,
user_id: str,
account_id: str,
*,
expires: int = 2,
revoked: bool = False,
) -> AuthSession:
return AuthSession(
id=f"session-{name}",
tenant_id=tenant_id,
user_id=user_id,
account_id=account_id,
token_hash=hash_session_token(self.tokens[name]),
expires_at=self.now + timedelta(hours=expires),
last_seen_at=self.now - timedelta(minutes=5),
revoked_at=self.now - timedelta(minutes=1) if revoked else None,
user_agent="Browser " + ("x" * 500),
ip_address="192.0.2.55",
)
def test_listing_is_account_scoped_bounded_and_redacted(self) -> None:
sensitive = {
"token",
"token_hash",
"csrf_token_hash",
"cookie",
"ip_address",
}
self.assertTrue(sensitive.isdisjoint(AccountSessionInfo.model_fields))
self.assertTrue(sensitive.isdisjoint(AdminSessionItem.model_fields))
active = list_account_sessions(
self.session,
account_id=self.account.id,
current_session_id="session-current",
now=self.now,
)
self.assertEqual({"session-current", "session-other"}, {item.id for item in active})
self.assertTrue(next(item for item in active if item.id == "session-current").current)
self.assertTrue(all(len(item.client or "") <= MAX_CLIENT_LABEL_LENGTH for item in active))
self.assertNotIn("192.0.2.55", repr(active))
self.assertNotIn("token_hash", repr(active))
all_states = list_account_sessions(
self.session,
account_id=self.account.id,
current_session_id="session-current",
include_inactive=True,
now=self.now,
)
self.assertEqual(
{"active", "expired", "revoked"},
{item.status for item in all_states},
)
tenant_only = list_account_sessions(
self.session,
account_id=self.account.id,
current_session_id="session-current",
tenant_id="tenant-1",
include_inactive=True,
now=self.now,
)
self.assertNotIn("session-other", {item.id for item in tenant_only})
def test_single_revocation_is_idempotent_and_effective_on_next_request(self) -> None:
item, changed = revoke_account_session(
self.session,
account_id=self.account.id,
session_id="session-other",
protected_session_id="session-current",
now=self.now,
)
self.assertTrue(changed)
self.session.commit()
self.assertIsNotNone(item)
self.assertIsNone(
authenticate_session_token(self.session, self.tokens["other"])
)
repeated, changed = revoke_account_session(
self.session,
account_id=self.account.id,
session_id="session-other",
protected_session_id="session-current",
now=self.now,
)
self.assertIs(item, repeated)
self.assertFalse(changed)
hidden, changed = revoke_account_session(
self.session,
account_id=self.account.id,
session_id="session-other-account",
now=self.now,
)
self.assertIsNone(hidden)
self.assertFalse(changed)
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
with self.assertRaisesRegex(ValueError, "current session"):
revoke_account_session(
self.session,
account_id=self.account.id,
session_id="session-current",
protected_session_id="session-current",
now=self.now,
)
revoked = revoke_other_account_sessions(
self.session,
account_id=self.account.id,
current_session_id="session-current",
now=self.now,
)
self.assertEqual(("session-other",), revoked)
self.assertIsNone(self.session.get(AuthSession, "session-current").revoked_at)
self.assertIsNone(self.session.get(AuthSession, "session-expired").revoked_at)
self.assertEqual(
(),
revoke_other_account_sessions(
self.session,
account_id=self.account.id,
current_session_id="session-current",
now=self.now,
),
)
def test_administrative_revocation_requires_session_and_current_password(self) -> None:
self.account.password_hash = hash_password("correct horse")
membership = self.session.get(User, "user-1")
current = self.session.get(AuthSession, "session-current")
principal_ref = PrincipalRef(
account_id=self.account.id,
membership_id=membership.id,
tenant_id=membership.tenant_id,
scopes=frozenset({"access:membership:update"}),
auth_method="session",
session_id=current.id,
)
without_session = ApiPrincipal(
principal=principal_ref,
account=self.account,
user=membership,
)
with self.assertRaises(HTTPException) as missing:
_require_session_admin_reauthorization(without_session, "correct horse")
self.assertEqual(403, missing.exception.status_code)
principal = ApiPrincipal(
principal=principal_ref,
account=self.account,
user=membership,
auth_session=current,
)
with self.assertRaises(HTTPException) as incorrect:
_require_session_admin_reauthorization(principal, "incorrect")
self.assertEqual(403, incorrect.exception.status_code)
_require_session_admin_reauthorization(principal, "correct horse")
if __name__ == "__main__":
unittest.main()
+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}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.15", "version": "0.1.24",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -16,7 +16,7 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.15", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
@@ -18,6 +18,7 @@ const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx");
const files = read("src/features/admin/FileConnectorsPanel.tsx"); const files = read("src/features/admin/FileConnectorsPanel.tsx");
const mail = read("src/features/admin/MailProfilesPanel.tsx"); const mail = read("src/features/admin/MailProfilesPanel.tsx");
const moduleSource = read("src/module.ts"); const moduleSource = read("src/module.ts");
const sessions = read("src/features/sessions/SessionSettingsPanel.tsx");
const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings]; const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings];
const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n"); const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n");
@@ -48,7 +49,47 @@ 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, /"settings\.sections": accessSettingsSections/);
assert.match(sessions, /PageActionBar/);
assert.match(sessions, /reloadAction/);
assert.match(sessions, /destructiveActions/);
assert.match(sessions, /DataGrid/);
assert.match(sessions, /ConfirmDialog/);
assert.doesNotMatch(sessions, /window\.(alert|confirm|prompt)\s*\(/);
assert.match(users, /fetchAdminUserSessions/);
assert.match(users, /revokeAdminUserSession/);
assert.match(users, /admin-user-sessions-v1/);
assert.match(users, /PasswordField/);
assert.match(users, /canRevokeSessions/);
assert.match(moduleSource, /translations,/); assert.match(moduleSource, /translations,/);
assert.match(moduleSource, /version: "0\.1\.11"/); assert.match(moduleSource, /version: "0\.1\.11"/);
+69
View File
@@ -0,0 +1,69 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type AccountSession = {
id: string;
tenant_id: string;
current: boolean;
status: "active" | "expired" | "revoked";
created_at: string;
last_seen_at?: string | null;
expires_at: string;
revoked_at?: string | null;
client?: string | null;
};
export type AccountSessionList = {
sessions: AccountSession[];
};
export function fetchAccountSessions(
settings: ApiSettings
): Promise<AccountSessionList> {
return apiFetch<AccountSessionList>(settings, "/api/v1/auth/sessions", {
cache: "no-store"
});
}
export function revokeAccountSession(
settings: ApiSettings,
sessionId: string
): Promise<{ session: AccountSession; revoked: boolean }> {
return apiFetch(settings, `/api/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, {
method: "POST"
});
}
export function revokeOtherAccountSessions(
settings: ApiSettings
): Promise<{ revoked_count: number }> {
return apiFetch(settings, "/api/v1/auth/sessions/revoke-others", {
method: "POST"
});
}
export function fetchAdminUserSessions(
settings: ApiSettings,
userId: string
): Promise<AccountSessionList> {
return apiFetch(
settings,
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions`,
{ cache: "no-store" }
);
}
export function revokeAdminUserSession(
settings: ApiSettings,
userId: string,
sessionId: string,
currentPassword: string
): Promise<{ session: AccountSession; revoked: boolean }> {
return apiFetch(
settings,
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`,
{
method: "POST",
body: JSON.stringify({ current_password: currentPassword })
}
);
}
+25 -13
View File
@@ -12,6 +12,7 @@ import type {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { fetchShellAuth } from "@govoplan/core-webui"; import { fetchShellAuth } from "@govoplan/core-webui";
import { ActionBlockerHint } from "@govoplan/core-webui"; import { ActionBlockerHint } from "@govoplan/core-webui";
import { PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
import { PageScrollViewport } from "@govoplan/core-webui"; import { PageScrollViewport } from "@govoplan/core-webui";
import { import {
TreeSubnav, TreeSubnav,
@@ -322,15 +323,27 @@ export default function AdminPage({
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection }; const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
return ( return (
<div className="workspace module-workspace"> <WorkspaceLayout
<TreeSubnav className="module-workspace"
active={active} primary={(
nodes={adminTree} <TreeSubnav
onSelect={selectSection} active={active}
ariaLabel="i18n:govoplan-access.admin.4e7afebc" nodes={adminTree}
/> onSelect={selectSection}
<section className="workspace-content"> ariaLabel="i18n:govoplan-access.admin.4e7afebc"
<div className="content-pad workspace-data-page"> />
)}
primaryLabel="i18n:govoplan-access.admin.4e7afebc"
contentLabel="i18n:govoplan-access.admin.4e7afebc"
documentationType="admin"
>
<PageLayout
archetype="workspace"
title="i18n:govoplan-access.admin.4e7afebc"
mode="workspace"
showHeader={false}
documentationType="admin"
>
{contributedSection && contributedSection.render(contributionContext)} {contributedSection && contributedSection.render(contributionContext)}
{!contributedSection && active === "system-mail-servers" && ( {!contributedSection && active === "system-mail-servers" && (
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} /> <MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
@@ -354,7 +367,7 @@ export default function AdminPage({
/> />
)} )}
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} canRevokeSessions={hasAnyScope(auth, ["admin:users:update", "access:membership:update"])} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
@@ -368,9 +381,8 @@ export default function AdminPage({
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />} {!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />} {!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />} {!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
</div> </PageLayout>
</section> </WorkspaceLayout>
</div>
); );
} }
+21 -20
View File
@@ -1,6 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Search, Trash2 } from "lucide-react"; import { Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin"; import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -102,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]);
@@ -152,33 +153,33 @@ 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 open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy} 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></>}>
<div className="admin-form-grid two-columns"> <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>
</div> </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 open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <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 && <><dl className="admin-details-grid"> {viewing && <><DescriptionList>
<div><dt>i18n:govoplan-access.name.709a2322</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.prefix.90eceb01</dt><dd>{viewing.prefix}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.name.709a2322</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.prefix.90eceb01</>}>{viewing.prefix}</DescriptionItem>
<div><dt>i18n:govoplan-access.owner.89ff3122</dt><dd>{viewing.user_email}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</dd></div> <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>
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.last_used.f1109d3d</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_used.f1109d3d</>}>{formatDateTime(viewing.last_used_at)}</DescriptionItem>
<div><dt>i18n:govoplan-access.expires.a99be3da</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</dd></div><div><dt>i18n:govoplan-access.revoked.85f17ac0</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.expires.a99be3da</>}>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.revoked.85f17ac0</>}>{formatDateTime(viewing.revoked_at)}</DescriptionItem>
</dl><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 open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}> <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()} />
</>); </>);
} }
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui"; import type { FormGrid, ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
import { import {
createExternalFunctionRoleMapping, createExternalFunctionRoleMapping,
deleteExternalFunctionRoleMapping, deleteExternalFunctionRoleMapping,
@@ -295,11 +295,11 @@ export default function ExternalFunctionRoleMappingsPanel({
</div> </div>
</AdminPageLayout> </AdminPageLayout>
<Dialog <Dialog variant="administration" size="large"
open={editing !== null} open={editing !== null}
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"} title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
onClose={() => !busy && closeEditor()} onClose={() => !busy && closeEditor()}
className="admin-dialog" className=""
footer={ footer={
<> <>
<Button onClick={closeEditor} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button> <Button onClick={closeEditor} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button>
@@ -309,7 +309,7 @@ export default function ExternalFunctionRoleMappingsPanel({
</> </>
} }
> >
<div className="admin-form-grid"> <FormGrid columns={1} gap="small" collapseAt="workspace" className="">
<FormField label="i18n:govoplan-access.function_id.e5e08937"> <FormField label="i18n:govoplan-access.function_id.e5e08937">
{functionPicker.renderPicker({ {functionPicker.renderPicker({
settings, settings,
@@ -327,7 +327,7 @@ export default function ExternalFunctionRoleMappingsPanel({
))} ))}
</select> </select>
</FormField> </FormField>
</div> </FormGrid>
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p> <p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
</Dialog> </Dialog>
+14 -13
View File
@@ -1,6 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin"; import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -149,28 +150,28 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div> <div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
</AdminPageLayout> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} 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: editing === "new" ? canDefine : canDefine || canManageMembers || canAssignRoles, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}> <Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} 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: editing === "new" ? canDefine : canDefine || canManageMembers || canAssignRoles, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>} {editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
<div className="admin-form-grid two-columns"> <FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} 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> <FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} 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>
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div> </FormGrid>
<div className="admin-assignment-grid"> <ContentGrid columns={2} spacing="block" collapseAt="wide">
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div> <div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div> <div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
</div> </ContentGrid>
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p> <p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"> {viewing && <DescriptionList>
<div><dt>i18n:govoplan-access.group.171a0606</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.group.171a0606</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.management.63cecca6</dt><dd>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.management.63cecca6</>}>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</DescriptionItem>
<div><dt>i18n:govoplan-access.members.1cb449c1</dt><dd>{viewing.member_count}</dd></div><div><dt>i18n:govoplan-access.roles.47dcc27d</dt><dd>{joinLabels(viewing.roles)}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.members.1cb449c1</>}>{viewing.member_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.roles.47dcc27d</>}>{joinLabels(viewing.roles)}</DescriptionItem>
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.updated.f2f8570d</>}>{formatDateTime(viewing.updated_at)}</DescriptionItem>
</dl>} </DescriptionList>}
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} /> <ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
+11 -10
View File
@@ -1,6 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin"; import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -138,25 +139,25 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div> <div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
</AdminPageLayout> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} 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: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}> <Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} 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: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
<div className="admin-form-grid two-columns"> <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"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>} {editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
</div> </FormGrid>
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => { <div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
const scopes = items.map((permission) => permission.scope); const scopes = items.map((permission) => permission.scope);
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>; return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
})}</div> })}</div>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <><dl className="admin-details-grid"> {viewing && <><DescriptionList>
<div><dt>i18n:govoplan-access.role.c3f104d1</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.role.c3f104d1</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
<div><dt>i18n:govoplan-access.type.3deb7456</dt><dd>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.type.3deb7456</>}>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem>
<div><dt>i18n:govoplan-access.user_assignments.bc7cc801</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.group_assignments.e534bb56</dt><dd>{viewing.group_assignments}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.user_assignments.bc7cc801</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.group_assignments.e534bb56</>}>{viewing.group_assignments}</DescriptionItem>
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>} </DescriptionList><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} /> <ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
@@ -1,3 +1,4 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
KeyRound, KeyRound,
@@ -8,7 +9,7 @@ import {
ShieldOff, ShieldOff,
Trash2 Trash2
} from "lucide-react"; } from "lucide-react";
import { import { FormGrid, ActionToolbar,
AdminIconButton, AdminIconButton,
AdminPageLayout, AdminPageLayout,
AdminSelectionList, AdminSelectionList,
@@ -213,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) }
]} /> ]} />
} }
], []); ], []);
@@ -273,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,
@@ -283,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,
@@ -444,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">
@@ -455,46 +462,50 @@ export default function ServiceAccountsPanel({
</div> </div>
</AdminPageLayout> </AdminPageLayout>
<Dialog <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="admin-dialog admin-dialog-wide" 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></>}
> >
<div className="admin-form-grid two-columns"> <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>
</div> </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>
</Dialog> </Dialog>
<Dialog <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="admin-dialog admin-dialog-wide" className=""
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>} footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
> >
{managing && <> {managing && <>
<div className="metric-grid compact"> <MetricGrid density="compact">
<MetricCard label="Status" value={managing.is_active ? "Active" : "Inactive"} tone={managing.is_active ? "good" : "warning"} /> <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="Active credentials" value={managing.active_credential_count} />
<MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} /> <MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} />
<MetricCard label="Revision" value={managing.revision} /> <MetricCard label="Revision" value={managing.revision} />
</div> </MetricGrid>
<div 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} />
</div> </ActionToolbar>
<div 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} />
</div> </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." />
</div> </div>
@@ -502,30 +513,32 @@ export default function ServiceAccountsPanel({
</>} </>}
</Dialog> </Dialog>
<Dialog <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="admin-dialog admin-dialog-wide" 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>}
<div className="admin-form-grid two-columns"> <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>
</div> </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 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>}> <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()} />
</> </>
); );
} }
@@ -1,6 +1,7 @@
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react"; import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
import { import {
createSystemRole, createSystemRole,
deleteSystemRole, deleteSystemRole,
@@ -248,19 +249,19 @@ export default function SystemRolesPanel({
</div> </div>
</AdminPageLayout> </AdminPageLayout>
<Dialog <Dialog variant="administration" size="wide"
open={editing !== null} open={editing !== null}
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"} title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
onClose={() => !busy && setEditing(null)} onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide" className=""
footer={<><Button onClick={() => setEditing(null)} 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: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}> footer={<><Button onClick={() => setEditing(null)} 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: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
<div className="admin-form-grid two-columns"> <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"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField> <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div> </FormGrid>
<div className="form-field"> <div className="form-field">
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span> <span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
<AdminSelectionList <AdminSelectionList
@@ -272,8 +273,8 @@ export default function SystemRolesPanel({
</div> </div>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog variant="administration" size="wide" open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-access.protected.28531336</dt><dd>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.account_assignments.f5a91f2a</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>i18n:govoplan-access.assigned_scopes.c7b09b12</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>} {viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.protected.28531336</>}>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.account_assignments.f5a91f2a</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.description.55f8ebc8</>}>{viewing.description || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{viewing.effective_permission_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assigned_scopes.c7b09b12</>}>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</DescriptionItem></DescriptionList>}
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} /> <ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
+13 -10
View File
@@ -1,6 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react"; import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui"; import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -233,13 +234,15 @@ export default function SystemUsersPanel({
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div> <div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
</AdminPageLayout> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} 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: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}> <Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} 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: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
<div className="admin-form-grid two-columns"> <FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<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.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> <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" && {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 <PasswordField
helpContextId="access.admin.system-users.initial-password"
helpModuleId="access"
value={draft.password} value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8" placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password" autoComplete="new-password"
@@ -248,20 +251,20 @@ export default function SystemUsersPanel({
</FormField> </FormField>
} }
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.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> <FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.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>
</div> </FormGrid>
<div className="admin-assignment-grid"> <ContentGrid columns={2} spacing="block" collapseAt="wide">
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div> <div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div> <div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
</div> </ContentGrid>
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>} {editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p> <p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{viewing.email}</dd></div><div><dt>i18n:govoplan-access.display_name.c7874aaa</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>i18n:govoplan-access.system_roles.a9461aa6</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>i18n:govoplan-access.tenants.1f7ae776</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>} {viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.display_name.c7874aaa</>}>{viewing.display_name || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.system_roles.a9461aa6</>}>{joinLabels(viewing.roles)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.tenants.1f7ae776</>}>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</DescriptionItem></DescriptionList>}
</Dialog> </Dialog>
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}> <Dialog variant="administration" size="large" open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>} {temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
</Dialog> </Dialog>
+125 -36
View File
@@ -1,6 +1,7 @@
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin"; import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
@@ -13,6 +14,11 @@ import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows"; import { loadDeltaRows } from "./utils/deltaRows";
import {
fetchAdminUserSessions,
revokeAdminUserSession,
type AccountSession
} from "../../api/sessions";
import { import {
ACCESS_INTERFACE_I18N, ACCESS_INTERFACE_I18N,
ACCESS_WORKFLOW_DOCUMENTATION, ACCESS_WORKFLOW_DOCUMENTATION,
@@ -29,7 +35,7 @@ const emptyDraft = {
roleIds: [] as string[] roleIds: [] as string[]
}; };
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, canRevokeSessions, onAuthRefresh
@@ -38,7 +44,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) { }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;canRevokeSessions: boolean;onAuthRefresh: () => Promise<void>;}) {
const [users, setUsers] = useState<UserAdminItem[]>([]); const [users, setUsers] = useState<UserAdminItem[]>([]);
const [groups, setGroups] = useState<GroupSummary[]>([]); const [groups, setGroups] = useState<GroupSummary[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]); const [roles, setRoles] = useState<RoleSummary[]>([]);
@@ -52,6 +58,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null); const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false); const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null); const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
const [sessionUser, setSessionUser] = useState<UserAdminItem | null>(null);
const [accountSessions, setAccountSessions] = useState<AccountSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionError, setSessionError] = useState("");
const [revokingSession, setRevokingSession] = useState<AccountSession | null>(null);
const [reauthorizationPassword, setReauthorizationPassword] = useState("");
const [draft, setDraft] = useState(emptyDraft); const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null); const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
@@ -185,6 +197,67 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
} }
} }
async function loadUserSessions(user: UserAdminItem) {
setSessionsLoading(true);
setSessionError("");
try {
const response = await fetchAdminUserSessions(settings, user.id);
setAccountSessions(response.sessions);
} catch (err) {
setSessionError(adminErrorMessage(err));
} finally {
setSessionsLoading(false);
}
}
function openUserSessions(user: UserAdminItem) {
setSessionUser(user);
setAccountSessions([]);
setRevokingSession(null);
setReauthorizationPassword("");
void loadUserSessions(user);
}
async function revokeSelectedSession() {
if (!sessionUser || !revokingSession || !reauthorizationPassword) return;
setBusy(true);
setSessionError("");
try {
await revokeAdminUserSession(
settings,
sessionUser.id,
revokingSession.id,
reauthorizationPassword
);
setSuccess("i18n:govoplan-access.session_revoked.5e551008");
setRevokingSession(null);
setReauthorizationPassword("");
await loadUserSessions(sessionUser);
} catch (err) {
setSessionError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
const sessionColumns = useMemo<DataGridColumn<AccountSession>[]>(() => [
{ id: "client", header: "i18n:govoplan-access.device_or_client.5e551002", width: "minmax(220px, 1fr)", fill: true, value: (row) => row.client || "", render: (row) => <div><strong>{row.current ? "i18n:govoplan-access.current_session.5e551003" : "i18n:govoplan-access.other_session.5e551004"}</strong><div className="muted small-note">{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}</div></div> },
{ id: "last_seen", header: "i18n:govoplan-access.last_seen.5e551006", width: 180, value: (row) => row.last_seen_at || "", render: (row) => formatDateTime(row.last_seen_at) },
{ id: "created", header: "i18n:govoplan-access.created.accf40c8", width: 180, value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, value: (row) => row.expires_at, render: (row) => formatDateTime(row.expires_at) },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 96, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{
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,
onClick: () => { setRevokingSession(row); setReauthorizationPassword(""); setSessionError(""); }
}]} /> }
], [busy, canRevokeSessions]);
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [ const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> }, { id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) }, { id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
@@ -194,11 +267,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) }, { id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[ { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) }, { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
{ id: "sessions", label: i18nMessage("i18n:govoplan-access.inspect_sessions_for_value.5e551017", { value0: row.email }), icon: <MonitorSmartphone />, onClick: () => openUserSessions(row) },
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) }, { id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), disabledReason: !(canUpdate || canSuspend || canManageGroups || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) }, { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), disabledReason: !(canUpdate || canSuspend || canManageGroups || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.is_last_active_owner ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) } { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.is_last_active_owner ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
]} /> }], ]} /> }],
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]); [canAssignRoles, canManageGroups, canRevokeSessions, canSuspend, canUpdate, settings]);
return ( return (
<> <>
@@ -206,13 +280,15 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div> <div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
</AdminPageLayout> </AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} 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: editing === "new" ? canCreate : canUpdate || canSuspend || canManageGroups || canAssignRoles, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}> <Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} 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: editing === "new" ? canCreate : canUpdate || canSuspend || canManageGroups || canAssignRoles, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
<div className="admin-form-grid two-columns"> <FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<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.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> <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" && {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 <PasswordField
helpContextId="access.admin.tenant-users.initial-password"
helpModuleId="access"
value={draft.password} value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8" placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password" autoComplete="new-password"
@@ -221,36 +297,52 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
</FormField> </FormField>
} }
<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> <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>
</div> </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>} {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>}
<div className="admin-assignment-grid"> <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> <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>
<div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div> <div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
</div> </ContentGrid>
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p> <p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
</Dialog> </Dialog>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"> {viewing && <DescriptionList>
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>i18n:govoplan-access.email.84add5b2</dt><dd>{viewing.email}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{viewing.display_name || viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.email.84add5b2</>}>{viewing.email}</DescriptionItem>
<div><dt>i18n:govoplan-access.membership.53bc9670</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.global_account.e1b00cf5</dt><dd>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.membership.53bc9670</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.global_account.e1b00cf5</>}>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem>
<div><dt>i18n:govoplan-access.groups.ae9629f4</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>i18n:govoplan-access.direct_roles.c4db7156</dt><dd>{joinLabels(viewing.roles)}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.groups.ae9629f4</>}>{joinLabels(viewing.groups)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.direct_roles.c4db7156</>}>{joinLabels(viewing.roles)}</DescriptionItem>
<div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem>
</dl>} </DescriptionList>}
</Dialog> </Dialog>
<Dialog open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading} disabledReason={accessExplanationLoading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.close.bbfa773e</Button>}> <Dialog variant="administration" size="wide" open={Boolean(sessionUser && !revokingSession)} title="i18n:govoplan-access.user_sessions.5e551018" onClose={() => !busy && setSessionUser(null)} className="" footer={<><Button onClick={() => sessionUser && void loadUserSessions(sessionUser)} disabled={sessionsLoading || busy} disabledReason={sessionsLoading || busy ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><Button onClick={() => setSessionUser(null)} disabled={busy}>i18n:govoplan-access.close.bbfa773e</Button></>}>
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
{sessionUser && <>
<p className="muted small-note">{sessionUser.display_name || sessionUser.email} · {sessionUser.email}</p>
<div className="admin-table-surface"><DataGrid id="admin-user-sessions-v1" rows={accountSessions} columns={sessionColumns} initialFit="container" getRowKey={(row) => row.id} loading={sessionsLoading} emptyText="i18n:govoplan-access.no_active_sessions.5e551013" /></div>
</>}
</Dialog>
<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" 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>
<Dialog variant="administration" size="wide" open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading} disabledReason={accessExplanationLoading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.close.bbfa773e</Button>}>
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>} {accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
{accessExplanation && <> {accessExplanation && <>
<dl className="admin-details-grid"> <DescriptionList>
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{accessExplanation.user.display_name || accessExplanation.user.email}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{accessExplanation.user.display_name || accessExplanation.user.email}</DescriptionItem>
<div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{accessExplanation.user.account_id}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{accessExplanation.user.account_id}</DescriptionItem>
<div><dt>i18n:govoplan-access.role_sources.6f42a672</dt><dd>{accessExplanation.role_sources.length}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.role_sources.6f42a672</>}>{accessExplanation.role_sources.length}</DescriptionItem>
<div><dt>i18n:govoplan-access.function_facts.848b32cc</dt><dd>{accessExplanation.function_facts.length}</dd></div> <DescriptionItem term={<>i18n:govoplan-access.function_facts.848b32cc</>}>{accessExplanation.function_facts.length}</DescriptionItem>
</dl> </DescriptionList>
<h3>i18n:govoplan-access.role_sources.6f42a672</h3> <h3>i18n:govoplan-access.role_sources.6f42a672</h3>
{accessExplanation.role_sources.length ? <div className="admin-assignment-grid"> {accessExplanation.role_sources.length ? <ContentGrid columns={2} spacing="block" collapseAt="wide">
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}> {accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
<strong>{source.role_name}</strong> <strong>{source.role_name}</strong>
<div className="muted small-note"> <div className="muted small-note">
@@ -262,19 +354,16 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} /> <AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div> <div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
</div>)} </div>)}
</div> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>} </ContentGrid> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
<h3>i18n:govoplan-access.function_facts.848b32cc</h3> <h3>i18n:govoplan-access.function_facts.848b32cc</h3>
{accessExplanation.function_facts.length ? <dl className="admin-details-grid"> {accessExplanation.function_facts.length ? <DescriptionList>
{accessExplanation.function_facts.map((fact) => <div key={fact.assignment_id}> {accessExplanation.function_facts.map((fact) => <DescriptionItem key={fact.assignment_id} term={fact.function_name || fact.function_id}>
<dt>{fact.function_name || fact.function_id}</dt>
<dd>
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br /> <span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br /> <span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)} <span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} /> <AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
</dd> </DescriptionItem>)}
</div>)} </DescriptionList> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
</dl> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3> <h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
<div className="admin-scope-list"> <div className="admin-scope-list">
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)} {accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
@@ -282,7 +371,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
</>} </>}
</Dialog> </Dialog>
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}> <Dialog variant="administration" size="large" open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>} {temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
</Dialog> </Dialog>
@@ -0,0 +1,269 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
Card,
ConfirmDialog,
ContentGrid,
DataGrid,
DismissibleAlert,
PageActionBar,
StatusBadge,
TableActionGroup,
formatAdminDateTime,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
fetchAccountSessions,
revokeAccountSession,
revokeOtherAccountSessions,
type AccountSession
} from "../../api/sessions";
export default function SessionSettingsPanel({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const [sessions, setSessions] = useState<AccountSession[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [revoking, setRevoking] = useState<AccountSession | null>(null);
const [revokingOthers, setRevokingOthers] = useState(false);
const interactive = auth.principal?.auth_method === "session";
async function load() {
if (!interactive) {
setSessions([]);
setLoading(false);
return;
}
setLoading(true);
setError("");
try {
const response = await fetchAccountSessions(settings);
setSessions(response.sessions);
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [
auth.principal?.session_id,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
const columns = useMemo<DataGridColumn<AccountSession>[]>(
() => [
{
id: "client",
header: "i18n:govoplan-access.device_or_client.5e551002",
width: "minmax(220px, 1fr)",
minWidth: 180,
fill: true,
sortable: true,
filterable: true,
value: (row) => row.client || "",
render: (row) => (
<div>
<strong>
{row.current
? "i18n:govoplan-access.current_session.5e551003"
: "i18n:govoplan-access.other_session.5e551004"}
</strong>
<div className="muted small-note">
{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}
</div>
</div>
)
},
{
id: "status",
header: "i18n:govoplan-access.status.bae7d5be",
width: 120,
value: (row) => row.status,
render: (row) => <StatusBadge status={row.status} />
},
{
id: "last_seen",
header: "i18n:govoplan-access.last_seen.5e551006",
width: 180,
sortable: true,
value: (row) => row.last_seen_at || "",
render: (row) => formatAdminDateTime(row.last_seen_at)
},
{
id: "created",
header: "i18n:govoplan-access.created.accf40c8",
width: 180,
sortable: true,
value: (row) => row.created_at,
render: (row) => formatAdminDateTime(row.created_at)
},
{
id: "expires",
header: "i18n:govoplan-access.expires.a99be3da",
width: 180,
sortable: true,
value: (row) => row.expires_at,
render: (row) => formatAdminDateTime(row.expires_at)
},
{
id: "actions",
header: "i18n:govoplan-access.actions.c3cd636a",
width: 96,
sticky: "end",
align: "right",
render: (row) => (
<TableActionGroup
actions={[
{
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
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
: undefined,
onClick: () => setRevoking(row)
}
]}
/>
)
}
],
[busy]
);
async function revokeOne() {
if (!revoking) return;
setBusy(true);
setError("");
try {
await revokeAccountSession(settings, revoking.id);
setRevoking(null);
setSuccess("i18n:govoplan-access.session_revoked.5e551008");
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setBusy(false);
}
}
async function revokeOthers() {
setBusy(true);
setError("");
try {
const response = await revokeOtherAccountSessions(settings);
setRevokingOthers(false);
setSuccess(
response.revoked_count
? "i18n:govoplan-access.other_sessions_revoked.5e551009"
: "i18n:govoplan-access.no_other_active_sessions.5e551010"
);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setBusy(false);
}
}
if (!interactive) {
return (
<ContentGrid columns={1} collapseAt="workspace" className="">
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
<p>i18n:govoplan-access.browser_session_required.5e551011</p>
</Card>
</ContentGrid>
);
}
return (
<ContentGrid columns={1} collapseAt="workspace" className="">
<PageActionBar
variant="detail"
actionScope="workspace"
refreshable
reloadAction={{
onReload: () => void load(),
loading,
disabledReason: loading
? "i18n:govoplan-access.administration_data_is_loading.4af2c001"
: undefined
}}
destructiveActions={
<Button
variant="danger"
helpContextId="access.sessions.action.revoke-others"
helpModuleId="access"
disabled={busy || sessions.filter((item) => !item.current).length === 0}
disabledReason={
busy
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
: sessions.filter((item) => !item.current).length === 0
? "i18n:govoplan-access.no_other_active_sessions.5e551010"
: undefined
}
onClick={() => setRevokingOthers(true)}
>
i18n:govoplan-access.revoke_all_other_sessions.5e551012
</Button>
}
/>
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
<div className="admin-table-surface">
<DataGrid
id="personal-sessions-v1"
rows={sessions}
columns={columns}
initialFit="container"
getRowKey={(row) => row.id}
emptyText="i18n:govoplan-access.no_active_sessions.5e551013"
/>
</div>
</Card>
<ConfirmDialog
open={Boolean(revoking)}
title="i18n:govoplan-access.revoke_session.5e551007"
message="i18n:govoplan-access.revoke_session_confirmation.5e551014"
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
tone="danger"
busy={busy}
helpContextId="access.sessions.action.revoke"
helpModuleId="access"
onCancel={() => setRevoking(null)}
onConfirm={() => void revokeOne()}
/>
<ConfirmDialog
open={revokingOthers}
title="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
message="i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015"
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()}
/>
</ContentGrid>
);
}
+44 -2
View File
@@ -2,6 +2,27 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-access.sessions_and_devices.5e551001": "Sessions and devices",
"i18n:govoplan-access.device_or_client.5e551002": "Device or client",
"i18n:govoplan-access.current_session.5e551003": "Current session",
"i18n:govoplan-access.other_session.5e551004": "Other session",
"i18n:govoplan-access.client_details_unavailable.5e551005": "Client details unavailable",
"i18n:govoplan-access.last_seen.5e551006": "Last seen",
"i18n:govoplan-access.revoke_session.5e551007": "Revoke session",
"i18n:govoplan-access.session_revoked.5e551008": "Session revoked.",
"i18n:govoplan-access.other_sessions_revoked.5e551009": "All other active sessions were revoked.",
"i18n:govoplan-access.no_other_active_sessions.5e551010": "There are no other active sessions.",
"i18n:govoplan-access.browser_session_required.5e551011": "Session management is available only from an interactive browser session.",
"i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Revoke all other sessions",
"i18n:govoplan-access.no_active_sessions.5e551013": "No active sessions were found.",
"i18n:govoplan-access.revoke_session_confirmation.5e551014": "This device or client will lose access on its next authenticated request. The current session remains active.",
"i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Revoke every other active session for this account? This current session remains active.",
"i18n:govoplan-access.session_revocation_permission_required.5e551016": "Membership update permission is required to revoke sessions.",
"i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Inspect sessions for {value0}",
"i18n:govoplan-access.user_sessions.5e551018": "User sessions",
"i18n:govoplan-access.current_password_required.5e551019": "Enter your current password to continue.",
"i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Re-authorize this administrative action with your current password. The selected session will lose access on its next authenticated request.",
"i18n:govoplan-access.current_password.5e551021": "Current password",
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administration data is loading.", "i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administration data is loading.",
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Create permission is required for this action.", "i18n:govoplan-access.create_permission_is_required.4af2c002": "Create permission is required for this action.",
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Update or assignment permission is required for this action.", "i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Update or assignment permission is required for this action.",
@@ -286,7 +307,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.slug.094da9b9": "Slug", "i18n:govoplan-access.slug.094da9b9": "Slug",
"i18n:govoplan-access.source_module.62b7241c": "Source module", "i18n:govoplan-access.source_module.62b7241c": "Source module",
"i18n:govoplan-access.status.bae7d5be": "Status", "i18n:govoplan-access.status.bae7d5be": "Status",
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.", "i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. GovOPlaN retains only the displayed prefix and a verification hash.",
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant", "i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.", "i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}", "i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
@@ -382,6 +403,27 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access." "i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
}, },
"de": { "de": {
"i18n:govoplan-access.sessions_and_devices.5e551001": "Sitzungen und Geräte",
"i18n:govoplan-access.device_or_client.5e551002": "Gerät oder Client",
"i18n:govoplan-access.current_session.5e551003": "Aktuelle Sitzung",
"i18n:govoplan-access.other_session.5e551004": "Andere Sitzung",
"i18n:govoplan-access.client_details_unavailable.5e551005": "Keine Clientdetails verfügbar",
"i18n:govoplan-access.last_seen.5e551006": "Zuletzt aktiv",
"i18n:govoplan-access.revoke_session.5e551007": "Sitzung widerrufen",
"i18n:govoplan-access.session_revoked.5e551008": "Sitzung wurde widerrufen.",
"i18n:govoplan-access.other_sessions_revoked.5e551009": "Alle anderen aktiven Sitzungen wurden widerrufen.",
"i18n:govoplan-access.no_other_active_sessions.5e551010": "Es gibt keine anderen aktiven Sitzungen.",
"i18n:govoplan-access.browser_session_required.5e551011": "Die Sitzungsverwaltung ist nur in einer interaktiven Browsersitzung verfügbar.",
"i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Alle anderen Sitzungen widerrufen",
"i18n:govoplan-access.no_active_sessions.5e551013": "Es wurden keine aktiven Sitzungen gefunden.",
"i18n:govoplan-access.revoke_session_confirmation.5e551014": "Dieses Gerät oder dieser Client verliert beim nächsten authentifizierten Aufruf den Zugriff. Die aktuelle Sitzung bleibt aktiv.",
"i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Alle anderen aktiven Sitzungen dieses Kontos widerrufen? Diese aktuelle Sitzung bleibt aktiv.",
"i18n:govoplan-access.session_revocation_permission_required.5e551016": "Zum Widerrufen von Sitzungen ist die Berechtigung zum Ändern von Mitgliedschaften erforderlich.",
"i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Sitzungen von {value0} prüfen",
"i18n:govoplan-access.user_sessions.5e551018": "Benutzersitzungen",
"i18n:govoplan-access.current_password_required.5e551019": "Geben Sie Ihr aktuelles Passwort ein, um fortzufahren.",
"i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Autorisieren Sie diese administrative Aktion erneut mit Ihrem aktuellen Passwort. Die ausgewählte Sitzung verliert beim nächsten authentifizierten Aufruf den Zugriff.",
"i18n:govoplan-access.current_password.5e551021": "Aktuelles Passwort",
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administrationsdaten werden geladen.", "i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administrationsdaten werden geladen.",
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Für diese Aktion ist die Berechtigung zum Erstellen erforderlich.", "i18n:govoplan-access.create_permission_is_required.4af2c002": "Für diese Aktion ist die Berechtigung zum Erstellen erforderlich.",
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Für diese Aktion ist eine Berechtigung zum Ändern oder Zuweisen erforderlich.", "i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Für diese Aktion ist eine Berechtigung zum Ändern oder Zuweisen erforderlich.",
@@ -666,7 +708,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.slug.094da9b9": "Slug", "i18n:govoplan-access.slug.094da9b9": "Slug",
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul", "i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
"i18n:govoplan-access.status.bae7d5be": "Status", "i18n:govoplan-access.status.bae7d5be": "Status",
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.", "i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Speichern Sie ihn in einem Secret-Manager. GovOPlaN speichert nur das angezeigte Präfix und einen Prüfhash.",
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant", "i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.", "i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}", "i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
+1
View File
@@ -1,6 +1,7 @@
export { default } from "./module"; export { default } from "./module";
export * from "./module"; export * from "./module";
export * from "./api/admin"; export * from "./api/admin";
export * from "./api/sessions";
export { default as AdminPage } from "./features/admin/AdminPage"; export { default as AdminPage } from "./features/admin/AdminPage";
export { ResourceAccessExplanation } from "@govoplan/core-webui"; export { ResourceAccessExplanation } from "@govoplan/core-webui";
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui"; export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
+20 -3
View File
@@ -1,10 +1,11 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui"; import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
import { adminReadScopes } from "@govoplan/core-webui"; import { adminReadScopes } from "@govoplan/core-webui";
import ActingContextSelector from "./features/acting-context/ActingContextSelector"; import ActingContextSelector from "./features/acting-context/ActingContextSelector";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
const AdminPage = lazy(() => import("./features/admin/AdminPage")); const AdminPage = lazy(() => import("./features/admin/AdminPage"));
const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel"));
const translations = { const translations = {
en: generatedTranslations.en, en: generatedTranslations.en,
@@ -24,9 +25,24 @@ const accessAdminSurfaces = [
{ id: "access.admin.tenant-service-accounts", moduleId: "access", kind: "section" as const, label: "Service accounts", order: 90 }, { id: "access.admin.tenant-service-accounts", moduleId: "access", kind: "section" as const, label: "Service accounts", order: 90 },
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 }, { id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 },
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 }, { id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 },
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 } { id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 },
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 }
]; ];
const accessSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "sessions",
surfaceId: "access.settings.sessions",
label: "i18n:govoplan-access.sessions_and_devices.5e551001",
group: "account",
order: 20,
allOf: ["access:session:manage_own"],
render: ({ settings, auth }) => createElement(SessionSettingsPanel, { settings, auth })
}
]
};
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) { function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
if (!onAuthChange) { if (!onAuthChange) {
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f"); throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
@@ -46,7 +62,8 @@ export const accessModule: PlatformWebModule = {
routes: [ routes: [
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }], { path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }],
uiCapabilities: { uiCapabilities: {
"access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability "access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability,
"settings.sections": accessSettingsSections
} }
}; };