feat: add access module boundary migrations

This commit is contained in:
2026-07-10 12:51:16 +02:00
parent 37828fe340
commit 04681f1d75
41 changed files with 7229 additions and 738 deletions

View File

@@ -6,8 +6,10 @@ administration.
The repository contains the extracted access seed implementation under
`src/govoplan_access/backend`. Session, API-key, and password helper services,
interactive auth routes, FastAPI auth dependencies, and the legacy
administration router are owned here. Access-side admin service helpers remain
interactive auth routes, and administration routers are owned here. The public
FastAPI request dependency API is exported from `govoplan_access.auth`; modules
must not import the backend dependency module directly. Access-side admin
service helpers remain
here for users, groups, roles, system accounts, sessions, API keys, tenant
access enforcement, admin/audit lookup capabilities, tenant owner
provisioning, and governance-template materialization into access-owned groups
@@ -16,13 +18,16 @@ transitional administration WebUI route shell and
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
system administration panels are contributed by `@govoplan/admin-webui` through
core's `admin.sections` UI capability. Live access ORM models are defined here
while retaining their historical table names; tenant records live in
`govoplan-tenancy`, governance templates in `govoplan-admin`, audit logs in
`govoplan-audit`, and system settings in `govoplan-core`. The staged
extraction path is documented in:
with `access_*` table names; tenant records live in `govoplan-tenancy`,
governance templates in `govoplan-admin`, audit logs in `govoplan-audit`, and
system settings in `govoplan-core`. The current access boundary is documented
in:
- `/mnt/DATA/git/govoplan-core/docs/ACCESS_EXTRACTION_PLAN.md`
- `/mnt/DATA/git/govoplan-core/docs/ACCESS_RBAC_MODEL.md`
- `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`
- `docs/ACCESS_MODULE_BOUNDARY.md`
- `docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md`
- `docs/OPENDESK_IDENTITY_BOUNDARY.md`
## Initial Ownership
@@ -33,13 +38,16 @@ This module will own:
- API keys
- legacy administration route contribution during the transition
- tenant-local users
- identity-to-account projection for explainable access decisions
- organization-bound functions and function assignments
- explicit delegation and acting-in-place facts
- groups and memberships
- roles and role assignments
- principal resolution
- permission evaluation
- access administration backend routes
- access administration WebUI route contributions
- published FastAPI auth dependency API
- published FastAPI auth dependency API at `govoplan_access.auth`
- access administration, tenant provisioning, and governance materializer
capabilities
- access-owned migrations
@@ -50,6 +58,17 @@ contributed by `govoplan-admin`; access must not register those routes.
`govoplan-access` depends on `govoplan-tenancy`; the registry loads tenancy
before access for authenticated platform composition.
## Principal Context
The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access
resolves sessions, API keys, and future service accounts into that DTO and
serializes it as `principal` in auth API responses. Feature modules should use
that DTO, primitive IDs, or the published `govoplan_access.auth` dependency API
instead of importing access ORM models or backend dependency internals.
The detailed module boundary and serialization fields are documented in
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
## WebUI Package
The repository root and `webui/` directory both expose the package

View File

@@ -0,0 +1,157 @@
# GovOPlaN Access Module Boundary
`govoplan-access` is the platform module that owns login identity and runtime
authorization state. Core remains the kernel: it composes modules, mounts
routes, owns process/database lifecycle, and exposes stable capability
contracts.
## Access-Owned Capabilities
`govoplan-access` owns the canonical implementation for:
- accounts and global login identity
- interactive authentication routes and session lifecycle
- API-key creation, verification, revocation, and scope delegation
- tenant-local users, memberships, groups, roles, and role assignments
- identity-to-account projection used for explainability
- organization-bound functions, function assignments, and delegation facts
- principal resolution and request authentication dependencies
- permission evaluation for access-owned scopes and legacy access aliases
- access-decision explain output with identity/account/function/role/right
provenance
- access administration backend routes for users, groups, roles, system
accounts, sessions, and API keys
- access administration WebUI route contribution for `/admin`
- tenant owner provisioning and default access bootstrap
- materializing governance templates into access-owned groups and roles
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
The active access tables use the `access_*` namespace while the model classes
live in this module: `access_accounts`, `access_users`, `access_groups`,
`access_roles`, `access_system_role_assignments`,
`access_user_group_memberships`, `access_user_role_assignments`,
`access_group_role_assignments`, `access_api_keys`, and
`access_auth_sessions`.
## Kernel-Owned Contracts
`govoplan-core` owns the stable contracts that let modules interact without
importing access internals:
- `ModuleManifest`, route factories, migration specs, and registry validation
- database engine/session lifecycle and migration orchestration
- capability registry and capability names in `govoplan_core.core.access`
- access DTO/protocol contracts such as `PrincipalRef`, `AccountRef`,
`UserRef`, `GroupRef`, `RoleRef`, `IdentityRef`,
`OrganizationUnitRef`, `FunctionRef`, `FunctionAssignmentRef`,
`FunctionDelegationRef`, `AccessDecisionProvenance`,
`PrincipalResolver`, `AccessDirectory`, `AccessSemanticDirectory`,
`PermissionEvaluator`, `AccessExplanationService`,
`TenantAccessProvisioner`, `AccessAdministration`, and
`AccessGovernanceMaterializer`
- health, platform metadata, and module startup ordering
- generic security helpers that are not access-state semantics, such as
secret encryption and UTC time helpers
Feature modules should depend on these kernel contracts or the published
`govoplan_access.auth` request dependency API, not on access ORM models or
`govoplan_access.backend.*` implementation internals.
## Principal Context Contract
The stable runtime principal is `govoplan_core.core.access.PrincipalRef`.
Access resolves request credentials into that DTO and `ApiPrincipal` keeps the
legacy ORM objects only for routers that have not yet moved to pure kernel
contracts. New module code should pass around `PrincipalRef` or primitive IDs.
`PrincipalRef.to_dict()` is the canonical API/WebUI serialization shape:
- `account_id`, `membership_id`, and `tenant_id`
- optional `identity_id`
- sorted `scopes`, `group_ids`, `role_ids`, `function_assignment_ids`, and
`delegation_ids`
- `auth_method` plus optional `session_id`, `api_key_id`, or
`service_account_id`
- optional `acting_for_account_id` for acting-in-place flows
- optional display fields `email` and `display_name`
`/api/v1/auth/me`, `/api/v1/auth/login`, profile refreshes, and tenant switches
include this payload as `principal` alongside the existing compatibility
fields. Modules that need current user context should prefer
`auth.principal`/`AuthInfo.principal` in the WebUI and
`principal.to_platform_principal()` in backend request handlers.
## Identity And Function Boundary
The full semantic model is documented in
[IDENTITY_ACCOUNT_FUNCTION_MODEL.md](IDENTITY_ACCOUNT_FUNCTION_MODEL.md).
In short:
- `govoplan-idm` imports and previews external identity and organization facts
from IDM systems.
- `govoplan-identity` owns canonical identities and identity/account links.
- `govoplan-organizations` owns canonical organization units, functions, and
account-held function assignments.
- `govoplan-access` owns the authorization projection that maps organization
and identity facts to roles, rights, delegation enforcement, and explainable
permission decisions.
Function assignments are account-held and organization-scoped. They can apply
only to the selected organization unit or to that unit and all subunits.
Delegation and acting-in-place must remain explicit facts with audit
provenance; modules must not infer either from plain group membership.
The backend foundation exposes these administration routes:
- `/api/v1/admin/identities`
- `/api/v1/admin/organization-units`
- `/api/v1/admin/functions`
- `/api/v1/admin/function-assignments`
- `/api/v1/admin/function-delegations`
Dedicated WebUI management panels and explicit acting-in-place context
selection are still follow-up work on top of these routes.
## Removed Compatibility Paths
These legacy imports were removed from core. Use access-owned modules, the
public `govoplan_access.auth` request dependency API, or kernel capabilities
instead:
- `govoplan_core.security.api_keys`
- `govoplan_core.security.sessions`
- `govoplan_core.security.passwords`
- `govoplan_core.api.v1.auth`
- `govoplan_core.api.v1.admin`
- `govoplan_core.api.v1.admin_schemas`
- `govoplan_core.admin.service`
- `govoplan_core.admin.governance`
HTTP route compatibility remains at the API layer: the access manifest
contributes the same `/api/v1/auth/*` and `/api/v1/admin/*` paths through module
route aggregation.
## Route Ownership
The access manifest contributes the `/api/v1/auth/*` interactive auth routes
and the access-owned `/api/v1/admin/*` administration routes through its module
route factory. Core default server configuration must not register auth or
admin routers as base routers.
Governance-template metadata CRUD is not access-owned. It is contributed by
`govoplan-admin`; access only materializes those templates into access-owned
groups and roles through the `access.governanceMaterializer` capability.
## Verification References
Focused verification is run from `/mnt/DATA/git/govoplan-core`.
- `tests.test_module_system` verifies manifest discovery, access startup in
module permutations, admin route ownership, governance-template route
separation, and legacy compatibility imports.
- `tests.test_api_smoke.ApiSmokeTests.test_cookie_session_requires_csrf_for_mutations`
verifies the access-owned session/auth route behavior.
- `tests.test_api_smoke.ApiSmokeTests.test_tenant_user_group_role_and_api_key_administration`
verifies access-owned administration and API-key behavior.
- `tests.test_api_smoke.ApiSmokeTests.test_profile_refresh_and_system_role_protection_model`
verifies profile/session refresh and protected system role behavior.

View File

@@ -0,0 +1,177 @@
# Identity, Account, Function, Role, And Right Model
GovOPlaN access distinguishes identity facts, organizational responsibility,
and authorization decisions. This is groundwork for postboxes, workflows,
service directories, portals, delegation, and audit review.
Directory services, identity providers, and IDM systems can authenticate people
and provide external facts. GovOPlaN access owns the normalized runtime
projection used for sessions, tenant memberships, groups, functions, roles,
delegations, and permission decisions.
## Semantic Layers
- Identity: the real person, service, or external subject. An identity can have
multiple accounts, for example a normal account and a privileged
administration account.
- Account: the login or technical account used to authenticate. Access
decisions are account-based because the account is the acting credential.
- Tenant membership: the account's participation in a tenant.
- Organization unit: the administrative unit where responsibility applies.
Organization units are hierarchical; access must know when a function applies
only to one unit or to that unit and all subunits.
- Function: a named responsibility held by an account in an organization unit,
such as case clerk, intake desk, treasurer, dean's office assistant, or
committee secretary. A function can map to one or more access roles.
- Role: a permission bundle or workflow authority attached to a function,
group, or explicit assignment.
- Right: the concrete scope or action permission evaluated at runtime.
The UI and API must not collapse these layers into a generic group concept.
Directory groups can feed mappings, but they must not silently become business
authority without a governed mapping rule.
## Organizational Function Scope
A function is meaningful only with organizational scope. The stable contract
therefore separates:
- `FunctionRef`: the organization-bound function definition, including tenant,
organization unit, role mappings, and delegation policy flags.
- `FunctionAssignmentRef`: the account-held assignment for that function,
including identity provenance and whether the assignment applies to all
subunits of the function's organization unit.
The assignment is the runtime authority. A role mapped to a function does not
grant rights until an account has an active assignment for that function.
Example:
- Identity `Anna Becker` owns accounts `anna` and `anna-admin`.
- Account `anna` has function `Registry Clerk` in organization unit
`Student Registry`.
- The assignment has `applies_to_subunits = true`, so the same function applies
to subordinate registry offices unless policy narrows it.
- The function maps to role `registry.case_editor`, which grants rights such as
`cases:case:update`.
## Delegation And Acting In Place
Functions can be delegated only if the function policy permits it. GovOPlaN
distinguishes two delegation modes:
- Delegation: the delegate acts as themself, with provenance showing the
delegated function assignment.
- Acting in place: the actor performs an action in another holder's function
context. Audit and explain responses must show both the real actor account
and the account being represented.
Both modes should be time-bound, revocable, auditable, and visible in access
explain output. Module code must not infer delegation from ordinary group
membership.
## IDM Boundary
`govoplan-idm` owns synchronization with external IDM systems: SCIM, LDAP,
SAML/OIDC claims, directory attributes, preview, rollback, and mapping import.
It does not own GovOPlaN's internal identity, organization, function, role, or
permission evaluation tables.
`govoplan-identity` owns canonical identity records and identity/account links.
`govoplan-organizations` owns canonical organization units, functions, and
function assignments. During the transition, access keeps a security projection
of those concepts for compatibility and authorization, but new integrations
should target the identity and organization capabilities first.
`govoplan-access` owns the platform projection created from those mappings:
- accounts and tenant membership projection
- identity-to-account links used for explainability
- groups, roles, function assignments, and delegation facts
- permission decisions and explain responses
- access-owned identity and membership change events
- mapping effects after an IDM import is accepted
Access does not own:
- mailboxes, calendars, files, cases, tasks, postboxes, or other module data
- canonical organization structure once `govoplan-organizations` is enabled
- canonical identity records once `govoplan-identity` is enabled
- module-specific ACL records beyond stable principal/group/role references
- external provider internals except where they mutate access-owned state
## Kernel Contracts
The stable DTO and protocol surface lives in
`govoplan_core.core.access`. The current groundwork adds:
- `IdentityRef`
- `OrganizationUnitRef`
- `FunctionRef`
- `FunctionAssignmentRef`
- `FunctionDelegationRef`
- `AccessDecisionProvenance`
- `AccessSemanticDirectory`
- `AccessExplanationService`
Feature modules should consume those contracts instead of importing access ORM
models. Storage, migration, and admin UI work can evolve behind the contract
without changing module integrations.
## Required Explainability
Access decisions must be explainable in concrete terms:
- actor identity and account
- tenant membership
- organization unit
- function assignment or group membership
- role source
- permission or right checked
- delegation or acting-in-place context
- policy, lock, or maintenance state that changed the result
This shape is required for role-bound postboxes, workflow authorization,
service directory personalization, delegated administration, and audit review.
## Consumer Expectations
- Postbox can grant access to a role-bound or function-bound postbox without
tying the postbox to a specific login account.
- Portal/service directory can show services relevant to a user's current
organization functions and tenant membership.
- Workflow can ask whether the current account can act in a function context
for a given organization unit.
- Audit can show who acted, with which account, under which function, and
whether delegation or acting-in-place was involved.
## Implementation Sequence
Implemented backend foundation:
- Kernel DTOs/protocols are covered by focused contract tests.
- Access-owned storage exists for identities, account links, organization
units, functions, function-role mappings, function assignments, and function
delegations.
- Admin APIs exist under `/api/v1/admin/identities`,
`/api/v1/admin/organization-units`, `/api/v1/admin/functions`,
`/api/v1/admin/function-assignments`, and
`/api/v1/admin/function-delegations`.
- `PrincipalRef` population includes identity, role, function assignment, and
delegation identifiers when those facts exist.
- The access manifest registers `access.semanticDirectory` and
`access.explanation` capabilities.
Remaining rollout:
1. Move canonical identity and organization reads to `identity.directory` and
`organizations.directory`, keeping access-owned rows as a compatibility
projection until migration is complete.
2. Add dedicated WebUI management panels for identities, organization units,
functions, assignments, and delegations.
3. Add explicit acting-in-place context selection; `act_in_place` delegation
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
assumptions.

View File

@@ -0,0 +1,97 @@
# OpenDesk Identity Integration Boundary
OpenDesk-style identity integrations should terminate in `govoplan-access` as
canonical accounts, tenant memberships, groups, roles, sessions, and principal
claims. Provider protocol details may live in access subpackages or dedicated
connector modules, but other GovOPlaN modules must consume identity through
access capabilities, typed DTOs, events, and published route dependencies.
## Boundary Decision
`govoplan-access` owns the identity projection and authorization effects:
- external identity links for accounts and memberships
- authentication callback/session issuance for federated login
- claim, group, and role mapping into access-owned roles and memberships
- SCIM-style provisioning effects for accounts, users, groups, and group
membership
- account suspension/deactivation effects that influence sessions and API keys
- audit-relevant identity events emitted through kernel event/audit contracts
Connector packages may own provider-specific transport and schema logic:
- LDAP and Active Directory bind/search/sync adapters
- OIDC and SAML provider metadata, callback protocol handling, and claim
normalization
- SCIM client/server protocol specifics
- Open-Xchange identity lookup or provisioning clients
Those connectors should call access capabilities or access-owned service APIs
instead of writing access tables directly.
## Integration Types
### LDAP And Active Directory
LDAP/AD adapters may authenticate credentials, search directory entries, and
sync group membership. Access owns the resulting account, membership, group,
and role mapping. Directory groups should map to access groups or role
assignments through explicit mapping rules; they should not grant feature
module permissions directly.
### OIDC And SAML
OIDC/SAML adapters may handle provider metadata, assertions, tokens, and claim
normalization. Access owns external subject linking, session creation, tenant
selection, first-login behavior, and claim-to-role/group mapping. Feature
modules should see only `PrincipalRef`, `UserRef`, scopes, group IDs, and
tenant context.
### SCIM Provisioning
SCIM provisioning belongs at the access boundary because it mutates accounts,
memberships, groups, and deactivation state. Tenant resolution remains a kernel
or tenancy capability concern. SCIM must not provision mailboxes, calendars,
campaign ownership, or file spaces directly; those modules may react to
access-published identity events when needed.
### Open-Xchange Touchpoints
Open-Xchange identity/contact integration should split identity from
collaboration data:
- Access owns external account IDs, email/display-name identity fields,
membership state, group references, and auth/session effects.
- Mail, calendar, contacts, or connector modules own mailboxes, address books,
calendar resources, contact folders, and provider-specific collaboration
objects.
Access may publish identity-change events and stable DTOs that those modules
consume, but it should not import their internals or own their provider data.
## Non-Goals
Access does not own:
- campaign ACLs, campaign ownership, delivery policy, or recipient contacts
- file storage permissions beyond principal/group identity references
- mail profile credentials, mailbox state, or reusable mail-server profiles
- calendar availability, appointments, rooms, or contact address books
- provider-specific UI panels for non-identity configuration
Those belong to their owning modules and should integrate through capabilities
or events.
## Implementation Shape
Provider integration should be added in small slices:
1. Define an access-owned external identity link model and DTO surface.
2. Add provider adapter contracts that normalize external subjects, groups,
claims, and deactivation signals.
3. Route OIDC/SAML login callbacks through access so sessions are issued by
the access session service.
4. Route LDAP/AD/SCIM provisioning through access administration services and
tenant provisioning capabilities.
5. Publish identity-change events for optional mail/calendar/contact/file
reactions without adding module-to-module imports.

View File

@@ -19,7 +19,7 @@
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"

View File

@@ -1,3 +1,3 @@
"""GovOPlaN access platform module."""
__version__ = "0.1.4"
__version__ = "0.1.6"

View File

@@ -0,0 +1,21 @@
"""Public FastAPI access dependency API.
Feature modules may import this package when they need request principal and
scope dependencies. Implementation details remain under ``govoplan_access.backend``.
"""
from govoplan_access.backend.auth.dependencies import (
ApiPrincipal,
get_api_principal,
has_scope,
require_any_scope,
require_scope,
)
__all__ = [
"ApiPrincipal",
"get_api_principal",
"has_scope",
"require_any_scope",
"require_scope",
]

View File

@@ -20,6 +20,7 @@ from govoplan_access.backend.db.models import (
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_access.backend.semantic import ensure_identity_for_account
from govoplan_access.backend.security.passwords import hash_password
from govoplan_core.security.permissions import (
DEFAULT_SYSTEM_ROLES,
@@ -98,6 +99,7 @@ def get_or_create_account(
raise AdminConflictError(
"The global account is disabled. A system administrator must reactivate it before adding a tenant membership."
)
ensure_identity_for_account(session, account)
return account, False, None
temporary_password = password or generate_temporary_password()
@@ -112,6 +114,7 @@ def get_or_create_account(
)
session.add(account)
session.flush()
ensure_identity_for_account(session, account)
return account, True, temporary_password

View File

@@ -25,6 +25,7 @@ from govoplan_access.backend.security.sessions import (
collect_user_groups,
collect_user_scopes,
)
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
from govoplan_access.backend.db.models import (
Account,
@@ -44,7 +45,7 @@ def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, AdminValidationError):
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
@@ -150,6 +151,8 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
last_login_at=account.last_login_at,
groups=[_group_summary(session, group, include_members=False) for group in groups],
roles=[_role_summary(session, role) for role in roles],
function_assignment_ids=collect_function_assignment_ids(session, user),
function_delegation_ids=collect_function_delegation_ids(session, user),
effective_scopes=collect_user_scopes(session, user, include_system=False),
is_owner=user.id in effective_owner_ids,
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,

View File

@@ -5,6 +5,8 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
@@ -128,6 +130,9 @@ class TenantSettingsItem(BaseModel):
slug: str
name: str
default_locale: str = Field(default="en", min_length=1, max_length=20)
available_languages: list[dict[str, Any]] = Field(default_factory=list)
system_enabled_language_codes: list[str] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
settings: dict[str, Any] = Field(default_factory=dict)
@@ -135,6 +140,7 @@ class TenantSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
enabled_language_codes: list[str] | None = None
class RoleSummary(BaseModel):
@@ -153,6 +159,227 @@ class RoleSummary(BaseModel):
system_required: bool = False
class IdentityAccountLinkItem(BaseModel):
id: str
account_id: str
email: str | None = None
display_name: str | None = None
is_primary: bool = False
source: str = "local"
class IdentityAdminItem(BaseModel):
id: str
display_name: str | None = None
external_subject: str | None = None
source: str = "local"
is_active: bool = True
accounts: list[IdentityAccountLinkItem] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class IdentityListResponse(BaseModel):
identities: list[IdentityAdminItem]
class IdentityCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
external_subject: str | None = Field(default=None, max_length=255)
source: str = Field(default="local", max_length=50)
account_ids: list[str] = Field(default_factory=list)
primary_account_id: str | None = None
is_active: bool = True
class IdentityUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
external_subject: str | None = Field(default=None, max_length=255)
source: str | None = Field(default=None, max_length=50)
account_ids: list[str] | None = None
primary_account_id: str | None = None
is_active: bool | None = None
class OrganizationUnitItem(BaseModel):
id: str
tenant_id: str
parent_id: str | None = None
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
created_at: datetime
updated_at: datetime
class OrganizationUnitListResponse(BaseModel):
organization_units: list[OrganizationUnitItem]
class OrganizationUnitCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str
name: str = Field(min_length=1, max_length=255)
parent_id: str | None = None
description: str | None = None
is_active: bool = True
class OrganizationUnitUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str | None = None
name: str | None = Field(default=None, min_length=1, max_length=255)
parent_id: str | None = None
description: str | None = None
is_active: bool | None = None
class FunctionAdminItem(BaseModel):
id: str
tenant_id: str
organization_unit_id: str
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
role_ids: list[str] = Field(default_factory=list)
delegable: bool = False
act_in_place_allowed: bool = False
is_active: bool = True
created_at: datetime
updated_at: datetime
class FunctionListResponse(BaseModel):
functions: list[FunctionAdminItem]
class FunctionCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
organization_unit_id: str
slug: str
name: str = Field(min_length=1, max_length=255)
description: str | None = None
role_ids: list[str] = Field(default_factory=list)
delegable: bool = False
act_in_place_allowed: bool = False
is_active: bool = True
class FunctionUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
organization_unit_id: str | None = None
slug: str | None = None
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
role_ids: list[str] | None = None
delegable: bool | None = None
act_in_place_allowed: bool | None = None
is_active: bool | None = None
class FunctionAssignmentAdminItem(BaseModel):
id: str
tenant_id: str
account_id: str
identity_id: str | None = None
function_id: str
organization_unit_id: str
applies_to_subunits: bool = False
source: str = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
created_at: datetime
updated_at: datetime
class FunctionAssignmentListResponse(BaseModel):
assignments: list[FunctionAssignmentAdminItem]
class FunctionAssignmentCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
account_id: str
function_id: str
organization_unit_id: str | None = None
identity_id: str | None = None
applies_to_subunits: bool = False
source: str = Field(default="direct", max_length=50)
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
class FunctionAssignmentUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
identity_id: str | None = None
organization_unit_id: str | None = None
applies_to_subunits: bool | None = None
source: str | None = Field(default=None, max_length=50)
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
class FunctionDelegationAdminItem(BaseModel):
id: str
tenant_id: str
function_assignment_id: str
delegator_account_id: str
delegate_account_id: str
mode: Literal["delegate", "act_in_place"] = "delegate"
reason: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
revoked_at: datetime | None = None
is_active: bool = True
created_at: datetime
updated_at: datetime
class FunctionDelegationListResponse(BaseModel):
delegations: list[FunctionDelegationAdminItem]
class FunctionDelegationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
function_assignment_id: str
delegate_account_id: str
mode: Literal["delegate", "act_in_place"] = "delegate"
reason: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
class FunctionDelegationUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
revoked: bool | None = None
class GroupSummary(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
@@ -180,6 +407,8 @@ class UserAdminItem(BaseModel):
last_login_at: datetime | None = None
groups: list[GroupSummary] = Field(default_factory=list)
roles: list[RoleSummary] = Field(default_factory=list)
function_assignment_ids: list[str] = Field(default_factory=list)
function_delegation_ids: list[str] = Field(default_factory=list)
effective_scopes: list[str] = Field(default_factory=list)
is_owner: bool = False
is_last_active_owner: bool = False
@@ -191,6 +420,13 @@ class UserListResponse(BaseModel):
users: list[UserAdminItem]
class UserListDeltaResponse(UserListResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class UserCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -222,6 +458,13 @@ class GroupListResponse(BaseModel):
groups: list[GroupSummary]
class GroupListDeltaResponse(GroupListResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class GroupCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -247,6 +490,13 @@ class RoleListResponse(BaseModel):
roles: list[RoleSummary]
class RoleListDeltaResponse(RoleListResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class RoleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -280,6 +530,13 @@ class SystemAccountListResponse(BaseModel):
roles: list[RoleSummary]
class SystemAccountListDeltaResponse(SystemAccountListResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class SystemAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -396,12 +653,124 @@ class RetentionRunResponse(BaseModel):
result: dict[str, Any]
class ConfigurationSafetyFieldItem(BaseModel):
key: str
label: str
owner_module: str
scope: Literal["system", "tenant", "user", "group", "campaign"]
storage: str
ui_managed: bool
risk: Literal["low", "medium", "high", "destructive"]
secret_handling: Literal["none", "reference_only", "env_only"] = "none"
required_scopes: list[str] = Field(default_factory=list)
dry_run_required: bool = False
validation_required: bool = True
policy_explanation_required: bool = False
audit_event: str | None = None
maintenance_required: bool = False
two_person_approval_required: bool = False
rollback_history_required: bool = False
notes: str | None = None
class ConfigurationSafetyCatalogResponse(BaseModel):
fields: list[ConfigurationSafetyFieldItem]
class ConfigurationChangeSafetyPlanRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str
value: Any = None
dry_run: bool = False
maintenance_mode: bool = False
approval_count: int = Field(default=0, ge=0)
class ConfigurationChangeSafetyPlanResponse(BaseModel):
plan: dict[str, Any]
class ConfigurationChangeRequestCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str
value: Any = None
dry_run: bool = False
target: dict[str, Any] = Field(default_factory=dict)
reason: str | None = Field(default=None, max_length=1000)
class ConfigurationChangeRequestApproveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str | None = Field(default=None, max_length=1000)
class ConfigurationChangeRequestResponse(BaseModel):
request: dict[str, Any]
class ConfigurationControlSnapshotResponse(BaseModel):
requests: list[dict[str, Any]] = Field(default_factory=list)
history: list[dict[str, Any]] = Field(default_factory=list)
class ConfigurationControlDeltaResponse(ConfigurationControlSnapshotResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class ConfigurationPackageCatalogResponse(BaseModel):
validation: dict[str, Any]
class ConfigurationPackageRunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
package: dict[str, Any]
tenant_id: str | None = None
supplied_data: dict[str, Any] = Field(default_factory=dict)
change_request_id: str | None = None
class ConfigurationPackageDryRunResponse(BaseModel):
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
required_data: list[dict[str, Any]] = Field(default_factory=list)
plan: list[dict[str, Any]] = Field(default_factory=list)
class ConfigurationPackageApplyResponse(BaseModel):
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
created_refs: dict[str, str] = Field(default_factory=dict)
updated_refs: dict[str, str] = Field(default_factory=dict)
class ConfigurationPackageExportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str | None = None
scopes: list[str] = Field(default_factory=list)
module_ids: list[str] = Field(default_factory=list)
object_refs: list[str] = Field(default_factory=list)
class ConfigurationPackageExportResponse(BaseModel):
fragments: 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)
class SystemSettingsItem(BaseModel):
default_locale: str = "en"
allow_tenant_custom_groups: bool = True
allow_tenant_custom_roles: bool = True
allow_tenant_api_keys: bool = True
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
available_languages: list[dict[str, Any]] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
settings: dict[str, Any] = Field(default_factory=dict)
@@ -413,6 +782,8 @@ class SystemSettingsUpdateRequest(BaseModel):
allow_tenant_custom_roles: bool
allow_tenant_api_keys: bool
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
available_languages: list[dict[str, Any]] | None = None
enabled_language_codes: list[str] | None = None
class ApiKeyAdminItem(BaseModel):
@@ -432,6 +803,13 @@ class ApiKeyListResponse(BaseModel):
api_keys: list[ApiKeyAdminItem]
class ApiKeyListDeltaResponse(ApiKeyListResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class AdminApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -463,3 +841,5 @@ class AuditAdminListResponse(BaseModel):
page: int = 1
page_size: int = 100
pages: int = 1
cursor: str | None = None
next_cursor: str | None = None

View File

@@ -8,21 +8,36 @@ from govoplan_core.api.v1.schemas import (
LoginRequest,
LoginResponse,
MeResponse,
PrincipalContextInfo,
ProfileUpdateRequest,
RoleInfo,
SwitchTenantRequest,
TenantInfo,
TenantMembershipInfo,
UserInfo,
UserUiPreferences,
)
from govoplan_core.core.access import AuthMethod, PrincipalRef
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_access.backend.db.models import Account, Tenant, User
from govoplan_core.db.session import get_session
from govoplan_core.i18n import (
i18n_settings,
normalize_enabled_language_codes,
normalize_language_code,
preferred_language_code,
system_enabled_language_codes,
system_i18n_payload,
tenant_enabled_language_codes,
user_enabled_language_codes,
)
from govoplan_core.security.permissions import normalize_email, scopes_grant
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.security.sessions import (
collect_system_roles,
@@ -68,17 +83,32 @@ def _clear_auth_cookies(response: Response) -> None:
response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs)
def _tenant_info(tenant: Tenant) -> TenantInfo:
def _tenant_info(tenant: Tenant, *, enabled_language_codes: list[str] | None = None) -> TenantInfo:
return TenantInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active,
default_locale=tenant.default_locale,
enabled_language_codes=enabled_language_codes or [],
)
def _user_info(user: User, account: Account) -> UserInfo:
def _user_ui_preferences(settings_payload: object) -> UserUiPreferences:
raw = settings_payload.get("ui") if isinstance(settings_payload, dict) else None
try:
return UserUiPreferences.model_validate(raw if isinstance(raw, dict) else {})
except ValueError:
return UserUiPreferences()
def _user_info(
user: User,
account: Account,
*,
preferred_language: str | None = None,
enabled_language_codes: list[str] | None = None,
) -> UserInfo:
return UserInfo(
id=user.id,
account_id=account.id,
@@ -87,6 +117,9 @@ def _user_info(user: User, account: Account) -> UserInfo:
tenant_display_name=user.display_name,
is_tenant_admin=user.is_tenant_admin,
password_reset_required=account.password_reset_required,
preferred_language=preferred_language,
enabled_language_codes=enabled_language_codes or [],
ui_preferences=_user_ui_preferences(user.settings),
)
@@ -124,6 +157,25 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
return memberships
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
system_settings = get_system_settings(session)
system_payload = system_i18n_payload(system_settings)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
user_enabled = user_enabled_language_codes(user.settings, tenant_enabled)
preferred = preferred_language_code(
user.settings,
user_enabled,
default_locale=tenant.default_locale or system_payload.get("default_language"),
)
return {
"available_languages": system_payload["available_languages"],
"tenant_enabled_language_codes": tenant_enabled,
"user_enabled_language_codes": user_enabled,
"preferred_language": preferred,
}
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
account = (
session.query(Account)
@@ -157,13 +209,22 @@ def _me_response(
user: User,
tenant: Tenant,
effective_scopes: list[str] | None = None,
auth_method: AuthMethod = "session",
api_key_id: str | None = None,
session_id: str | None = None,
service_account_id: str | None = None,
include_system: bool = True,
include_all_memberships: bool = True,
) -> MeResponse:
tenant_roles = collect_user_roles(session, user)
system_roles = collect_system_roles(session, account) if include_system else []
groups = collect_user_groups(session, user)
active_tenant = _tenant_info(tenant)
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
languages = _language_context(session, tenant=tenant, user=user)
tenant_enabled = list(languages["tenant_enabled_language_codes"])
user_enabled = list(languages["user_enabled_language_codes"])
preferred_language = str(languages["preferred_language"])
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
memberships = _tenant_memberships(session, account) if include_all_memberships else [
TenantMembershipInfo(
id=tenant.id,
@@ -175,13 +236,35 @@ def _me_response(
)
]
return MeResponse(
user=_user_info(user, account),
user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=memberships,
scopes=effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system),
scopes=scopes,
roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"),
groups=_groups_info(groups),
principal=PrincipalContextInfo.model_validate(
PrincipalRef(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant.id,
identity_id=identity_id_for_account(session, account.id),
scopes=frozenset(scopes),
group_ids=frozenset(group.id for group in groups),
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
auth_method=auth_method,
api_key_id=api_key_id,
session_id=session_id,
service_account_id=service_account_id,
email=account.email,
display_name=account.display_name or user.display_name,
).to_dict()
),
available_languages=languages["available_languages"],
enabled_language_codes=tenant_enabled,
default_language=preferred_language,
)
@@ -209,7 +292,15 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
**me_payload.model_dump(),
**_me_response(
session,
account=account,
user=user,
tenant=tenant,
effective_scopes=me_payload.scopes,
auth_method="session",
session_id=created.model.id,
).model_dump(),
)
@@ -224,6 +315,10 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
user=principal.user,
tenant=tenant,
effective_scopes=principal.scopes,
auth_method=principal.auth_method,
api_key_id=principal.api_key_id,
session_id=principal.session_id,
service_account_id=principal.principal.service_account_id,
include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None,
)
@@ -244,6 +339,46 @@ def update_profile(
if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user)
next_settings = dict(principal.user.settings or {})
settings_changed = False
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
system_settings = get_system_settings(session)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
next_i18n = i18n_settings(next_settings)
if "preferred_language" in payload.model_fields_set:
preferred = normalize_language_code(payload.preferred_language)
if preferred:
normalized_preferred = normalize_enabled_language_codes(
[preferred],
[{"code": code} for code in tenant_enabled],
fallback_codes=tenant_enabled,
)[0]
next_i18n["preferred_language"] = normalized_preferred
else:
next_i18n.pop("preferred_language", None)
if "enabled_language_codes" in payload.model_fields_set:
next_i18n["enabled_language_codes"] = normalize_enabled_language_codes(
payload.enabled_language_codes,
[{"code": code} for code in tenant_enabled],
fallback_codes=tenant_enabled,
)
next_settings["i18n"] = next_i18n
settings_changed = True
if "ui_preferences" in payload.model_fields_set:
if payload.ui_preferences is None:
next_settings["ui"] = UserUiPreferences().model_dump()
else:
next_ui = _user_ui_preferences(next_settings).model_dump()
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
settings_changed = True
if settings_changed:
principal.user.settings = next_settings
session.add(principal.user)
audit_from_principal(
session,
@@ -262,6 +397,8 @@ def update_profile(
account=principal.account,
user=principal.user,
tenant=tenant,
auth_method=principal.auth_method,
session_id=principal.session_id,
include_system=True,
include_all_memberships=True,
)
@@ -283,7 +420,14 @@ def switch_tenant(
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
session.commit()
return _me_response(session, account=principal.account, user=membership, tenant=tenant)
return _me_response(
session,
account=principal.account,
user=membership,
tenant=tenant,
auth_method="session",
session_id=principal.session_id,
)
@router.post("/logout")

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,15 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_core.db.session import get_database, get_session
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.sessions import authenticate_session_token, collect_user_groups, collect_user_scopes, verify_auth_session_csrf
from govoplan_access.backend.security.sessions import (
authenticate_session_token,
collect_user_groups,
collect_user_roles,
collect_user_scopes,
verify_auth_session_csrf,
)
from govoplan_tenancy.backend.db.models import Tenant
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import intersect_api_key_scopes
@@ -63,6 +70,26 @@ class ApiPrincipal:
def group_ids(self) -> frozenset[str]:
return self.principal.group_ids
@property
def role_ids(self) -> frozenset[str]:
return self.principal.role_ids
@property
def function_assignment_ids(self) -> frozenset[str]:
return self.principal.function_assignment_ids
@property
def delegation_ids(self) -> frozenset[str]:
return self.principal.delegation_ids
@property
def identity_id(self) -> str | None:
return self.principal.identity_id
@property
def acting_for_account_id(self) -> str | None:
return self.principal.acting_for_account_id
@property
def auth_method(self) -> str:
return self.principal.auth_method
@@ -128,8 +155,12 @@ def _build_principal_ref(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant_id,
identity_id=identity_id_for_account(session, account.id),
scopes=frozenset(scopes),
group_ids=_principal_group_ids(session, user),
role_ids=frozenset(role.id for role in collect_user_roles(session, user)),
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None,
session_id=auth_session.id if auth_session else None,

View File

@@ -0,0 +1,376 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import slugify
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role
from govoplan_core.core.configuration_packages import (
ConfigurationApplyResult,
ConfigurationDiagnostic,
ConfigurationExportResult,
ConfigurationExportSelection,
ConfigurationPackageFragment,
ConfigurationPlanItem,
ConfigurationPreflightContext,
ConfigurationPreflightResult,
ConfigurationProvider,
ConfigurationProviderDescription,
)
from govoplan_core.db.session import get_database
ACCESS_CONFIGURATION_CAPABILITY = "access.configuration"
class SqlAccessConfigurationProvider(ConfigurationProvider):
module_id = "access"
def describe(self) -> ConfigurationProviderDescription:
return ConfigurationProviderDescription(
module_id=self.module_id,
fragment_types=("roles", "groups", "group_role_assignments"),
schema_refs={
"roles": "govoplan/access/configuration/roles.v1",
"groups": "govoplan/access/configuration/groups.v1",
"group_role_assignments": "govoplan/access/configuration/group-role-assignments.v1",
},
exported_scopes=("system", "tenant"),
)
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
with get_database().session() as session:
return _preflight_fragment(session, fragment, context)
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: Mapping[str, Any], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
del supplied_data
with get_database().session() as session:
result = _apply_fragment(session, fragment, context)
if not any(item.severity == "blocker" for item in result.diagnostics):
session.commit()
return result
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
del context
with get_database().session() as session:
return _export_access_configuration(session, selection)
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext) -> tuple[ConfigurationDiagnostic, ...]:
del context
if import_result.diagnostics:
return tuple(item for item in import_result.diagnostics if item.severity == "blocker")
return ()
def _preflight_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
if fragment.fragment_type == "roles":
return _preflight_roles(session, fragment, context)
if fragment.fragment_type == "groups":
return _preflight_groups(session, fragment, context)
if fragment.fragment_type == "group_role_assignments":
return _preflight_group_role_assignments(session, fragment, context)
return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),))
def _apply_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
if fragment.fragment_type == "roles":
return _apply_roles(session, fragment, context)
if fragment.fragment_type == "groups":
return _apply_groups(session, fragment, context)
if fragment.fragment_type == "group_role_assignments":
return _apply_group_role_assignments(session, fragment, context)
return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),))
def _preflight_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
diagnostics: list[ConfigurationDiagnostic] = []
plan: list[ConfigurationPlanItem] = []
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
level = str(item.get("level") or "tenant").strip().casefold()
tenant_id = _tenant_id(context, item, level=level)
if level == "tenant" and tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
plan.append(_plan("blocked", fragment, slug, "Tenant role needs a tenant_id."))
continue
existing = _role_by_slug(session, slug, tenant_id)
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} role {slug}."))
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
def _apply_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
diagnostics: list[ConfigurationDiagnostic] = []
created: dict[str, str] = {}
updated: dict[str, str] = {}
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
level = str(item.get("level") or "tenant").strip().casefold()
tenant_id = _tenant_id(context, item, level=level)
if level == "tenant" and tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
continue
role = _role_by_slug(session, slug, tenant_id)
target = updated if role else created
if role is None:
role = Role(tenant_id=tenant_id, slug=slug, name=_required(item, "name"), permissions=[])
session.add(role)
session.flush()
role.name = str(item.get("name") or role.name).strip()
role.description = _optional(item, "description")
role.permissions = _string_list(item.get("permissions"))
role.is_assignable = _bool(item.get("is_assignable"), default=True)
role.system_required = _bool(item.get("required"), default=role.system_required)
target[slug] = f"role:{role.id}"
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
def _preflight_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
diagnostics: list[ConfigurationDiagnostic] = []
plan: list[ConfigurationPlanItem] = []
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
tenant_id = _tenant_id(context, item, level="tenant")
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
plan.append(_plan("blocked", fragment, slug, "Group needs a tenant_id."))
continue
existing = _group_by_slug(session, slug, tenant_id)
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} group {slug}."))
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
def _apply_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
diagnostics: list[ConfigurationDiagnostic] = []
created: dict[str, str] = {}
updated: dict[str, str] = {}
for item in _payload_items(fragment):
slug = slugify(_required(item, "slug"))
tenant_id = _tenant_id(context, item, level="tenant")
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, slug))
continue
group = _group_by_slug(session, slug, tenant_id)
target = updated if group else created
if group is None:
group = Group(tenant_id=tenant_id, slug=slug, name=_required(item, "name"))
session.add(group)
session.flush()
group.name = str(item.get("name") or group.name).strip()
group.description = _optional(item, "description")
group.is_active = _bool(item.get("is_active"), default=True)
group.system_required = _bool(item.get("required"), default=group.system_required)
target[slug] = f"group:{group.id}"
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
def _preflight_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
diagnostics: list[ConfigurationDiagnostic] = []
plan: list[ConfigurationPlanItem] = []
for item in _payload_items(fragment):
tenant_id = _tenant_id(context, item, level="tenant")
group_slug = slugify(_required(item, "group"))
role_slug = slugify(_required(item, "role"))
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, f"{group_slug}:{role_slug}"))
plan.append(_plan("blocked", fragment, f"{group_slug}:{role_slug}", "Group-role assignment needs a tenant_id."))
continue
group = _group_by_slug(session, group_slug, tenant_id)
role = _role_by_slug(session, role_slug, tenant_id)
if group is None or role is None:
missing = ", ".join(ref for ref, exists in ((f"group:{group_slug}", group is not None), (f"role:{role_slug}", role is not None)) if not exists)
diagnostics.append(ConfigurationDiagnostic(
severity="info",
code="access_reference_pending",
message=f"Access assignment references are not present yet and may be created by earlier package fragments: {missing}.",
module_id=fragment.module_id,
object_ref=f"{group_slug}:{role_slug}",
resolution="Keep role and group fragments before assignment fragments in the package.",
))
plan.append(_plan("bind", fragment, f"{group_slug}:{role_slug}", f"Bind {group_slug} to {role_slug} after referenced objects exist."))
continue
exists = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).count()
plan.append(_plan("skip" if exists else "bind", fragment, f"{group_slug}:{role_slug}", f"{'Keep' if exists else 'Bind'} {group_slug} to {role_slug}."))
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
def _apply_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
diagnostics: list[ConfigurationDiagnostic] = []
created: dict[str, str] = {}
for item in _payload_items(fragment):
tenant_id = _tenant_id(context, item, level="tenant")
group_slug = slugify(_required(item, "group"))
role_slug = slugify(_required(item, "role"))
object_ref = f"{group_slug}:{role_slug}"
if tenant_id is None:
diagnostics.append(_tenant_required(fragment, object_ref))
continue
group = _group_by_slug(session, group_slug, tenant_id)
role = _role_by_slug(session, role_slug, tenant_id)
if group is None:
diagnostics.append(_missing_ref(fragment, f"group:{group_slug}"))
if role is None:
diagnostics.append(_missing_ref(fragment, f"role:{role_slug}"))
if group is None or role is None:
continue
assignment = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).one_or_none()
if assignment is None:
assignment = GroupRoleAssignment(tenant_id=tenant_id, group_id=group.id, role_id=role.id)
session.add(assignment)
session.flush()
created[object_ref] = f"group_role_assignment:{assignment.id}"
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created)
def _export_access_configuration(session: Session, selection: ConfigurationExportSelection) -> ConfigurationExportResult:
tenant_id = selection.tenant_id
diagnostics: list[ConfigurationDiagnostic] = []
fragments: list[ConfigurationPackageFragment] = []
if tenant_id is None and "system" not in selection.scopes:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="tenant_required",
message="Access configuration export needs selection.tenant_id unless exporting system scope.",
module_id="access",
resolution="Choose a tenant before exporting tenant roles and groups.",
))
return ConfigurationExportResult(diagnostics=tuple(diagnostics))
if tenant_id is not None:
roles = session.query(Role).filter(Role.tenant_id == tenant_id).order_by(Role.slug.asc()).all()
groups = session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.slug.asc()).all()
assignments = (
session.query(GroupRoleAssignment, Group, Role)
.join(Group, Group.id == GroupRoleAssignment.group_id)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.filter(GroupRoleAssignment.tenant_id == tenant_id)
.order_by(Group.slug.asc(), Role.slug.asc())
.all()
)
fragments.extend((
ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role) for role in roles]}),
ConfigurationPackageFragment(module_id="access", fragment_type="groups", payload={"items": [_group_payload(group) for group in groups]}),
ConfigurationPackageFragment(module_id="access", fragment_type="group_role_assignments", payload={"items": [{"group": group.slug, "role": role.slug} for _assignment, group, role in assignments]}),
))
if "system" in selection.scopes:
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.slug.asc()).all()
fragments.append(ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role, level="system") for role in system_roles]}))
return ConfigurationExportResult(fragments=tuple(fragments), diagnostics=tuple(diagnostics))
def _payload_items(fragment: ConfigurationPackageFragment) -> list[Mapping[str, Any]]:
raw_items = fragment.payload.get("items")
if raw_items is None:
raw_items = fragment.payload.get(fragment.fragment_type)
if raw_items is None and fragment.payload:
raw_items = [fragment.payload]
if not isinstance(raw_items, list):
raise ValueError(f"Access configuration fragment {fragment.fragment_type!r} requires an items list.")
return [item for item in raw_items if isinstance(item, Mapping)]
def _tenant_id(context: ConfigurationPreflightContext, item: Mapping[str, Any], *, level: str) -> str | None:
if level == "system":
return None
value = item.get("tenant_id") or context.tenant_id
return str(value).strip() if value is not None and str(value).strip() else None
def _role_by_slug(session: Session, slug: str, tenant_id: str | None) -> Role | None:
query = session.query(Role).filter(Role.slug == slug)
query = query.filter(Role.tenant_id.is_(None)) if tenant_id is None else query.filter(Role.tenant_id == tenant_id)
return query.one_or_none()
def _group_by_slug(session: Session, slug: str, tenant_id: str) -> Group | None:
return session.query(Group).filter(Group.tenant_id == tenant_id, Group.slug == slug).one_or_none()
def _role_payload(role: Role, *, level: str = "tenant") -> dict[str, object]:
return {
"slug": role.slug,
"name": role.name,
"description": role.description,
"permissions": list(role.permissions or []),
"level": "system" if role.tenant_id is None else level,
"is_assignable": role.is_assignable,
"required": role.system_required,
}
def _group_payload(group: Group) -> dict[str, object]:
return {
"slug": group.slug,
"name": group.name,
"description": group.description,
"is_active": group.is_active,
"required": group.system_required,
}
def _required(item: Mapping[str, Any], key: str) -> str:
value = item.get(key)
if value is None or not str(value).strip():
raise ValueError(f"Access configuration item requires {key!r}.")
return str(value).strip()
def _optional(item: Mapping[str, Any], key: str) -> str | None:
value = item.get(key)
if value is None:
return None
text = str(value).strip()
return text or None
def _string_list(value: object) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("Access configuration permissions must be a list.")
return [str(item).strip() for item in value if str(item).strip()]
def _bool(value: object, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
def _plan(action: str, fragment: ConfigurationPackageFragment, object_ref: str, summary: str) -> ConfigurationPlanItem:
return ConfigurationPlanItem(action=action, module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=object_ref, summary=summary) # type: ignore[arg-type]
def _tenant_required(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="tenant_required",
message="Access tenant configuration requires a tenant_id.",
module_id=fragment.module_id,
object_ref=object_ref,
resolution="Run the package for a selected tenant or set tenant_id on the fragment item.",
)
def _missing_ref(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="access_reference_missing",
message=f"Access configuration references missing object {object_ref!r}.",
module_id=fragment.module_id,
object_ref=object_ref,
resolution="Create referenced groups and roles earlier in the package plan.",
)
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="fragment_type_unsupported",
message=f"Access configuration does not support fragment type {fragment.fragment_type!r}.",
module_id=fragment.module_id,
object_ref=fragment.fragment_id or fragment.fragment_type,
)

View File

@@ -18,7 +18,7 @@ def new_uuid() -> str:
class Account(AccessBase, TimestampMixin):
"""Global login identity shared by one or more tenant memberships."""
__tablename__ = "accounts"
__tablename__ = "access_accounts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
email: Mapped[str] = mapped_column(String(320), nullable=False)
@@ -31,22 +31,70 @@ class Account(AccessBase, TimestampMixin):
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
memberships: Mapped[list[User]] = relationship(back_populates="account")
identity_links: Mapped[list[IdentityAccountLink]] = relationship(
back_populates="account", cascade="all, delete-orphan"
)
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan")
system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship(
back_populates="account", cascade="all, delete-orphan"
)
class Identity(AccessBase, TimestampMixin):
__tablename__ = "access_identities"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
display_name: Mapped[str | None] = mapped_column(String(255))
external_subject: Mapped[str | None] = mapped_column(String(255), index=True)
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
account_links: Mapped[list[IdentityAccountLink]] = relationship(
back_populates="identity", cascade="all, delete-orphan"
)
class IdentityAccountLink(AccessBase, TimestampMixin):
__tablename__ = "access_identity_account_links"
__table_args__ = (
UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
Index(
"uq_identity_account_links_primary_account",
"account_id",
unique=True,
sqlite_where=text("is_primary = 1"),
postgresql_where=text("is_primary IS TRUE"),
),
Index(
"uq_identity_account_links_primary_identity",
"identity_id",
unique=True,
sqlite_where=text("is_primary = 1"),
postgresql_where=text("is_primary IS TRUE"),
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
identity_id: Mapped[str] = mapped_column(ForeignKey("access_identities.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
identity: Mapped[Identity] = relationship(back_populates="account_links")
account: Mapped[Account] = relationship(back_populates="identity_links")
class User(AccessBase, TimestampMixin):
__tablename__ = "users"
__tablename__ = "access_users"
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -64,11 +112,11 @@ class User(AccessBase, TimestampMixin):
class Group(AccessBase, TimestampMixin):
__tablename__ = "groups"
__tablename__ = "access_groups"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@@ -80,7 +128,7 @@ class Group(AccessBase, TimestampMixin):
class Role(AccessBase, TimestampMixin):
__tablename__ = "roles"
__tablename__ = "access_roles"
__table_args__ = (
UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),
Index(
@@ -93,7 +141,7 @@ class Role(AccessBase, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@@ -104,54 +152,148 @@ class Role(AccessBase, TimestampMixin):
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class OrganizationUnit(AccessBase, TimestampMixin):
__tablename__ = "access_organization_units"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
parent_id: Mapped[str | None] = mapped_column(ForeignKey("access_organization_units.id", ondelete="SET NULL"), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class Function(AccessBase, TimestampMixin):
__tablename__ = "access_functions"
__table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
delegable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
act_in_place_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class FunctionRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "access_function_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_function_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
class FunctionAssignment(AccessBase, TimestampMixin):
__tablename__ = "access_function_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"account_id",
"function_id",
"organization_unit_id",
name="uq_function_assignments_account_scope",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
identity_id: Mapped[str | None] = mapped_column(ForeignKey("access_identities.id", ondelete="SET NULL"), nullable=True, index=True)
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.id", ondelete="CASCADE"), nullable=False, index=True)
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
acting_for_account_id: Mapped[str | None] = mapped_column(ForeignKey("access_accounts.id", ondelete="SET NULL"), nullable=True, index=True)
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class FunctionDelegation(AccessBase, TimestampMixin):
__tablename__ = "access_function_delegations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"function_assignment_id",
"delegate_account_id",
"mode",
name="uq_function_delegations_assignment_delegate_mode",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
function_assignment_id: Mapped[str] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
delegator_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
delegate_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
mode: Mapped[str] = mapped_column(String(30), default="delegate", nullable=False)
reason: Mapped[str | None] = mapped_column(Text)
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class SystemRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "system_role_assignments"
__tablename__ = "access_system_role_assignments"
__table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
account: Mapped[Account] = relationship(back_populates="system_role_assignments")
role: Mapped[Role] = relationship()
class UserGroupMembership(AccessBase, TimestampMixin):
__tablename__ = "user_group_memberships"
__tablename__ = "access_user_group_memberships"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
class UserRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "user_role_assignments"
__tablename__ = "access_user_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
class GroupRoleAssignment(AccessBase, TimestampMixin):
__tablename__ = "group_role_assignments"
__tablename__ = "access_group_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
class ApiKey(AccessBase, TimestampMixin):
__tablename__ = "api_keys"
__tablename__ = "access_api_keys"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
@@ -164,12 +306,12 @@ class ApiKey(AccessBase, TimestampMixin):
class AuthSession(AccessBase, TimestampMixin):
__tablename__ = "auth_sessions"
__tablename__ = "access_auth_sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
@@ -186,8 +328,15 @@ __all__ = [
"Account",
"ApiKey",
"AuthSession",
"Function",
"FunctionAssignment",
"FunctionDelegation",
"FunctionRoleAssignment",
"Group",
"GroupRoleAssignment",
"Identity",
"IdentityAccountLink",
"OrganizationUnit",
"Role",
"SystemRoleAssignment",
"Tenant",

View File

@@ -2,9 +2,30 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping
from govoplan_core.core.access import AccessDirectory, AccessSubjectRef, AccountRef, GroupRef, UserRef
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.access import (
AccessSemanticDirectory,
AccessSubjectRef,
AccountRef,
FunctionAssignmentRef,
FunctionRef,
GroupRef,
IdentityRef,
OrganizationUnitRef,
UserRef,
)
from govoplan_access.backend.db.models import (
Account,
Function,
FunctionAssignment,
FunctionRoleAssignment,
Group,
Identity,
IdentityAccountLink,
OrganizationUnit,
User,
)
from govoplan_core.db.session import get_database
from govoplan_access.backend.semantic import active_function_assignments_for_account
def _status(active: bool) -> str:
@@ -40,7 +61,60 @@ def _group_ref(group: Group) -> GroupRef:
)
class SqlAccessDirectory(AccessDirectory):
def _identity_ref(identity: Identity, account_links: list[IdentityAccountLink]) -> IdentityRef:
primary_account_id = next((link.account_id for link in account_links if link.is_primary), None)
return IdentityRef(
id=identity.id,
display_name=identity.display_name,
primary_account_id=primary_account_id,
account_ids=tuple(link.account_id for link in account_links),
status=_status(identity.is_active), # type: ignore[arg-type]
)
def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
return OrganizationUnitRef(
id=item.id,
tenant_id=item.tenant_id,
name=item.name,
parent_id=item.parent_id,
status=_status(item.is_active), # type: ignore[arg-type]
)
def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
return FunctionRef(
id=function.id,
tenant_id=function.tenant_id,
organization_unit_id=function.organization_unit_id,
slug=function.slug,
name=function.name,
role_ids=tuple(role_ids),
delegable=function.delegable,
act_in_place_allowed=function.act_in_place_allowed,
status=_status(function.is_active), # type: ignore[arg-type]
)
def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
return FunctionAssignmentRef(
id=item.id,
tenant_id=item.tenant_id,
account_id=item.account_id,
identity_id=item.identity_id,
function_id=item.function_id,
organization_unit_id=item.organization_unit_id,
applies_to_subunits=item.applies_to_subunits,
source=item.source, # type: ignore[arg-type]
delegated_from_assignment_id=item.delegated_from_assignment_id,
acting_for_account_id=item.acting_for_account_id,
valid_from=item.valid_from,
valid_until=item.valid_until,
status=_status(item.is_active), # type: ignore[arg-type]
)
class SqlAccessDirectory(AccessSemanticDirectory):
def get_account(self, account_id: str) -> AccountRef | None:
with get_database().session() as session:
account = session.get(Account, account_id)
@@ -113,6 +187,9 @@ class SqlAccessDirectory(AccessDirectory):
return tuple(_group_ref(group) for group in groups)
def display_label(self, subject: AccessSubjectRef) -> str | None:
if subject.kind == "identity":
identity = self.get_identity(subject.id)
return identity.display_name if identity else subject.label
if subject.kind == "account":
account = self.get_account(subject.id)
return account.display_name or account.email if account else subject.label
@@ -122,4 +199,117 @@ class SqlAccessDirectory(AccessDirectory):
if subject.kind == "group":
group = self.get_group(subject.id)
return group.name if group else subject.label
if subject.kind == "organization_unit":
item = self.get_organization_unit(subject.id)
return item.name if item else subject.label
if subject.kind == "function":
item = self.get_function(subject.id)
return item.name if item else subject.label
return subject.label or subject.id
def get_identity(self, identity_id: str) -> IdentityRef | None:
with get_database().session() as session:
identity = session.get(Identity, identity_id)
if identity is None:
return None
links = (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id == identity.id)
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc())
.all()
)
return _identity_ref(identity, links)
def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]:
with get_database().session() as session:
accounts = (
session.query(Account)
.join(IdentityAccountLink, IdentityAccountLink.account_id == Account.id)
.filter(IdentityAccountLink.identity_id == identity_id)
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc())
.all()
)
return tuple(_account_ref(account) for account in accounts)
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
with get_database().session() as session:
item = session.get(OrganizationUnit, organization_unit_id)
return _organization_unit_ref(item) if item is not None else None
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
with get_database().session() as session:
items = (
session.query(OrganizationUnit)
.filter(OrganizationUnit.tenant_id == tenant_id)
.order_by(OrganizationUnit.name.asc())
.all()
)
return tuple(_organization_unit_ref(item) for item in items)
def get_function(self, function_id: str) -> FunctionRef | None:
with get_database().session() as session:
function = session.get(Function, function_id)
if function is None:
return None
role_ids = [
row[0]
for row in session.query(FunctionRoleAssignment.role_id)
.filter(FunctionRoleAssignment.function_id == function.id)
.order_by(FunctionRoleAssignment.created_at.asc())
.all()
]
return _function_ref(function, role_ids)
def functions_for_organization_unit(
self,
organization_unit_id: str,
*,
include_subunits: bool = False,
) -> tuple[FunctionRef, ...]:
with get_database().session() as session:
unit_ids = {organization_unit_id}
if include_subunits:
pending = [organization_unit_id]
while pending:
parent_id = pending.pop()
children = [
row[0]
for row in session.query(OrganizationUnit.id)
.filter(OrganizationUnit.parent_id == parent_id)
.all()
]
for child_id in children:
if child_id not in unit_ids:
unit_ids.add(child_id)
pending.append(child_id)
functions = (
session.query(Function)
.filter(Function.organization_unit_id.in_(unit_ids))
.order_by(Function.name.asc())
.all()
)
role_rows = (
session.query(FunctionRoleAssignment.function_id, FunctionRoleAssignment.role_id)
.filter(FunctionRoleAssignment.function_id.in_([item.id for item in functions]))
.all()
if functions else []
)
role_ids_by_function: dict[str, list[str]] = {}
for function_id, role_id in role_rows:
role_ids_by_function.setdefault(function_id, []).append(role_id)
return tuple(_function_ref(item, role_ids_by_function.get(item.id, [])) for item in functions)
def get_function_assignment(self, assignment_id: str) -> FunctionAssignmentRef | None:
with get_database().session() as session:
item = session.get(FunctionAssignment, assignment_id)
return _function_assignment_ref(item) if item is not None else None
def function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> tuple[FunctionAssignmentRef, ...]:
with get_database().session() as session:
items = active_function_assignments_for_account(session, account_id, tenant_id=tenant_id)
return tuple(_function_assignment_ref(item) for item in items)

View File

@@ -0,0 +1,256 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import (
Account,
Function,
FunctionAssignment,
FunctionDelegation,
FunctionRoleAssignment,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_access.backend.semantic import (
active_function_assignments_for_account,
active_function_delegations_for_account,
identity_id_for_account,
)
from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef
from govoplan_core.db.session import get_database
from govoplan_core.security.permissions import scopes_grant
class SqlAccessExplanationService(AccessExplanationService):
def explain_scope_provenance(
self,
principal: PrincipalRef,
required_scope: str,
) -> tuple[AccessDecisionProvenance, ...]:
with get_database().session() as session:
return tuple(_scope_provenance(session, principal, required_scope))
def explain_resource_provenance(
self,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
) -> tuple[AccessDecisionProvenance, ...]:
del resource_type, resource_id
return self.explain_scope_provenance(principal, action)
def _scope_provenance(session: Session, principal: PrincipalRef, required_scope: str) -> list[AccessDecisionProvenance]:
items: list[AccessDecisionProvenance] = []
account = session.get(Account, principal.account_id)
identity_id = principal.identity_id or identity_id_for_account(session, principal.account_id)
if identity_id:
items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link"))
items.append(
AccessDecisionProvenance(
kind="account",
id=principal.account_id,
label=(account.display_name or account.email) if account else None,
source=principal.auth_method,
)
)
user = session.get(User, principal.membership_id) if principal.membership_id else None
if user is not None:
items.append(
AccessDecisionProvenance(
kind="tenant_membership",
id=user.id,
label=user.display_name or user.email,
tenant_id=user.tenant_id,
source="membership",
)
)
_append_direct_role_provenance(session, items, user, required_scope)
_append_group_role_provenance(session, items, user, required_scope)
_append_function_role_provenance(session, items, user, required_scope)
if account is not None:
_append_system_role_provenance(session, items, account, required_scope)
items.append(AccessDecisionProvenance(kind="right", id=required_scope, label=required_scope))
return items
def _append_direct_role_provenance(
session: Session,
items: list[AccessDecisionProvenance],
user: User,
required_scope: str,
) -> None:
roles = (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id)
.all()
)
for role in roles:
if scopes_grant(role.permissions or [], required_scope):
items.append(
AccessDecisionProvenance(
kind="role",
id=role.id,
label=role.name,
tenant_id=user.tenant_id,
source="direct_user_role",
)
)
def _append_group_role_provenance(
session: Session,
items: list[AccessDecisionProvenance],
user: User,
required_scope: str,
) -> None:
rows = (
session.query(Group, Role)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.join(GroupRoleAssignment, GroupRoleAssignment.group_id == Group.id)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.filter(
UserGroupMembership.tenant_id == user.tenant_id,
UserGroupMembership.user_id == user.id,
GroupRoleAssignment.tenant_id == user.tenant_id,
Group.is_active.is_(True),
)
.all()
)
for group, role in rows:
if scopes_grant(role.permissions or [], required_scope):
items.append(
AccessDecisionProvenance(
kind="group",
id=group.id,
label=group.name,
tenant_id=user.tenant_id,
source="group_membership",
)
)
items.append(
AccessDecisionProvenance(
kind="role",
id=role.id,
label=role.name,
tenant_id=user.tenant_id,
source="group_role",
details={"group_id": group.id},
)
)
def _append_function_role_provenance(
session: Session,
items: list[AccessDecisionProvenance],
user: User,
required_scope: str,
) -> None:
direct_assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
for assignment in direct_assignments:
_append_function_assignment_provenance(session, items, assignment, required_scope, source="function_assignment")
delegations = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
for delegation in delegations:
assignment = session.get(FunctionAssignment, delegation.function_assignment_id)
if assignment is None:
continue
items.append(
AccessDecisionProvenance(
kind="delegation",
id=delegation.id,
label=delegation.mode,
tenant_id=delegation.tenant_id,
source="function_delegation",
details={
"function_assignment_id": delegation.function_assignment_id,
"delegator_account_id": delegation.delegator_account_id,
"delegate_account_id": delegation.delegate_account_id,
},
)
)
_append_function_assignment_provenance(session, items, assignment, required_scope, source="delegated_function")
def _append_function_assignment_provenance(
session: Session,
items: list[AccessDecisionProvenance],
assignment: FunctionAssignment,
required_scope: str,
*,
source: str,
) -> None:
function = session.get(Function, assignment.function_id)
if function is None:
return
roles = (
session.query(Role)
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
.filter(FunctionRoleAssignment.tenant_id == assignment.tenant_id, FunctionRoleAssignment.function_id == function.id)
.all()
)
matching = [role for role in roles if scopes_grant(role.permissions or [], required_scope)]
if not matching:
return
items.append(
AccessDecisionProvenance(
kind="organization_unit",
id=assignment.organization_unit_id,
tenant_id=assignment.tenant_id,
source=source,
details={"applies_to_subunits": assignment.applies_to_subunits},
)
)
items.append(
AccessDecisionProvenance(
kind="function",
id=assignment.id,
label=function.name,
tenant_id=assignment.tenant_id,
source=source,
details={"function_id": function.id, "organization_unit_id": assignment.organization_unit_id},
)
)
for role in matching:
items.append(
AccessDecisionProvenance(
kind="role",
id=role.id,
label=role.name,
tenant_id=assignment.tenant_id,
source=source,
details={"function_assignment_id": assignment.id, "function_id": function.id},
)
)
def _append_system_role_provenance(
session: Session,
items: list[AccessDecisionProvenance],
account: Account,
required_scope: str,
) -> None:
roles = (
session.query(Role)
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
.all()
)
for role in roles:
if scopes_grant(role.permissions or [], required_scope):
items.append(
AccessDecisionProvenance(
kind="role",
id=role.id,
label=role.name,
source="system_role",
)
)

View File

@@ -1,17 +1,34 @@
from __future__ import annotations
from pathlib import Path
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
from govoplan_core.core.access import (
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
)
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
@@ -44,6 +61,10 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"),
_permission("access:role:write", "Manage roles", "Create and update assignable roles.", "Tenant access", "tenant"),
_permission("access:role:assign", "Assign roles", "Bind roles to memberships, groups, accounts or services.", "Tenant access", "tenant"),
_permission("access:function:read", "View functions", "Inspect organization-bound functions and assignments.", "Tenant access", "tenant"),
_permission("access:function:write", "Manage functions", "Create and update organization units, functions, and role mappings.", "Tenant access", "tenant"),
_permission("access:function:assign", "Assign functions", "Assign organization-bound functions to accounts.", "Tenant access", "tenant"),
_permission("access:function:delegate", "Delegate functions", "Create and revoke permitted function delegations.", "Tenant access", "tenant"),
_permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"),
_permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"),
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
@@ -103,6 +124,10 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:group:manage_members",
"access:role:read",
"access:role:assign",
"access:function:read",
"access:function:write",
"access:function:assign",
"access:function:delegate",
"access:api_key:read",
"access:api_key:create",
"access:api_key:revoke",
@@ -128,6 +153,169 @@ ADMIN_READ_SCOPES = (
"access:tenant:read",
"access:account:read",
"access:governance:read",
"access:function:read",
)
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
DocumentationTopic(
id="access.workflow.grant-user-access",
title="Grant a person access",
summary="Use the access administration screens to create or update a tenant membership, place the person in groups, and assign only the roles they need.",
body="The common path is to find or create the person, review their existing membership, then use groups and roles to grant access. If a role or group is not available, the active governance rules or your own delegation limit may block the change.",
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin"),
order=30,
conditions=(
DocumentationCondition(
required_modules=("access",),
any_scopes=(
"admin:users:read",
"admin:groups:read",
"admin:roles:read",
"access:membership:read",
"access:group:read",
"access:role:read",
),
),
),
links=(
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"),
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
),
configuration_keys=("access_governance",),
metadata={
"kind": "workflow",
"outcome": "A person can sign in to the tenant and receives the intended access through groups and roles.",
"prerequisites": [
"You can open Admin.",
"You may read users, groups, and roles.",
"Write or assignment actions require matching management permissions.",
],
"steps": [
"Open Admin and go to Users.",
"Find the existing person or create a membership with their email address and display name.",
"Review current groups and direct roles before changing anything.",
"Add the person to the smallest group that grants the needed shared access.",
"Assign direct roles only when a group does not match the case.",
"Save and review any blocker message before asking a system or tenant owner for help.",
],
"result": "The membership has the intended effective permissions and no broader roles than necessary.",
"verification": "Open the user again and compare groups, direct roles, and effective permissions with the request.",
"related_field_ids": [
"access.user.email",
"access.user.display_name",
"access.user.groups",
"access.user.roles",
],
"related_topic_ids": [
"access.reference.admin-access-fields",
"docs.pattern.field-help",
],
},
),
DocumentationTopic(
id="access.reference.admin-access-fields",
title="Access administration fields",
summary="The access administration screens show tenant memberships, groups, roles, and API keys. Admin docs map the visible fields to API payloads and permission scopes.",
body="Users need the visible labels and a short explanation. Admins also need the backing route, API field, permission, and governance note so they can diagnose unavailable actions.",
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
order=31,
conditions=(
DocumentationCondition(
required_modules=("access",),
any_scopes=(
"admin:users:read",
"admin:groups:read",
"admin:roles:read",
"admin:api_keys:read",
"access:membership:read",
"access:group:read",
"access:role:read",
"access:api_key:read",
),
),
),
links=(
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"),
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
),
configuration_keys=("access_governance",),
metadata={
"kind": "reference",
"route": "/admin",
"screen": "Admin",
"section": "Users, groups, roles, and API keys",
"fields": [
{
"field_id": "access.user.email",
"label": "Email",
"user_description": "The address used to identify the person when they sign in.",
"admin_description": "Stored on the account and membership payloads. It must be normalized and unique for the relevant login account.",
"api_path": "/api/v1/admin/users",
"api_field": "email",
"permission_scope": "access:membership:create",
"validation": "Must be a valid email address.",
"provenance": "Tenant membership creation or account lookup.",
},
{
"field_id": "access.user.display_name",
"label": "Display name",
"user_description": "The readable name shown in user lists and review screens.",
"admin_description": "Maps to display_name on user and account responses where available.",
"api_path": "/api/v1/admin/users",
"api_field": "display_name",
"permission_scope": "access:membership:update",
"validation": "Human-readable text; keep it recognizable for administrators.",
"provenance": "Tenant membership profile.",
},
{
"field_id": "access.user.groups",
"label": "Groups",
"user_description": "Shared access bundles that can add roles for many people at once.",
"admin_description": "Maps to group_ids when updating a user or group membership.",
"api_path": "/api/v1/admin/users/{user_id}",
"api_field": "group_ids",
"permission_scope": "access:group:manage_members",
"validation": "Groups must belong to the same tenant.",
"provenance": "User-group membership rows.",
},
{
"field_id": "access.user.roles",
"label": "Roles",
"user_description": "Direct access grants assigned to one person or inherited from groups.",
"admin_description": "Maps to role_ids on user and group role update requests.",
"api_path": "/api/v1/admin/users/{user_id}",
"api_field": "role_ids",
"permission_scope": "access:role:assign",
"validation": "Roles must be assignable and cannot exceed the actor's delegation limit.",
"provenance": "Direct user roles plus group role inheritance.",
},
{
"field_id": "access.api_key.scopes",
"label": "Scopes",
"user_description": "The actions an API key may perform.",
"admin_description": "Maps to scopes when creating an API key and is intersected with the owner's current permissions.",
"api_path": "/api/v1/admin/api-keys",
"api_field": "scopes",
"permission_scope": "access:api_key:create",
"validation": "Use the narrowest scopes possible.",
"provenance": "API key grant plus owner delegation.",
},
],
"related_topic_ids": [
"access.workflow.grant-user-access",
"docs.pattern.field-help",
],
},
),
)
@@ -152,6 +340,20 @@ def _access_directory(context: ModuleContext) -> object:
return SqlAccessDirectory()
def _access_semantic_directory(context: ModuleContext) -> object:
del context
from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory()
def _access_explanation_service(context: ModuleContext) -> object:
del context
from govoplan_access.backend.explanation import SqlAccessExplanationService
return SqlAccessExplanationService()
def _tenant_provisioner(context: ModuleContext) -> object:
del context
from govoplan_access.backend.tenancy.provisioning import LegacyTenantAccessProvisioner
@@ -173,6 +375,13 @@ def _governance_materializer(context: ModuleContext) -> object:
return SqlAccessGovernanceMaterializer()
def _configuration_provider(context: ModuleContext) -> object:
del context
from govoplan_access.backend.configuration_provider import SqlAccessConfigurationProvider
return SqlAccessConfigurationProvider()
def _route_factory(context: ModuleContext):
from fastapi import APIRouter
@@ -194,16 +403,28 @@ manifest = ModuleManifest(
name="Access",
version="0.1.6",
dependencies=("tenancy",),
optional_dependencies=("identity", "organizations"),
permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES,
route_factory=_route_factory,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
migration_spec=MigrationSpec(
module_id="access",
metadata=AccessBase.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
access_models.Account,
access_models.Identity,
access_models.IdentityAccountLink,
access_models.User,
access_models.Group,
access_models.Role,
access_models.OrganizationUnit,
access_models.Function,
access_models.FunctionRoleAssignment,
access_models.FunctionAssignment,
access_models.FunctionDelegation,
access_models.SystemRoleAssignment,
access_models.UserGroupMembership,
access_models.UserRoleAssignment,
@@ -224,10 +445,14 @@ manifest = ModuleManifest(
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_ACCESS_DIRECTORY: _access_directory,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: _access_semantic_directory,
CAPABILITY_ACCESS_EXPLANATION: _access_explanation_service,
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
},
documentation=ACCESS_DOCUMENTATION,
)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,396 @@
"""access semantic directory
Revision ID: 4a5b6c7d8e9f
Revises: 3f4a5b6c7d8e
Create Date: 2026-07-10 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4a5b6c7d8e9f"
down_revision = "3f4a5b6c7d8e"
branch_labels = None
depends_on = None
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def _indexes(table_name: str) -> set[str]:
return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)}
def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False, **kwargs) -> None:
if table_name in _tables() and name not in _indexes(table_name):
op.create_index(name, table_name, columns, unique=unique, **kwargs)
def _drop_index_if_exists(name: str, table_name: str) -> None:
if table_name in _tables() and name in _indexes(table_name):
op.drop_index(name, table_name=table_name)
def upgrade() -> None:
tables = _tables()
if "access_identities" not in tables:
op.create_table(
"access_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=True),
sa.Column("external_subject", sa.String(length=255), nullable=True),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_identities")),
)
_create_index_if_missing(op.f("ix_access_identities_external_subject"), "access_identities", ["external_subject"])
tables = _tables()
if "access_identity_account_links" not in tables:
op.create_table(
"access_identity_account_links",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=False),
sa.Column("is_primary", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["access_accounts.id"],
name=op.f("fk_access_identity_account_links_account_id_access_accounts"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["identity_id"],
["access_identities.id"],
name=op.f("fk_access_identity_account_links_identity_id_access_identities"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_identity_account_links")),
sa.UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
)
_create_index_if_missing(op.f("ix_access_identity_account_links_account_id"), "access_identity_account_links", ["account_id"])
_create_index_if_missing(op.f("ix_access_identity_account_links_identity_id"), "access_identity_account_links", ["identity_id"])
_create_index_if_missing(
"uq_identity_account_links_primary_account",
"access_identity_account_links",
["account_id"],
unique=True,
sqlite_where=sa.text("is_primary = 1"),
postgresql_where=sa.text("is_primary IS TRUE"),
)
_create_index_if_missing(
"uq_identity_account_links_primary_identity",
"access_identity_account_links",
["identity_id"],
unique=True,
sqlite_where=sa.text("is_primary = 1"),
postgresql_where=sa.text("is_primary IS TRUE"),
)
tables = _tables()
if "access_organization_units" not in tables:
op.create_table(
"access_organization_units",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("parent_id", sa.String(length=36), nullable=True),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["parent_id"],
["access_organization_units.id"],
name=op.f("fk_access_organization_units_parent_id_access_organization_units"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenancy_tenants.id"],
name=op.f("fk_access_organization_units_tenant_id_tenancy_tenants"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_organization_units")),
sa.UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),
)
_create_index_if_missing(op.f("ix_access_organization_units_parent_id"), "access_organization_units", ["parent_id"])
_create_index_if_missing(op.f("ix_access_organization_units_tenant_id"), "access_organization_units", ["tenant_id"])
tables = _tables()
if "access_functions" not in tables:
op.create_table(
"access_functions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("delegable", sa.Boolean(), nullable=False),
sa.Column("act_in_place_allowed", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["organization_unit_id"],
["access_organization_units.id"],
name=op.f("fk_access_functions_organization_unit_id_access_organization_units"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenancy_tenants.id"],
name=op.f("fk_access_functions_tenant_id_tenancy_tenants"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_functions")),
sa.UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),
)
_create_index_if_missing(op.f("ix_access_functions_organization_unit_id"), "access_functions", ["organization_unit_id"])
_create_index_if_missing(op.f("ix_access_functions_tenant_id"), "access_functions", ["tenant_id"])
tables = _tables()
if "access_function_role_assignments" not in tables:
op.create_table(
"access_function_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["function_id"],
["access_functions.id"],
name=op.f("fk_access_function_role_assignments_function_id_access_functions"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["role_id"],
["access_roles.id"],
name=op.f("fk_access_function_role_assignments_role_id_access_roles"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenancy_tenants.id"],
name=op.f("fk_access_function_role_assignments_tenant_id_tenancy_tenants"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_role_assignments")),
sa.UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_function_role_assignments"),
)
_create_index_if_missing(op.f("ix_access_function_role_assignments_function_id"), "access_function_role_assignments", ["function_id"])
_create_index_if_missing(op.f("ix_access_function_role_assignments_role_id"), "access_function_role_assignments", ["role_id"])
_create_index_if_missing(op.f("ix_access_function_role_assignments_tenant_id"), "access_function_role_assignments", ["tenant_id"])
tables = _tables()
if "access_function_assignments" not in tables:
op.create_table(
"access_function_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["access_accounts.id"],
name=op.f("fk_access_function_assignments_account_id_access_accounts"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["acting_for_account_id"],
["access_accounts.id"],
name=op.f("fk_access_function_assignments_acting_for_account_id_access_accounts"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["delegated_from_assignment_id"],
["access_function_assignments.id"],
name=op.f("fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["function_id"],
["access_functions.id"],
name=op.f("fk_access_function_assignments_function_id_access_functions"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["identity_id"],
["access_identities.id"],
name=op.f("fk_access_function_assignments_identity_id_access_identities"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["organization_unit_id"],
["access_organization_units.id"],
name=op.f("fk_access_function_assignments_organization_unit_id_access_organization_units"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenancy_tenants.id"],
name=op.f("fk_access_function_assignments_tenant_id_tenancy_tenants"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_assignments")),
sa.UniqueConstraint("tenant_id", "account_id", "function_id", "organization_unit_id", name="uq_function_assignments_account_scope"),
)
_create_index_if_missing(op.f("ix_access_function_assignments_account_id"), "access_function_assignments", ["account_id"])
_create_index_if_missing(op.f("ix_access_function_assignments_acting_for_account_id"), "access_function_assignments", ["acting_for_account_id"])
_create_index_if_missing(op.f("ix_access_function_assignments_delegated_from_assignment_id"), "access_function_assignments", ["delegated_from_assignment_id"])
_create_index_if_missing(op.f("ix_access_function_assignments_function_id"), "access_function_assignments", ["function_id"])
_create_index_if_missing(op.f("ix_access_function_assignments_identity_id"), "access_function_assignments", ["identity_id"])
_create_index_if_missing(op.f("ix_access_function_assignments_organization_unit_id"), "access_function_assignments", ["organization_unit_id"])
_create_index_if_missing(op.f("ix_access_function_assignments_tenant_id"), "access_function_assignments", ["tenant_id"])
tables = _tables()
if "access_function_delegations" not in tables:
op.create_table(
"access_function_delegations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("function_assignment_id", sa.String(length=36), nullable=False),
sa.Column("delegator_account_id", sa.String(length=36), nullable=False),
sa.Column("delegate_account_id", sa.String(length=36), nullable=False),
sa.Column("mode", sa.String(length=30), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["delegate_account_id"],
["access_accounts.id"],
name=op.f("fk_access_function_delegations_delegate_account_id_access_accounts"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["delegator_account_id"],
["access_accounts.id"],
name=op.f("fk_access_function_delegations_delegator_account_id_access_accounts"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["function_assignment_id"],
["access_function_assignments.id"],
name=op.f("fk_access_function_delegations_function_assignment_id_access_function_assignments"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenancy_tenants.id"],
name=op.f("fk_access_function_delegations_tenant_id_tenancy_tenants"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_delegations")),
sa.UniqueConstraint("tenant_id", "function_assignment_id", "delegate_account_id", "mode", name="uq_function_delegations_assignment_delegate_mode"),
)
_create_index_if_missing(op.f("ix_access_function_delegations_delegate_account_id"), "access_function_delegations", ["delegate_account_id"])
_create_index_if_missing(op.f("ix_access_function_delegations_delegator_account_id"), "access_function_delegations", ["delegator_account_id"])
_create_index_if_missing(op.f("ix_access_function_delegations_function_assignment_id"), "access_function_delegations", ["function_assignment_id"])
_create_index_if_missing(op.f("ix_access_function_delegations_revoked_at"), "access_function_delegations", ["revoked_at"])
_create_index_if_missing(op.f("ix_access_function_delegations_tenant_id"), "access_function_delegations", ["tenant_id"])
def downgrade() -> None:
for table_name, indexes in (
(
"access_function_delegations",
(
op.f("ix_access_function_delegations_tenant_id"),
op.f("ix_access_function_delegations_revoked_at"),
op.f("ix_access_function_delegations_function_assignment_id"),
op.f("ix_access_function_delegations_delegator_account_id"),
op.f("ix_access_function_delegations_delegate_account_id"),
),
),
(
"access_function_assignments",
(
op.f("ix_access_function_assignments_tenant_id"),
op.f("ix_access_function_assignments_organization_unit_id"),
op.f("ix_access_function_assignments_identity_id"),
op.f("ix_access_function_assignments_function_id"),
op.f("ix_access_function_assignments_delegated_from_assignment_id"),
op.f("ix_access_function_assignments_acting_for_account_id"),
op.f("ix_access_function_assignments_account_id"),
),
),
(
"access_function_role_assignments",
(
op.f("ix_access_function_role_assignments_tenant_id"),
op.f("ix_access_function_role_assignments_role_id"),
op.f("ix_access_function_role_assignments_function_id"),
),
),
(
"access_functions",
(
op.f("ix_access_functions_tenant_id"),
op.f("ix_access_functions_organization_unit_id"),
),
),
(
"access_organization_units",
(
op.f("ix_access_organization_units_tenant_id"),
op.f("ix_access_organization_units_parent_id"),
),
),
(
"access_identity_account_links",
(
"uq_identity_account_links_primary_identity",
"uq_identity_account_links_primary_account",
op.f("ix_access_identity_account_links_identity_id"),
op.f("ix_access_identity_account_links_account_id"),
),
),
("access_identities", (op.f("ix_access_identities_external_subject"),)),
):
for index_name in indexes:
_drop_index_if_exists(index_name, table_name)
for table_name in (
"access_function_delegations",
"access_function_assignments",
"access_function_role_assignments",
"access_functions",
"access_organization_units",
"access_identity_account_links",
"access_identities",
):
if table_name in _tables():
op.drop_table(table_name)

View File

@@ -0,0 +1 @@

View File

@@ -18,6 +18,7 @@ from govoplan_access.backend.db.models import (
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_access.backend.semantic import collect_function_roles
from govoplan_core.security.permissions import expand_scopes
from govoplan_core.security.time import ensure_aware_utc, utc_now
@@ -169,6 +170,8 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
)
for role in group_roles:
roles_by_id[role.id] = role
for role in collect_function_roles(session, user):
roles_by_id[role.id] = role
return list(roles_by_id.values())
@@ -218,4 +221,3 @@ def collect_tenant_memberships(session: Session, account: Account) -> list[tuple
.order_by(Tenant.name.asc())
.all()
)

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import (
Account,
Function,
FunctionAssignment,
FunctionDelegation,
FunctionRoleAssignment,
Identity,
IdentityAccountLink,
Role,
User,
)
from govoplan_core.security.time import utc_now
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
return (
session.query(Identity)
.join(IdentityAccountLink, IdentityAccountLink.identity_id == Identity.id)
.filter(
IdentityAccountLink.account_id == account_id,
IdentityAccountLink.is_primary.is_(True),
Identity.is_active.is_(True),
)
.order_by(IdentityAccountLink.created_at.asc())
.first()
)
def identity_id_for_account(session: Session, account_id: str) -> str | None:
identity = primary_identity_for_account(session, account_id)
return identity.id if identity is not None else None
def ensure_identity_for_account(session: Session, account: Account, *, source: str = "local") -> Identity:
identity = primary_identity_for_account(session, account.id)
if identity is not None:
return identity
identity = Identity(display_name=account.display_name or account.email, source=source)
session.add(identity)
session.flush()
session.add(IdentityAccountLink(identity_id=identity.id, account_id=account.id, is_primary=True, source=source))
session.flush()
return identity
def active_function_assignments_for_account(
session: Session,
account_id: str,
*,
tenant_id: str | None = None,
) -> list[FunctionAssignment]:
now = utc_now()
query = (
session.query(FunctionAssignment)
.join(Function, Function.id == FunctionAssignment.function_id)
.filter(
FunctionAssignment.account_id == account_id,
FunctionAssignment.is_active.is_(True),
Function.is_active.is_(True),
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
)
.order_by(FunctionAssignment.created_at.asc())
)
if tenant_id is not None:
query = query.filter(FunctionAssignment.tenant_id == tenant_id, Function.tenant_id == tenant_id)
return query.all()
def active_function_delegations_for_account(
session: Session,
account_id: str,
*,
tenant_id: str | None = None,
modes: Iterable[str] = ("delegate",),
) -> list[FunctionDelegation]:
now = utc_now()
mode_set = sorted({str(mode) for mode in modes})
if not mode_set:
return []
query = (
session.query(FunctionDelegation)
.join(FunctionAssignment, FunctionAssignment.id == FunctionDelegation.function_assignment_id)
.join(Function, Function.id == FunctionAssignment.function_id)
.filter(
FunctionDelegation.delegate_account_id == account_id,
FunctionDelegation.mode.in_(mode_set),
FunctionDelegation.is_active.is_(True),
FunctionDelegation.revoked_at.is_(None),
FunctionAssignment.is_active.is_(True),
Function.is_active.is_(True),
Function.delegable.is_(True),
or_(FunctionDelegation.valid_from.is_(None), FunctionDelegation.valid_from <= now),
or_(FunctionDelegation.valid_until.is_(None), FunctionDelegation.valid_until > now),
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
)
.order_by(FunctionDelegation.created_at.asc())
)
if tenant_id is not None:
query = query.filter(FunctionDelegation.tenant_id == tenant_id, FunctionAssignment.tenant_id == tenant_id)
return query.all()
def collect_function_assignment_ids(session: Session, user: User) -> list[str]:
ids = [item.id for item in active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)]
for delegation in active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id):
ids.append(delegation.function_assignment_id)
return sorted(dict.fromkeys(ids))
def collect_function_delegation_ids(session: Session, user: User) -> list[str]:
return [
item.id
for item in active_function_delegations_for_account(
session,
user.account_id,
tenant_id=user.tenant_id,
modes=("delegate", "act_in_place"),
)
]
def collect_function_roles(session: Session, user: User) -> list[Role]:
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
delegated = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
assignment_ids = {assignment.id for assignment in assignments}
assignment_ids.update(delegation.function_assignment_id for delegation in delegated)
if not assignment_ids:
return []
function_ids = [
row[0]
for row in session.query(FunctionAssignment.function_id)
.filter(FunctionAssignment.tenant_id == user.tenant_id, FunctionAssignment.id.in_(assignment_ids))
.all()
]
if not function_ids:
return []
return (
session.query(Role)
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
.filter(
FunctionRoleAssignment.tenant_id == user.tenant_id,
FunctionRoleAssignment.function_id.in_(function_ids),
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)

View File

@@ -14,7 +14,7 @@
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"

View File

@@ -1,4 +1,4 @@
import type { ApiSettings } from "@govoplan/core-webui";
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui";
import { apiFetch } from "@govoplan/core-webui";
export type PermissionItem = {
@@ -171,17 +171,35 @@ export type SystemSettingsItem = {
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
available_languages?: LanguagePackage[];
enabled_language_codes?: string[];
settings: Record<string, unknown>;
};
export type LanguagePackage = {
code: string;
label: string;
native_label?: string | null;
};
export type TenantSettingsItem = {
id: string;
slug: string;
name: string;
default_locale: string;
available_languages: LanguagePackage[];
system_enabled_language_codes: string[];
enabled_language_codes: string[];
settings: Record<string, unknown>;
};
export type TenantSettingsDeltaSections = Partial<{
identity: Pick<TenantSettingsItem, "id" | "slug" | "name">;
locale: Pick<TenantSettingsItem, "default_locale">;
languages: Pick<TenantSettingsItem, "available_languages" | "system_enabled_language_codes" | "enabled_language_codes">;
settings: Pick<TenantSettingsItem, "settings">["settings"];
}>;
export type RetentionRunResponse = {
result: {
dry_run: boolean;
@@ -236,6 +254,42 @@ export type AuditAdminItem = {
created_at: string;
};
type DeltaResponseFields = {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type UserListDeltaResponse = { users: UserAdminItem[] } & DeltaResponseFields;
export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseFields;
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields;
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
export type TenantSettingsDeltaResponse = {
item?: TenantSettingsItem | null;
sections: TenantSettingsDeltaSections;
changed_sections: string[];
} & DeltaResponseFields;
export type AuditAdminDeltaResponse = {
items: AuditAdminItem[];
total: number;
page: number;
page_size: number;
pages: number;
cursor?: string | null;
next_cursor?: string | null;
} & DeltaResponseFields;
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
const params = new URLSearchParams();
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
return params.toString() ? `?${params.toString()}` : "";
}
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
@@ -252,6 +306,11 @@ export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminIt
return response.tenants;
}
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
@@ -289,7 +348,12 @@ export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettin
return apiFetch(settings, "/api/v1/admin/tenant/settings");
}
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise<TenantSettingsItem> {
export function fetchTenantSettingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantSettingsDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/tenant/settings/delta${suffix}`);
}
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
@@ -298,6 +362,11 @@ export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]
return response.users;
}
export function fetchUsersDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<UserListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/users/delta${suffix}`);
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
@@ -324,6 +393,11 @@ export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]
return response.groups;
}
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/groups/delta${suffix}`);
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
@@ -350,6 +424,11 @@ export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]>
return response.roles;
}
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/roles/delta${suffix}`);
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
@@ -377,6 +456,11 @@ export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSumma
return response.roles;
}
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/system/roles/delta${suffix}`);
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
@@ -403,6 +487,11 @@ export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ acco
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function fetchSystemAccountsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<SystemAccountListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/system/accounts/delta${suffix}`);
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
@@ -429,6 +518,15 @@ export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false
return response.api_keys;
}
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`);
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
@@ -450,12 +548,13 @@ export type AuditQueryOptions = {
offset?: number;
page?: number;
pageSize?: number;
cursor?: string | null;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number; cursor?: string | null; next_cursor?: string | null }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
@@ -464,6 +563,7 @@ export async function fetchAdminAudit(settings: ApiSettings, options: AuditQuery
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
@@ -473,6 +573,24 @@ export async function fetchAdminAudit(settings: ApiSettings, options: AuditQuery
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export async function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise<AuditAdminDeltaResponse> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
if (options.since) params.set("since", options.since);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit/delta${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: {
email: string;
@@ -502,6 +620,8 @@ export type SystemSettingsUpdatePayload = {
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
available_languages?: LanguagePackage[] | null;
enabled_language_codes?: string[] | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
@@ -532,6 +652,11 @@ export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "gr
return response.templates;
}
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/system/governance-templates/delta${suffix}`);
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { fetchAdminAudit, type AuditAdminItem } from "../../api/admin";
import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui";
type AuditSortBy = "time" | "actor" | "action" | "object" | "tenant";
const DEFAULT_QUERY: DataGridQueryState = {
sort: { columnId: "time", direction: "desc" },
@@ -16,12 +18,16 @@ export default function AdminAuditPanel({
settings,
auth,
systemMode = false
}: {
settings: ApiSettings;
auth: AuthInfo;
systemMode?: boolean;
}) {
}: {settings: ApiSettings;auth: AuthInfo;systemMode?: boolean;}) {
const [items, setItems] = useState<AuditAdminItem[]>([]);
const itemsRef = useRef<AuditAdminItem[]>([]);
const pageItemsRef = useRef<Record<string, AuditAdminItem[]>>({});
const pageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -37,27 +43,60 @@ export default function AdminAuditPanel({
setError("");
try {
const sortColumn = query.sort?.columnId;
const response = await fetchAdminAudit(settings, {
const sortBy = sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn) ?
sortColumn as AuditSortBy :
"time";
const sortDirection = query.sort?.direction ?? "desc";
const filters = query.filters;
const pageCursor = page === 1 ? null : pageCursorsRef.current[page];
const deltaMode = page === 1 || pageCursor !== undefined;
const requestOptions = {
scope: systemMode ? "system" : "tenant",
page,
pageSize,
sortBy: sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn)
? sortColumn as "time" | "actor" | "action" | "object" | "tenant"
: "time",
sortDirection: query.sort?.direction ?? "desc",
filters: query.filters
});
setItems(response.items);
cursor: pageCursor,
sortBy,
sortDirection,
filters
};
const deltaKey = `access:audit:${systemMode ? "system" : "tenant"}:${tenantId}:${pageSize}:${page}:${pageCursor ?? "root"}:${JSON.stringify({ sortBy, sortDirection, filters })}`;
const response = deltaMode
? await fetchAdminAuditDelta(settings, { ...requestOptions, since: getDeltaWatermark(deltaKey) })
: await fetchAdminAudit(settings, requestOptions);
const baseItems = pageItemsRef.current[deltaKey] ?? [];
const nextItems = "full" in response && !response.full
? mergeDeltaRows(baseItems, response.items, response.deleted, (item) => item.id, { sort: compareAuditEvents(sortBy, sortDirection) }).slice(0, pageSize)
: response.items;
pageItemsRef.current[deltaKey] = nextItems;
itemsRef.current = nextItems;
setItems(nextItems);
setTotal(response.total);
if (response.page !== page) setPage(response.page);
if (!deltaMode && response.page !== page) setPage(response.page);
if (response.cursor !== undefined) pageCursorsRef.current[page] = response.cursor ?? null;
if (response.next_cursor !== undefined) {
if (response.next_cursor) pageCursorsRef.current[page + 1] = response.next_cursor;
else delete pageCursorsRef.current[page + 1];
}
if ("full" in response && !response.full && page === 1 && (response.items.length > 0 || response.deleted.length > 0)) {
pageCursorsRef.current = { 1: null };
}
if ("watermark" in response) setDeltaWatermark(deltaKey, response.watermark);
if (!deltaMode) resetDeltaWatermark(deltaKey);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken]);
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
itemsRef.current = [];
pageItemsRef.current = {};
pageCursorsRef.current = { 1: null };
resetDeltaWatermark();
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, pageSize, query, resetDeltaWatermark]);
useEffect(() => {void load();}, [load]);
const handleQueryChange = useCallback((next: DataGridQueryState) => {
setQuery((current) => {
@@ -68,13 +107,13 @@ export default function AdminAuditPanel({
}, []);
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
{ id: "time", header: "Time", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
{ id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() },
...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="Inspect audit event" icon={<Search />} onClick={() => setSelected(row)} /></div> }
], [systemMode]);
{ id: "time", header: "i18n:govoplan-access.time.6c82e6dd", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "actor", header: "i18n:govoplan-access.actor.cbd19b5c", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "i18n:govoplan-access.system.bc0792d8" },
{ id: "action", header: "i18n:govoplan-access.action.97c89a4d", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
{ id: "object", header: "i18n:govoplan-access.object.2883f191", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() },
...(systemMode ? [{ id: "tenant", header: "i18n:govoplan-access.tenant_context.b401a2ad", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="i18n:govoplan-access.inspect_audit_event.5776c1b2" icon={<Search />} onClick={() => setSelected(row)} /></div> }],
[systemMode]);
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
const lastShown = Math.min(total, page * pageSize);
@@ -82,21 +121,21 @@ export default function AdminAuditPanel({
return (
<>
<AdminPageLayout
title={systemMode ? "System audit" : "Tenant audit"}
description={systemMode
? `System-level administrative history. Showing ${firstShown}${lastShown} of ${total} records.`
: `Tenant-level administrative history for the active tenant. Showing ${firstShown}${lastShown} of ${total} records.`}
title={systemMode ? "i18n:govoplan-access.system_audit.69c6b424" : "i18n:govoplan-access.tenant_audit.492b9138"}
description={systemMode ? i18nMessage("i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1", { value0:
firstShown, value1: lastShown, value2: total }) : i18nMessage("i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff", { value0:
firstShown, value1: lastShown, value2: total })}
loading={loading}
error={error}
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>Reload</Button>}
>
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>}>
<div className="admin-table-surface">
<DataGrid
id={systemMode ? "admin-system-audit-v5" : "admin-tenant-audit-v5"}
rows={items}
columns={columns}
initialFit="container" getRowKey={(row) => row.id}
emptyText="No administrative audit records found."
emptyText="i18n:govoplan-access.no_administrative_audit_records_found.8d128767"
className="admin-audit-grid"
initialSort={{ columnId: "time", direction: "desc" }}
pagination={{
@@ -107,15 +146,36 @@ export default function AdminAuditPanel({
pageSizeOptions: [10, 25, 50, 100, 250],
disabled: loading,
onPageChange: setPage,
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
onPageSizeChange: (next) => {setPageSize(next);setPage(1);}
}}
onQueryChange={handleQueryChange}
/>
onQueryChange={handleQueryChange} />
</div>
</AdminPageLayout>
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
{selected && <><dl className="admin-details-grid"><div><dt>Scope</dt><dd>{selected.scope}</dd></div><div><dt>Action</dt><dd>{selected.action}</dd></div><div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div><div><dt>Object</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>Tenant context</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
<Dialog open={Boolean(selected)} title="i18n:govoplan-access.audit_event_details.3749b52d" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{selected && <><dl className="admin-details-grid"><div><dt>i18n:govoplan-access.scope.4651a34e</dt><dd>{selected.scope}</dd></div><div><dt>i18n:govoplan-access.action.97c89a4d</dt><dd>{selected.action}</dd></div><div><dt>i18n:govoplan-access.actor.cbd19b5c</dt><dd>{selected.actor_email || "i18n:govoplan-access.system.bc0792d8"}</dd></div><div><dt>i18n:govoplan-access.object.2883f191</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>i18n:govoplan-access.tenant_context.b401a2ad</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>i18n:govoplan-access.time.6c82e6dd</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
</Dialog>
</>
);
</>);
}
function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number {
return (left, right) => {
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
const directed = sortDirection === "asc" ? primary : -primary;
return directed || right.id.localeCompare(left.id);
};
}
function auditSortValue(item: AuditAdminItem, sortBy: AuditSortBy): string | number {
if (sortBy === "time") return new Date(item.created_at).getTime();
if (sortBy === "actor") return item.actor_email || "System";
if (sortBy === "action") return item.action;
if (sortBy === "object") return `${item.object_type || ""} ${item.object_id || ""}`;
return item.tenant_id || "";
}
function compareAuditValues(left: string | number, right: string | number): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left).localeCompare(String(right));
}

View File

@@ -1,6 +1,13 @@
import { useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { AdminSectionContribution, AdminSectionsUiCapability, ApiSettings, AuthInfo, MailProfilesUiCapability } from "@govoplan/core-webui";
import type {
AdminSectionContribution,
AdminSectionsUiCapability,
ApiSettings,
AuthInfo,
FilesConnectorsUiCapability,
MailProfilesUiCapability
} from "@govoplan/core-webui";
import { fetchMe } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
@@ -14,6 +21,7 @@ import GroupsPanel from "./GroupsPanel";
import RolesPanel from "./RolesPanel";
import ApiKeysPanel from "./ApiKeysPanel";
import AdminAuditPanel from "./AdminAuditPanel";
import FileConnectorsPanel from "./FileConnectorsPanel";
import MailProfilesPanel from "./MailProfilesPanel";
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
@@ -21,6 +29,38 @@ import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCap
type AdminSection = string;
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
const handledAdminSectionIds = new Set<string>([
"overview",
"system-settings",
"system-configuration-changes",
"system-configuration-packages",
"system-modules",
"system-audit",
"system-tenants",
"system-roles",
"system-role-templates",
"system-groups",
"system-users",
"system-file-connectors",
"system-mail-servers",
"system-retention",
"tenant-settings",
"tenant-roles",
"tenant-groups",
"tenant-users",
"tenant-file-connectors",
"tenant-mail-servers",
"tenant-api-keys",
"tenant-retention",
"tenant-audit",
"tenant-group-file-connectors",
"tenant-group-mail-servers",
"tenant-group-retention",
"tenant-user-file-connectors",
"tenant-user-mail-servers",
"tenant-user-retention"
]);
export default function AdminPage({
settings,
auth,
@@ -31,16 +71,19 @@ export default function AdminPage({
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
}) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
const mailProfilesAvailable = Boolean(mailProfilesUi);
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
const auditAvailable = usePlatformModuleInstalled("audit");
const policyAvailable = usePlatformModuleInstalled("policy");
const tenancyAvailable = usePlatformModuleInstalled("tenancy");
const contributedSections = useMemo(() => (
adminSectionCapabilities
.flatMap((capability) => capability.sections)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
), [adminSectionCapabilities]);
const contributedSections = useMemo(
() =>
adminSectionCapabilities
.flatMap((capability) => capability.sections)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
[adminSectionCapabilities]
);
const contributionById = useMemo(() => {
const mapped = new Map<string, AdminSectionContribution>();
for (const section of contributedSections) {
@@ -58,7 +101,7 @@ export default function AdminPage({
if (policyAvailable) sections.add("system-retention");
if (mailProfilesAvailable) sections.add("system-mail-servers");
}
if (tenancyAvailable && hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
if (auditAvailable && hasScope(auth, "system:audit:read")) sections.add("system-audit");
@@ -71,6 +114,10 @@ export default function AdminPage({
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
}
if (fileConnectorsAvailable && hasAnyScope(auth, ["files:file:admin", "admin:settings:read"])) {
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
}
if (policyAvailable && hasScope(auth, "admin:policies:read")) {
sections.add("tenant-retention");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention");
@@ -79,7 +126,7 @@ export default function AdminPage({
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit");
return sections;
}, [auth, auditAvailable, contributedSections, mailProfilesAvailable, policyAvailable, tenancyAvailable]);
}, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, policyAvailable]);
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null;
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
@@ -105,50 +152,85 @@ export default function AdminPage({
}, [settings.accessToken, settings.apiBaseUrl]);
if (!hasAnyScope(auth, adminReadScopes)) {
return <div className="content-pad"><Card title="Administration unavailable"><p>Your current roles do not grant administrative access.</p></Card></div>;
return (
<div className="content-pad">
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
</Card>
</div>
);
}
const rootItems = contributedNavItems(contributedSections, available, "ROOT");
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
{ items: asSubnavItems(rootItems) },
{
title: "SYSTEM",
items: asSubnavItems(sortNavItems([
...contributedNavItems(contributedSections, available, "SYSTEM"),
visibleNavItem(available, "system-tenants", "Tenants", 20),
visibleNavItem(available, "system-roles", "System roles", 30),
visibleNavItem(available, "system-users", "Users", 60),
visibleNavItem(available, "system-mail-servers", "Mail servers", 70),
visibleNavItem(available, "system-retention", "Retention", 80),
visibleNavItem(available, "system-audit", "Audit", 90)
]))
title: "ADMINISTRATION",
items: asSubnavItems(
sortNavItems([
...contributedNavItems(contributedSections, available, "ROOT"),
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30),
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
visibleNavItem(available, "system-audit", "i18n:govoplan-access.audit.fa1703dd", 90),
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
])
)
},
{
title: "GLOBAL",
items: asSubnavItems(
sortNavItems([
visibleNavItem(available, "system-tenants", "i18n:govoplan-access.tenants.1f7ae776", 10),
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
visibleNavItem(available, "system-groups", "i18n:govoplan-access.group_templates", 40),
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
visibleNavItem(available, "system-retention", "i18n:govoplan-access.retention.c7199d9e", 80),
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
])
)
},
{
title: "TENANT",
items: [
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []),
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []),
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : [])
]
items: asSubnavItems(
sortNavItems([
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 20),
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 30),
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 40),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 50),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 60),
visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 70),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 80),
visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 90),
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
])
)
},
{
title: "GROUP",
items: [
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []),
]
items: asSubnavItems(
sortNavItems([
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-group-retention", "i18n:govoplan-access.retention.c7199d9e", 30),
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
])
)
},
{
title: "USER",
items: [
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []),
]
items: asSubnavItems(
sortNavItems([
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-user-retention", "i18n:govoplan-access.retention.c7199d9e", 30),
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
])
)
}
].filter((group) => group.items.length > 0);
const contributedSection = contributionById.get(active);
@@ -161,22 +243,24 @@ export default function AdminPage({
<div className="content-pad workspace-data-page">
{contributedSection && contributedSection.render(contributionContext)}
{!contributedSection && active === "system-retention" && <RetentionPoliciesPanel settings={settings} scopeType="system" canWrite={hasScope(auth, "system:settings:write")} />}
{!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")} />}
{!contributedSection && active === "system-tenants" && <TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "system-users" && <SystemUsersPanel
settings={settings}
canCreate={hasScope(auth, "system:accounts:create")}
canUpdate={hasScope(auth, "system:accounts:update")}
canSuspend={hasScope(auth, "system:accounts:suspend")}
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
onAuthRefresh={refreshAuth}
/>}
{!contributedSection && active === "system-roles" && <SystemRolesPanel
settings={settings}
canWrite={hasScope(auth, "system:roles:write")}
onAuthRefresh={refreshAuth}
/>}
{!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")} />
)}
{!contributedSection && active === "system-tenants" && (
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
)}
{!contributedSection && active === "system-users" && (
<SystemUsersPanel
settings={settings}
canCreate={hasScope(auth, "system:accounts:create")}
canUpdate={hasScope(auth, "system:accounts:update")}
canSuspend={hasScope(auth, "system:accounts:suspend")}
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
onAuthRefresh={refreshAuth}
/>
)}
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "system-audit" && <AdminAuditPanel settings={settings} auth={auth} systemMode />}
{!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-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} />}
@@ -185,6 +269,8 @@ export default function AdminPage({
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers: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-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
@@ -202,9 +288,14 @@ function canUseContributedSection(auth: AuthInfo, section: AdminSectionContribut
return true;
}
function contributedNavItems(sections: AdminSectionContribution[], available: ReadonlySet<string>, group: string): OrderedAdminNavItem[] {
function contributedNavItems(
sections: AdminSectionContribution[],
available: ReadonlySet<string>,
group: string,
excludedIds: ReadonlySet<string> = new Set()
): OrderedAdminNavItem[] {
return sections
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id))
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id))
.map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 }));
}

View File

@@ -1,80 +1,139 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeys, fetchPermissionCatalog, fetchUsers, 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 { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { DateTimeField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { scopeGrants } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { settings: ApiSettings; auth: AuthInfo; canCreate: boolean; canRevoke: boolean }) {
function defaultDraft(userId: string) {
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
}
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canRevoke: boolean;}) {
const [keys, setKeys] = useState<ApiKeyAdminItem[]>([]);
const [users, setUsers] = useState<UserAdminItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const keysRef = useRef<ApiKeyAdminItem[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [showRevoked, setShowRevoked] = useState(false);
const [creating, setCreating] = useState(false);
const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null);
const [draft, setDraft] = useState({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" });
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
const [draft, setDraft] = useState(() => defaultDraft(auth.user.id));
const [savedDraftKey, setSavedDraftKey] = useState(() => draftKey(defaultDraft(auth.user.id)));
const [secret, setSecret] = useState<{name: string;value: string;} | null>(null);
const [revoking, setRevoking] = useState<ApiKeyAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = creating && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeCreate
});
async function load() {
setLoading(true);
setError("");
try {
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([fetchApiKeys(settings, showRevoked), fetchUsers(settings), fetchPermissionCatalog(settings)]);
const keyScope = showRevoked ? "all" : "active";
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([
loadDeltaRows(
keysRef.current,
`access:api-keys:${keyScope}`,
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchApiKeysDelta(settings, showRevoked, { since }),
(response) => response.api_keys,
(key) => key.id,
"access_api_key",
sortApiKeys
),
loadDeltaRows(
usersRef.current,
"access:api-keys:users",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchUsersDelta(settings, { since }),
(response) => response.users,
(user) => user.id,
"access_user",
sortUsers
),
fetchPermissionCatalog(settings)
]);
keysRef.current = nextKeys;
usersRef.current = nextUsers;
setKeys(nextKeys);
setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active));
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked]);
useEffect(() => {
keysRef.current = [];
usersRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked, resetDeltaWatermark]);
const selectedUser = users.find((user) => user.id === draft.userId);
const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope)));
const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [
{ id: "name", header: "Name", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}</div></div> },
{ id: "owner", header: "Owner", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
{ id: "scopes", header: "Scopes", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
{ id: "last_used", header: "Last used", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
{ id: "expires", header: "Expires", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} disabled />
<AdminIconButton label={`Revoke ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
</div> }
], [canRevoke]);
{ id: "name", header: "i18n:govoplan-access.name.709a2322", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}</div></div> },
{ id: "owner", header: "i18n:govoplan-access.owner.89ff3122", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
{ id: "scopes", header: "i18n:govoplan-access.scopes.c23540e5", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
{ 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: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} disabled />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
</div> }],
[canRevoke]);
function openCreate() {
const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0];
setDraft({ name: "", userId: defaultUser?.id || "", scopes: ["campaign:read"], expiresAt: "" });
const nextDraft = defaultDraft(defaultUser?.id || "");
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setCreating(true);
setError("");
}
async function save() {
function closeCreate() {
setCreating(false);
const nextDraft = defaultDraft(auth.user.id);
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null });
setSecret({ name: created.name, value: created.secret });
setCreating(false);
setSuccess(`API key ${created.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name }));
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function revoke() {
@@ -83,42 +142,54 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
setError("");
try {
await revokeApiKey(settings, revoking.id);
setSuccess(`API key ${revoking.name} revoked.`);
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_revoked.b4431f0e", { value0: revoking.name }));
setRevoking(null);
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
return (
<>
<AdminPageLayout title="Tenant API keys" description="Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited." loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> Show revoked</label><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add API key" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No API keys found." /></div>
<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={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> i18n:govoplan-access.show_revoked.b4265807</label><Button onClick={() => void load()} disabled={loading}>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} /></>}>
<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>
<Dialog open={creating} title="Create API key" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "Creating…" : "Create key"}</Button></>}>
<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}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !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">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Owner"><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="">Select user</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} {user.email}</option>)}</select></FormField>
<FormField label="Expiry"><input type="datetime-local" value={draft.expiresAt} onChange={(event) => setDraft({ ...draft, expiresAt: 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.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.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
</div>
<div className="form-field"><span className="form-label">Allowed scopes</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope}${permission.description}` }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="The selected user has no tenant permissions available to an API key." /></div>
<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>
</Dialog>
<Dialog open={Boolean(viewing)} title="API key details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<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>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>Name</dt><dd>{viewing.name}</dd></div><div><dt>Prefix</dt><dd>{viewing.prefix}</dd></div>
<div><dt>Owner</dt><dd>{viewing.user_email}</dd></div><div><dt>Status</dt><dd>{viewing.revoked_at ? "Revoked" : "Active"}</dd></div>
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Last used</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
<div><dt>Expires</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}</dd></div><div><dt>Revoked</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
</dl><h3>Scopes</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
<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>
<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>
<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>
<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>
</dl><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog>
<Dialog open={Boolean(secret)} title="API key secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}>
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.</p></>}
<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>}>
{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>
<ConfirmDialog open={Boolean(revoking)} title="Revoke API key" message={`Revoke ${revoking?.name}? Existing clients will immediately lose access.`} confirmLabel="Revoke key" 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} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
</>);
}
function draftKey(draft: ReturnType<typeof defaultDraft>): string {
return JSON.stringify(draft);
}
function sortApiKeys(left: ApiKeyAdminItem, right: ApiKeyAdminItem): number {
return left.name.localeCompare(right.name) || left.prefix.localeCompare(right.prefix);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings, FileConnectorScope, FileConnectorTargetOption, FilesConnectorsUiCapability } from "@govoplan/core-webui";
import { AdminPageLayout, Card, adminErrorMessage, useDeltaWatermarks, usePlatformUiCapability } from "@govoplan/core-webui";
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = {
settings: ApiSettings;
scopeType: Extract<FileConnectorScope, "user" | "group">;
canWrite: boolean;
};
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel: string;panelTitle: string;}> = {
user: {
title: "i18n:govoplan-access.user_file_connections.996444a0",
description: "i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2",
targetLabel: "i18n:govoplan-access.user.9f8a2389",
panelTitle: "i18n:govoplan-access.user_file_connections.996444a0"
},
group: {
title: "i18n:govoplan-access.group_file_connections.73985bda",
description: "i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc",
targetLabel: "i18n:govoplan-access.group.171a0606",
panelTitle: "i18n:govoplan-access.group_file_connections.73985bda"
}
};
export default function FileConnectorsPanel({ settings, scopeType, canWrite }: Props) {
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const [targets, setTargets] = useState<FileConnectorTargetOption[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loadingTargets, setLoadingTargets] = useState(Boolean(FileConnectorScopeManager));
const [targetError, setTargetError] = useState("");
useEffect(() => {
if (!FileConnectorScopeManager) {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, FileConnectorScopeManager, resetDeltaWatermark]);
async function loadTargets() {
setLoadingTargets(true);
setTargetError("");
try {
if (scopeType === "user") {
const users = await loadDeltaRows(usersRef.current, "access:file-connector-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
usersRef.current = users;
setTargets(users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
})));
} else {
const groups = await loadDeltaRows(groupsRef.current, "access:file-connector-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
groupsRef.current = groups;
setTargets(groups.map((group) => ({
id: group.id,
label: group.name,
secondary: group.slug
})));
}
} catch (err) {
setTargets([]);
setTargetError(adminErrorMessage(err));
} finally {
setLoadingTargets(false);
}
}
const labels = copy[scopeType];
if (!FileConnectorScopeManager) {
return (
<AdminPageLayout title={labels.title} description={labels.description}>
<Card title="i18n:govoplan-access.files_module_unavailable.0ee90db1">
<p className="muted">i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153</p>
</Card>
</AdminPageLayout>);
}
return (
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
<FileConnectorScopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={labels.targetLabel}
title={labels.panelTitle}
canWrite={canWrite} />
</AdminPageLayout>);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
}

View File

@@ -1,65 +1,99 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createGroup, fetchGroups, fetchRoles, fetchUsers, 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 { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: {
settings: ApiSettings;
auth: AuthInfo;
canDefine: boolean;
canManageMembers: boolean;
canAssignRoles: boolean;
onAuthRefresh: () => Promise<void>;
}) {
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh
}: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;canManageMembers: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
const [groups, setGroups] = useState<GroupSummary[]>([]);
const [users, setUsers] = useState<UserAdminItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<GroupSummary | "new" | null>(null);
const [viewing, setViewing] = useState<GroupSummary | null>(null);
const [deactivating, setDeactivating] = useState<GroupSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextGroups, nextUsers, nextRoles] = await Promise.all([fetchGroups(settings), fetchUsers(settings), fetchRoles(settings)]);
const [nextGroups, nextUsers, nextRoles] = await Promise.all([
loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups),
loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers),
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)]
);
groupsRef.current = nextGroups;
usersRef.current = nextUsers;
rolesRef.current = nextRoles;
setGroups(nextGroups);
setUsers(nextUsers);
setRoles(nextRoles.filter((role) => role.is_assignable));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
useEffect(() => {
groupsRef.current = [];
usersRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
function openEdit(group: GroupSummary) {
setDraft({ slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) });
const nextDraft = { slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(group);
setError("");
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] });
setSuccess(`Group ${draft.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.group_value_created.5a39a341", { value0: draft.name }));
} else if (editing) {
const managed = Boolean(editing.system_template_id);
await updateGroup(settings, editing.id, {
@@ -68,13 +102,14 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
...(canManageMembers ? { member_ids: draft.memberIds } : {}),
...(canAssignRoles ? { role_ids: draft.roleIds } : {})
});
setSuccess(`Group ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.group_value_updated.3d97d5b2", { value0: draft.name }));
}
setEditing(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function deactivate() {
@@ -83,57 +118,75 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
setError("");
try {
await updateGroup(settings, deactivating.id, { is_active: false });
setSuccess(`Group ${deactivating.name} deactivated.`);
setSuccess(i18nMessage("i18n:govoplan-access.group_value_deactivated.8512bf9c", { value0: deactivating.name }));
setDeactivating(null);
if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
{ id: "group", header: "Group", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
{ id: "members", header: "Members", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
{ id: "roles", header: "Inherited roles", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
<AdminIconButton label={`Deactivate ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
</div> }
], [canAssignRoles, canDefine, canManageMembers]);
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
</div> }],
[canAssignRoles, canDefine, canManageMembers]);
return (
<>
<AdminPageLayout title="Tenant groups" description="Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add group" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No groups found." /></div>
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<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>
<Dialog open={editing !== null} title={editing === "new" ? "Create group" : "Edit group"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "Saving…" : "Save group"}</Button></>}>
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.</p>}
<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}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{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>}
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Status"><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">Active</option><option value="inactive">Inactive</option></select></FormField>
<FormField label="Description"><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.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.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>
</div>
<div className="admin-assignment-grid">
<div><span className="form-label">Members</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="No tenant users exist." /></div>
<div><span className="form-label">Inherited roles</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="No assignable roles exist." /></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>
<p className="muted small-note">The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.</p>
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
</Dialog>
<Dialog open={Boolean(viewing)} title="Group details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<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>}>
{viewing && <dl className="admin-details-grid">
<div><dt>Group</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Management</dt><dd>{viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant managed"}</dd></div>
<div><dt>Members</dt><dd>{viewing.member_count}</dd></div><div><dt>Roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
<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>
<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>
<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>
<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>
</dl>}
</Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate group" message={`Deactivate ${deactivating?.name}? Memberships remain stored, but role inheritance stops.`} confirmLabel="Deactivate group" 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()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email);
}
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
if (builtinDelta !== 0) return builtinDelta;
return left.name.localeCompare(right.name);
}

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
import { fetchGroups, fetchUsers } from "../../api/admin";
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
import { Card } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { usePlatformUiCapability } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = {
settings: ApiSettings;
@@ -13,32 +14,32 @@ type Props = {
canWritePolicy: boolean;
};
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; profileTitle: string; policyTitle: string }> = {
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;profileTitle: string;policyTitle: string;}> = {
system: {
title: "System mail profiles",
description: "Instance-level mail server profiles and policy limits inherited by every tenant.",
profileTitle: "System profiles",
policyTitle: "System mail profile policy"
title: "i18n:govoplan-access.system_mail_profiles.5af3eb64",
description: "i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7",
profileTitle: "i18n:govoplan-access.system_profiles.98adae54",
policyTitle: "i18n:govoplan-access.system_mail_profile_policy.7edd7fcf"
},
tenant: {
title: "Tenant mail profiles",
description: "Tenant-level mail server profiles and policy limits for the active tenant.",
profileTitle: "Tenant profiles",
policyTitle: "Tenant mail profile policy"
title: "i18n:govoplan-access.tenant_mail_profiles.5132a623",
description: "i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f",
profileTitle: "i18n:govoplan-access.tenant_profiles.4d7281ce",
policyTitle: "i18n:govoplan-access.tenant_mail_profile_policy.239298a1"
},
user: {
title: "User mail profiles",
description: "User-scoped profiles and policy limits for campaign owners in the active tenant.",
targetLabel: "User",
profileTitle: "User profiles",
policyTitle: "User mail profile policy"
title: "i18n:govoplan-access.user_mail_profiles.f54a845a",
description: "i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf",
targetLabel: "i18n:govoplan-access.user.9f8a2389",
profileTitle: "i18n:govoplan-access.user_profiles.57730285",
policyTitle: "i18n:govoplan-access.user_mail_profile_policy.529e035b"
},
group: {
title: "Group mail profiles",
description: "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
targetLabel: "Group",
profileTitle: "Group profiles",
policyTitle: "Group mail profile policy"
title: "i18n:govoplan-access.group_mail_profiles.ebf1b5ba",
description: "i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66",
targetLabel: "i18n:govoplan-access.group.171a0606",
profileTitle: "i18n:govoplan-access.group_profiles.74568838",
policyTitle: "i18n:govoplan-access.group_mail_profile_policy.d98ef5a2"
}
};
@@ -46,6 +47,9 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
const [targetError, setTargetError] = useState("");
@@ -56,8 +60,11 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
setTargetError("");
return;
}
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager]);
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager, resetDeltaWatermark]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
@@ -70,14 +77,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
setTargetError("");
try {
if (scopeType === "user") {
const users = await fetchUsers(settings);
const users = await loadDeltaRows(usersRef.current, "access:mail-profile-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
usersRef.current = users;
setTargets(users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
})));
} else {
const groups = await fetchGroups(settings);
const groups = await loadDeltaRows(groupsRef.current, "access:mail-profile-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
groupsRef.current = groups;
setTargets(groups.map((group) => ({
id: group.id,
label: group.name,
@@ -97,11 +106,11 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
if (!MailProfileScopeManager) {
return (
<AdminPageLayout title={labels.title} description={labels.description}>
<Card title="Mail module unavailable">
<p className="muted">Install and enable the Mail module to manage mail server profiles and profile policies.</p>
<Card title="i18n:govoplan-access.mail_module_unavailable.b4e95104">
<p className="muted">i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a</p>
</Card>
</AdminPageLayout>
);
</AdminPageLayout>);
}
return (
@@ -115,8 +124,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
policyTitle={labels.policyTitle}
canWriteProfiles={canWriteProfiles}
canManageCredentials={canManageCredentials}
canWritePolicy={canWritePolicy}
/>
</AdminPageLayout>
);
canWritePolicy={canWritePolicy} />
</AdminPageLayout>);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { fetchGroups, fetchUsers, runRetentionPolicy, type PrivacyRetentionPolicyScope, type RetentionRunResponse } from "../../api/admin";
import { fetchGroupsDelta, fetchUsersDelta, runRetentionPolicy, type GroupSummary, type PrivacyRetentionPolicyScope, type RetentionRunResponse, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { RetentionPolicyScopeManager, type RetentionPolicyTargetOption } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = {
settings: ApiSettings;
@@ -13,37 +14,40 @@ type Props = {
canWrite: boolean;
};
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;policyTitle: string;policyDescription: string;}> = {
system: {
title: "System retention",
description: "Instance-wide privacy retention policy and lower-level override permissions.",
policyTitle: "System retention policy",
policyDescription: "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override."
title: "i18n:govoplan-access.system_retention.4191e7f7",
description: "i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224",
policyTitle: "i18n:govoplan-access.system_retention_policy.7027f6ba",
policyDescription: "i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13"
},
tenant: {
title: "Tenant retention",
description: "Tenant-level privacy and retention limits for the active tenant.",
policyTitle: "Tenant retention policy",
policyDescription: "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override."
title: "i18n:govoplan-access.tenant_retention.95b35db0",
description: "i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c",
policyTitle: "i18n:govoplan-access.tenant_retention_policy.f10893d7",
policyDescription: "i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77"
},
user: {
title: "User retention",
description: "User-scoped retention limits for campaigns owned by users in the active tenant.",
targetLabel: "User",
policyTitle: "User retention policy",
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override."
title: "i18n:govoplan-access.user_retention.f0966bbf",
description: "i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b",
targetLabel: "i18n:govoplan-access.user.9f8a2389",
policyTitle: "i18n:govoplan-access.user_retention_policy.2776f485",
policyDescription: "i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0"
},
group: {
title: "Group retention",
description: "Group-scoped retention limits for group-owned campaigns in the active tenant.",
targetLabel: "Group",
policyTitle: "Group retention policy",
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override."
title: "i18n:govoplan-access.group_retention.57cdcdaa",
description: "i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04",
targetLabel: "i18n:govoplan-access.group.171a0606",
policyTitle: "i18n:govoplan-access.group_retention_policy.ad941c0b",
policyDescription: "i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f"
}
};
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
const [targetError, setTargetError] = useState("");
const [busy, setBusy] = useState(false);
@@ -52,7 +56,12 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
@@ -65,14 +74,16 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
setTargetError("");
try {
if (scopeType === "user") {
const users = await fetchUsers(settings);
const users = await loadDeltaRows(usersRef.current, "access:retention-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
usersRef.current = users;
setTargets(users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
})));
} else {
const groups = await fetchGroups(settings);
const groups = await loadDeltaRows(groupsRef.current, "access:retention-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
groupsRef.current = groups;
setTargets(groups.map((group) => ({
id: group.id,
label: group.name,
@@ -94,10 +105,10 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
try {
const response = await runRetentionPolicy(settings, dryRun);
setRetentionResult(response);
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
setSuccess(dryRun ? "i18n:govoplan-access.retention_dry_run_completed.91895aee" : "i18n:govoplan-access.retention_policy_applied.7fa4e050");
setConfirmRetentionRun(false);
} catch (err) { setRunError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setRunError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const labels = copy[scopeType];
@@ -112,31 +123,39 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
targetLabel={labels.targetLabel}
title={labels.policyTitle}
description={labels.policyDescription}
canWrite={canWrite}
/>
{scopeType === "system" && (
<div className="retention-run-card">
<Card title="Retention execution">
<p className="muted small-note">Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.</p>
canWrite={canWrite} />
{scopeType === "system" &&
<div className="retention-run-card">
<Card title="i18n:govoplan-access.retention_execution.84b7105d">
<p className="muted small-note">i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c</p>
<div className="button-row compact-actions subsection-bottom-actions">
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>i18n:govoplan-access.dry_run.485a3d15</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>i18n:govoplan-access.apply_retention.5b991811</Button>
</div>
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
</Card>
</div>
)}
}
</AdminPageLayout>
<ConfirmDialog
open={confirmRetentionRun}
title="Apply retention policy"
message="This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed."
confirmLabel="Apply retention"
title="i18n:govoplan-access.apply_retention_policy.9e5d32b4"
message="i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715"
confirmLabel="i18n:govoplan-access.apply_retention.5b991811"
tone="danger"
busy={busy}
onCancel={() => setConfirmRetentionRun(false)}
onConfirm={() => void runRetention(false)}
/>
</>
);
onConfirm={() => void runRetention(false)} />
</>);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRoles, 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 { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
@@ -9,34 +9,53 @@ import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
import { hasTenantWildcard } from "@govoplan/core-webui";
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: { settings: ApiSettings; auth: AuthInfo; canDefine: boolean; onAuthRefresh: () => Promise<void> }) {
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;onAuthRefresh: () => Promise<void>;}) {
const [roles, setRoles] = useState<RoleSummary[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
const [viewing, setViewing] = useState<RoleSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextRoles, nextPermissions] = await Promise.all([fetchRoles(settings), fetchPermissionCatalog(settings)]);
const [nextRoles, nextPermissions] = await Promise.all([
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles),
fetchPermissionCatalog(settings)]
);
rolesRef.current = nextRoles;
setRoles(nextRoles);
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
useEffect(() => {
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
const permissionGroups = useMemo(() => {
const groups = new Map<string, PermissionItem[]>();
@@ -44,35 +63,44 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
return Array.from(groups.entries());
}, [permissions]);
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
function openEdit(role: RoleSummary) {
if (role.is_builtin || role.system_template_id) return;
setDraft({ slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable });
const nextDraft = { slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(role);
setError("");
}
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
function togglePermission(scope: string, checked: boolean) {
const next = new Set(draft.permissions);
if (checked) next.add(scope); else next.delete(scope);
if (checked) next.add(scope);else next.delete(scope);
setDraft({ ...draft, permissions: Array.from(next) });
}
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
setSuccess(`Role ${draft.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.role_value_created.2a964899", { value0: draft.name }));
} else if (editing) {
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
setSuccess(`Role ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.role_value_updated.ec386eb0", { value0: draft.name }));
}
setEditing(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function remove() {
@@ -81,51 +109,61 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
setError("");
try {
await deleteRole(settings, deleting.id);
setSuccess(`Role ${deleting.name} deleted.`);
setSuccess(i18nMessage("i18n:govoplan-access.role_value_deleted.3bd819cf", { value0: deleting.name }));
setDeleting(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{ id: "role", header: "Role", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
{ id: "permissions", header: "Permissions", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
{ id: "assignments", header: "Assignments", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
{ id: "type", header: "Type", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "Built-in" : row.system_template_id ? "System" : "Custom"} /> },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
</div> }
], [canDefine, permissions]);
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
</div> }],
[canDefine, permissions]);
return (
<>
<AdminPageLayout title="Tenant roles" description="Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No roles found." /></div>
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<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>
<Dialog open={editing !== null} title={editing === "new" ? "Create role" : "Edit role"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}>
<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}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !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">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></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.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>}
</div>
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
</Dialog>
<Dialog open={Boolean(viewing)} title="Role details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<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>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>Role</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
<div><dt>Type</dt><dd>{viewing.is_builtin ? "Built-in" : viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant custom"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div>
<div><dt>User assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Group assignments</dt><dd>{viewing.group_assignments}</dd></div>
</dl><h3>Permissions</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
<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>
<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>
<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>
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title="Delete role" message={`Delete ${deleting?.name}? Only unassigned tenant-defined roles can be deleted.`} confirmLabel="Delete role" 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()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
if (builtinDelta !== 0) return builtinDelta;
return left.name.localeCompare(right.name);
}

View File

@@ -1,22 +1,23 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui";
import {
createSystemRole,
deleteSystemRole,
fetchPermissionCatalog,
fetchSystemRoles,
fetchSystemRolesDelta,
updateSystemRole,
type PermissionItem,
type RoleSummary
} from "../../api/admin";
type RoleSummary } from
"../../api/admin";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = {
slug: "",
@@ -30,30 +31,41 @@ export default function SystemRolesPanel({
settings,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
const [roles, setRoles] = useState<RoleSummary[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
const [viewing, setViewing] = useState<RoleSummary | null>(null);
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextRoles, catalogue] = await Promise.all([
fetchSystemRoles(settings),
fetchPermissionCatalog(settings)
]);
loadDeltaRows(rolesRef.current, "access:system-roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchSystemRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_system_role", sortSystemRoles),
fetchPermissionCatalog(settings)]
);
rolesRef.current = nextRoles;
setRoles(nextRoles);
setPermissions(catalogue.filter((item) => item.level === "system"));
} catch (err) {
@@ -63,25 +75,38 @@ export default function SystemRolesPanel({
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() {
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new");
}
function openEdit(role: RoleSummary) {
setDraft({
const nextDraft = {
slug: role.slug,
name: role.name,
description: role.description || "",
permissions: role.permissions,
isAssignable: role.is_assignable
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(role);
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -92,7 +117,7 @@ export default function SystemRolesPanel({
description: draft.description || null,
permissions: draft.permissions
});
setSuccess(`System role ${draft.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_created.9c1a6bc8", { value0: draft.name }));
} else if (editing) {
await updateSystemRole(settings, editing.id, {
name: draft.name,
@@ -100,13 +125,15 @@ export default function SystemRolesPanel({
permissions: draft.permissions,
is_assignable: draft.isAssignable
});
setSuccess(`System role ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_updated.3a3f8862", { value0: draft.name }));
}
setEditing(null);
await load();
await onAuthRefresh();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
@@ -118,7 +145,7 @@ export default function SystemRolesPanel({
setError("");
try {
await deleteSystemRole(settings, deleting.id);
setSuccess(`System role ${deleting.name} deleted.`);
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_deleted.7b18a6f8", { value0: deleting.name }));
setDeleting(null);
await load();
await onAuthRefresh();
@@ -130,125 +157,133 @@ export default function SystemRolesPanel({
}
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{
id: "role",
header: "System role",
width: 240,
minWidth: 180,
maxWidth: 380,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "description",
header: "Description",
width: 360,
fill: true,
minWidth: 220,
maxWidth: 640,
resizable: true,
sortable: true,
filterable: true,
value: (row) => row.description || "",
render: (row) => row.description || "—"
},
{
id: "permissions",
header: "Permissions",
width: 120,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.effective_permission_count,
render: (row) => String(row.effective_permission_count)
},
{
id: "assignable",
header: "Assignable",
width: 120,
resizable: false,
sortable: true,
filterable: true,
value: (row) => row.is_assignable ? "yes" : "no",
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "Yes" : "No"} />
},
{
id: "assignments",
header: "Accounts",
width: 110,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.user_assignments
},
{
id: "actions",
header: "Actions",
width: 150,
sticky: "end",
resizable: false,
align: "right",
render: (row) => {
const protectedOwner = row.slug === "system_owner";
return <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
{
id: "role",
header: "i18n:govoplan-access.system_role.91762640",
width: 240,
minWidth: 180,
maxWidth: 380,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "description",
header: "i18n:govoplan-access.description.55f8ebc8",
width: 360,
fill: true,
minWidth: 220,
maxWidth: 640,
resizable: true,
sortable: true,
filterable: true,
value: (row) => row.description || "",
render: (row) => row.description || "—"
},
{
id: "permissions",
header: "i18n:govoplan-access.permissions.d06d5557",
width: 120,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.effective_permission_count,
render: (row) => String(row.effective_permission_count)
},
{
id: "assignable",
header: "i18n:govoplan-access.assignable.a88debc5",
width: 120,
resizable: false,
sortable: true,
filterable: true,
value: (row) => row.is_assignable ? "yes" : "no",
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"} />
},
{
id: "assignments",
header: "i18n:govoplan-access.accounts.36bae316",
width: 110,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.user_assignments
},
{
id: "actions",
header: "i18n:govoplan-access.actions.c3cd636a",
width: 150,
sticky: "end",
resizable: false,
align: "right",
render: (row) => {
const protectedOwner = row.slug === "system_owner";
return <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
</div>;
}
}
], [canWrite]);
}],
[canWrite]);
return (
<>
<AdminPageLayout
title="System roles"
description="Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users."
title="i18n:govoplan-access.system_roles.a9461aa6"
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add system role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}>
<div className="admin-table-surface">
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No system roles found." />
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? "Create system role" : "Edit system role"}
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}
>
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !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">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: 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.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>
</div>
<div className="form-field">
<span className="form-label">System permissions</span>
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
<AdminSelectionList
options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))}
selected={draft.permissions}
onChange={(next) => setDraft({ ...draft, permissions: next })}
/>
<p className="muted small-note">A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.</p>
onChange={(next) => setDraft({ ...draft, permissions: next })} />
<p className="muted small-note">i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45</p>
</div>
</Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "System role details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Protected</dt><dd>{viewing.slug === "system_owner" ? "Yes" : "No"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div><div><dt>Account assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Effective permissions</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>Assigned scopes</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
<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>}>
{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>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title="Delete system role" message={`Delete ${deleting?.name}? The role must have no account assignments.`} confirmLabel="Delete role" 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()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
return left.name.localeCompare(right.name);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
@@ -10,7 +10,7 @@ import { PasswordField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import {
createSystemAccount,
fetchSystemAccounts,
fetchSystemAccountsDelta,
fetchTenants,
updateSystemAccount,
updateSystemAccountRoles,
@@ -20,7 +20,7 @@ import {
type SystemMembershipDraft,
type TenantAdminItem
} from "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const emptyDraft = {
email: "",
@@ -40,49 +40,80 @@ export default function SystemUsersPanel({
canAssignRoles,
canManageMemberships,
onAuthRefresh
}: {
settings: ApiSettings;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
canAssignRoles: boolean;
canManageMemberships: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const accountsRef = useRef<SystemAccountItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<SystemAccountItem | "new" | null>(null);
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; value: string } | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [access, nextTenants] = await Promise.all([fetchSystemAccounts(settings), fetchTenants(settings)]);
setAccounts(access.accounts);
setRoles(access.roles);
let nextWatermark = getDeltaWatermark("access:system-accounts");
let nextAccounts = accountsRef.current;
let nextRoles = rolesRef.current;
let hasMore = false;
do {
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
nextAccounts = response.full ? response.accounts : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark("access:system-accounts", nextWatermark);
const nextTenants = await fetchTenants(settings);
accountsRef.current = nextAccounts;
rolesRef.current = nextRoles;
setAccounts(nextAccounts);
setRoles(nextRoles);
setTenants(nextTenants);
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
accountsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() {
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new");
}
function openEdit(item: SystemAccountItem) {
setDraft({
const nextDraft = {
email: item.email,
displayName: item.display_name || "",
password: "",
@@ -97,10 +128,18 @@ export default function SystemUsersPanel({
is_owner: membership.is_owner,
is_last_active_owner: membership.is_last_active_owner
}))
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(item);
}
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
function membership(tenantId: string) {
return draft.memberships.find((item) => item.tenant_id === tenantId);
}
@@ -108,13 +147,13 @@ export default function SystemUsersPanel({
function setMembership(tenantId: string, enabled: boolean) {
setDraft((current) => ({
...current,
memberships: enabled
? [...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }]
: current.memberships.filter((item) => item.tenant_id !== tenantId)
memberships: enabled ?
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] :
current.memberships.filter((item) => item.tenant_id !== tenantId)
}));
}
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -129,21 +168,22 @@ export default function SystemUsersPanel({
memberships: canManageMemberships ? draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })) : []
});
if (response.temporary_password) setTemporaryPassword({ email: response.account.email, value: response.temporary_password });
setSuccess(`Global account ${response.account.email} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_created.5100e467", { value0: response.account.email }));
} else if (editing) {
const accountChanges: { display_name?: string | null; is_active?: boolean } = {};
const accountChanges: {display_name?: string | null;is_active?: boolean;} = {};
if (canUpdate) accountChanges.display_name = draft.displayName || null;
if (canSuspend) accountChanges.is_active = draft.isActive;
if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges);
if (canAssignRoles) await updateSystemAccountRoles(settings, editing.account_id, draft.roleIds);
if (canManageMemberships) await updateSystemMemberships(settings, editing.account_id, draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })));
setSuccess(`Global account ${editing.email} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_updated.0de76b0e", { value0: editing.email }));
}
setEditing(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function deactivate() {
@@ -152,73 +192,85 @@ export default function SystemUsersPanel({
setError("");
try {
await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
setSuccess(`${deactivating.email} deactivated.`);
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated.ed3d027c", { value0: deactivating.email }));
setDeactivating(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
{ id: "account", header: "Account", width: "minmax(240px, 1.2fr)", 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: "tenants", header: "Tenant memberships", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
{ id: "roles", header: "System roles", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
</div> }
], [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
{ id: "account", header: "i18n:govoplan-access.account.85dfa32c", width: "minmax(240px, 1.2fr)", 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: "tenants", header: "i18n:govoplan-access.tenant_memberships.451de736", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "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: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
</div> }],
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
return (
<>
<AdminPageLayout
title="Central users"
description="Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here."
title="i18n:govoplan-access.central_users.91ac1b51"
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add global account" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
>
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="No global accounts found." /></div>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<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>
<Dialog open={editing !== null} title={editing === "new" ? "Create global account" : "Edit global account"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "Saving…" : "Save account"}</Button></>}>
<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}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" && (
<FormField label="Initial password">
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })} />
</FormField>
)}
<FormField label="Account status"><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">Active</option><option value="inactive">Inactive</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>
<div className="admin-assignment-grid">
<div><span className="form-label">System roles</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">Tenant memberships</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></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><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
</div>
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.</p>}
<p className="muted small-note">Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.</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>
</Dialog>
<Dialog open={Boolean(viewing)} title="Global account details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>Account</dt><dd>{viewing.email}</dd></div><div><dt>Display name</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>System roles</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>Tenants</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
<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>}>
{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>}
</Dialog>
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
<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>}>
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
</Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate global account" message={`Deactivate ${deactivating?.email}? All sessions and tenant access will stop. Historical ownership remains intact.`} confirmLabel="Deactivate account" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>
);
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_global_account.66d92736" message={i18nMessage("i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_account.fd9fd676" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortSystemAccounts(left: SystemAccountItem, right: SystemAccountItem): number {
return left.email.localeCompare(right.email);
}
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
return left.name.localeCompare(right.name);
}

View File

@@ -3,14 +3,22 @@ import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { fetchTenantSettings, updateTenantSettings, type TenantSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const DELTA_KEY = "access:tenant-settings";
const fallback: TenantSettingsItem = {
id: "",
slug: "",
name: "",
default_locale: "en",
available_languages: [
{ code: "en", label: "English", native_label: "English" },
{ code: "de", label: "German", native_label: "Deutsch" }
],
system_enabled_language_codes: ["en", "de"],
enabled_language_codes: ["en", "de"],
settings: {}
};
@@ -18,23 +26,42 @@ export default function TenantSettingsPanel({
settings,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
const [savedDraft, setSavedDraft] = useState<TenantSettingsItem>(fallback);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => setDraft(savedDraft)
});
async function load() {
setLoading(true);
setError("");
setSuccess("");
try {
setDraft(await fetchTenantSettings(settings));
const wasDirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
const loaded = await fetchTenantSettingsDelta(settings, { since: getDeltaWatermark(DELTA_KEY) });
setDeltaWatermark(DELTA_KEY, loaded.watermark);
if (loaded.full && loaded.item) {
setSavedDraft(loaded.item);
if (!wasDirty) setDraft(loaded.item);
} else if (loaded.changed_sections.length) {
setSavedDraft((current) => applyTenantSettingsSections(current, loaded.sections));
if (!wasDirty) setDraft((current) => applyTenantSettingsSections(current, loaded.sections));
}
} catch (err) {
setError(adminErrorMessage(err));
} finally {
@@ -42,44 +69,107 @@ export default function TenantSettingsPanel({
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
resetDeltaWatermark(DELTA_KEY);
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
setSuccess("");
try {
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale });
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes });
setDraft(saved);
setSuccess("Tenant general settings saved.");
setSavedDraft(saved);
resetDeltaWatermark(DELTA_KEY);
setSuccess("i18n:govoplan-access.tenant_general_settings_saved.485e7681");
await onAuthRefresh();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
function toggleLanguage(code: string, checked: boolean) {
const enabled = new Set(draft.enabled_language_codes);
if (checked) enabled.add(code);
else enabled.delete(code);
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
}
return (
<AdminPageLayout
title="Tenant general settings"
description="Settings for the active tenant context."
title="i18n:govoplan-access.tenant_general_settings.db1c3ba8"
description="i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "Saving..." : "Save general settings"}</Button></>}
>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "i18n:govoplan-access.saving.ae7e8875" : "i18n:govoplan-access.save_general_settings.5c90f8c4"}</Button></>}>
<div className="admin-settings-form">
<Card title="Locale">
<FormField label="Tenant locale" help="Used as this tenant's locale default for tenant-aware views and future formatting defaults.">
<input value={draft.default_locale} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} />
<Card title="i18n:govoplan-access.locale.8970f0e6">
<FormField label="i18n:govoplan-access.tenant_locale.8fc19914" help="i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b">
<select value={draft.default_locale} disabled={!canWrite || busy || defaultLocaleOptions.length === 0} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })}>
{defaultLocaleOptions.map((code) => {
const language = draft.available_languages.find((item) => item.code === code);
return <option key={code} value={code}>{languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</option>;
})}
</select>
</FormField>
<div className="settings-list">
{draft.system_enabled_language_codes.map((code) => {
const language = draft.available_languages.find((item) => item.code === code);
return (
<label className="admin-inline-check" key={code}>
<input
type="checkbox"
checked={draft.enabled_language_codes.includes(code)}
disabled={!canWrite || busy || code === draft.default_locale}
onChange={(event) => toggleLanguage(code, event.target.checked)} />
<span><strong>{code.toUpperCase()}</strong> {languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</span>
</label>
);
})}
</div>
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
<dl className="detail-list">
<div><dt>Tenant</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>Slug</dt><dd>{draft.slug || "-"}</dd></div>
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
<div><dt>i18n:govoplan-access.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
</dl>
</Card>
</div>
</AdminPageLayout>
);
</AdminPageLayout>);
}
function languageOptionLabel(language: {code: string;label: string;native_label?: string | null}): string {
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
}
function localeOptions(current: string, enabled: string[]): string[] {
return [...new Set([current, ...enabled].filter((item) => item && item.trim()))];
}
function tenantSettingsDraftKey(item: TenantSettingsItem): string {
return JSON.stringify({
default_locale: item.default_locale,
enabled_language_codes: item.enabled_language_codes
});
}
function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantSettingsDeltaSections): TenantSettingsItem {
return {
...item,
...(sections.identity ?? {}),
...(sections.locale ?? {}),
...(sections.languages ?? {}),
...(sections.settings ? { settings: sections.settings } : {})
};
}

View File

@@ -1,14 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type OverrideValue = "inherit" | "allow" | "deny";
type TenantDraft = {
@@ -54,35 +55,46 @@ export default function TenantsPanel({
canUpdate,
canSuspend,
onAuthRefresh
}: {
settings: ApiSettings;
auth: AuthInfo;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise<void>;}) {
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
const tenantsRef = useRef<TenantAdminItem[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
fetchTenants(settings),
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
fetchSystemSettings(settings).catch(() => null)
]);
loadDeltaRows(tenantsRef.current, "tenancy:tenants", getDeltaWatermark, setDeltaWatermark, (since) => fetchTenantsDelta(settings, { since }), (response) => response.tenants, (tenant) => tenant.id, "tenant", sortTenants),
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
fetchSystemSettings(settings).catch(() => null)]
);
tenantsRef.current = nextTenants;
setTenants(nextTenants);
setOwnerCandidates(nextOwnerCandidates);
setSystemSettings(nextSystemSettings);
@@ -93,16 +105,22 @@ export default function TenantsPanel({
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
tenantsRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() {
setDraft({ ...emptyDraft, ownerAccountId: auth.user.account_id });
const nextDraft = { ...emptyDraft, ownerAccountId: auth.user.account_id };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing("new");
setError("");
}
function openEdit(tenant: TenantAdminItem) {
setDraft({
const nextDraft = {
slug: tenant.slug,
name: tenant.name,
ownerAccountId: "",
@@ -112,12 +130,20 @@ export default function TenantsPanel({
customGroups: fromOverride(tenant.allow_custom_groups),
customRoles: fromOverride(tenant.allow_custom_roles),
apiKeys: fromOverride(tenant.allow_api_keys)
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(tenant);
setError("");
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -137,7 +163,7 @@ export default function TenantsPanel({
...governance
});
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
setSuccess(`Tenant ${created.name} created with ${selectedOwner?.display_name || selectedOwner?.email || "the selected account"} as Owner.`);
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb", { value0: created.name, value1: selectedOwner?.display_name || selectedOwner?.email || "i18n:govoplan-access.the_selected_account.1211bfb9" }));
await onAuthRefresh();
} else if (editing) {
const payload: Parameters<typeof updateTenant>[2] = {};
@@ -151,13 +177,15 @@ export default function TenantsPanel({
}
if (canSuspend) payload.is_active = draft.isActive;
await updateTenant(settings, editing.id, payload);
setSuccess(`Tenant ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_updated.25b2c855", { value0: draft.name }));
await onAuthRefresh();
}
setEditing(null);
await load();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
@@ -169,7 +197,7 @@ export default function TenantsPanel({
setError("");
try {
await updateTenant(settings, confirmSuspend.id, { is_active: false });
setSuccess(`${confirmSuspend.name} suspended.`);
setSuccess(i18nMessage("i18n:govoplan-access.value_suspended.31731a28", { value0: confirmSuspend.name }));
setConfirmSuspend(null);
await onAuthRefresh();
await load();
@@ -185,74 +213,82 @@ export default function TenantsPanel({
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
const systemDeniedGovernance = [
systemAllowsCustomGroups ? "" : "custom groups",
systemAllowsCustomRoles ? "" : "custom roles",
systemAllowsApiKeys ? "" : "API keys"
].filter(Boolean).join(", ");
systemAllowsCustomGroups ? "" : "i18n:govoplan-access.custom_groups.453a605c",
systemAllowsCustomRoles ? "" : "i18n:govoplan-access.custom_roles.d48dc976",
systemAllowsApiKeys ? "" : "i18n:govoplan-access.api_keys.94fcf3c2"].
filter(Boolean).join(", ");
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
{ id: "name", header: "Tenant", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
{ id: "users", header: "Users", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
{ id: "groups", header: "Groups", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
{ id: "campaigns", header: "Campaigns", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
{ id: "files", header: "Files", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
{ id: "locale", header: "Locale", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
<AdminIconButton label={`Suspend ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
</div> }
], [activeTenantId, canSuspend, canUpdate]);
{ id: "name", header: "i18n:govoplan-access.tenant.3ca93c78", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
{ id: "users", header: "i18n:govoplan-access.users.57f2b181", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
{ id: "campaigns", header: "i18n:govoplan-access.campaigns.01a23a28", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
</div> }],
[activeTenantId, canSuspend, canUpdate]);
return (
<>
<AdminPageLayout
title="Tenants"
description="Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended."
title="i18n:govoplan-access.tenants.1f7ae776"
description="i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
>
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenants found." /></div>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenants_found.72d04cf4" /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create tenant" : "Edit tenant"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" && !draft.ownerAccountId)}>{busy ? "Saving…" : "Save tenant"}</Button></>}>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_tenant.4dbd55d9" : "i18n:govoplan-access.edit_tenant.e2ba43f9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || editing === "new" && !draft.ownerAccountId}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_tenant.9eb2ac74"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
{editing === "new" && <FormField label="Initial tenant owner"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? `${candidate.display_name} (${candidate.email})` : candidate.email}</option>)}</select></FormField>}
<FormField label="Default locale"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Suspended</option></select></FormField>}
<FormField label="Description"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
{editing === "new" && <FormField label="i18n:govoplan-access.initial_tenant_owner.682291a9"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? i18nMessage("i18n:govoplan-access.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
<FormField label="i18n:govoplan-access.default_locale.b99d021f"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.suspended.794696a7</option></select></FormField>}
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
<h3>System governance overrides</h3>
<h3>i18n:govoplan-access.system_governance_overrides.97cdf3ce</h3>
<div className="admin-form-grid two-columns">
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-access.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-access.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-access.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
</div>
<p className="muted small-note">Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.</p>
{systemDeniedGovernance && <p className="muted small-note">Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.</p>}
<p className="muted small-note">i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868</p>
{systemDeniedGovernance && <p className="muted small-note">i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a {systemDeniedGovernance} i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244</p>}
</Dialog>
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>Tenant</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Suspended"}</dd></div><div><dt>Default locale</dt><dd>{viewing.default_locale}</dd></div>
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
<div><dt>Custom groups</dt><dd>{viewing.effective_governance.allow_custom_groups ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
<div><dt>Custom roles</dt><dd>{viewing.effective_governance.allow_custom_roles ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
<div><dt>API keys</dt><dd>{viewing.effective_governance.allow_api_keys ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
<div><dt>Objects</dt><dd>{viewing.counts.users ?? 0} users, {viewing.counts.groups ?? 0} groups, {viewing.counts.campaigns ?? 0} campaigns, {viewing.counts.files ?? 0} files</dd></div>
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-access.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
<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>
<div><dt>i18n:govoplan-access.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
<div><dt>i18n:govoplan-access.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
<div><dt>i18n:govoplan-access.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
<div><dt>i18n:govoplan-access.objects.72a83add</dt><dd>{viewing.counts.users ?? 0} i18n:govoplan-access.users.81651889 {viewing.counts.groups ?? 0} i18n:govoplan-access.groups.07551586 {viewing.counts.campaigns ?? 0} i18n:govoplan-access.campaigns.2282ffeb {viewing.counts.files ?? 0} files</dd></div>
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
</Dialog>
<ConfirmDialog open={Boolean(confirmSuspend)} title="Suspend tenant" message={`Suspend ${confirmSuspend?.name}? Existing data remains retained, but its members cannot use the tenant.`} confirmLabel="Suspend tenant" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
</>
);
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-access.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-access.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
</>);
}
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean; allowDisabled?: boolean }) {
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow" disabled={allowDisabled}>Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;allowDisabled?: boolean;}) {
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">i18n:govoplan-access.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-access.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-access.explicitly_deny.17ad945a</option></select></FormField>;
}
function draftKey(draft: TenantDraft): string {
return JSON.stringify(draft);
}
function sortTenants(left: TenantAdminItem, right: TenantAdminItem): number {
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createUser, fetchGroups, fetchRoles, fetchUsers, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
@@ -10,7 +10,8 @@ import { PasswordField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { hasTenantWildcard } from "@govoplan/core-webui";
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = {
email: "",
@@ -22,51 +23,77 @@ const emptyDraft = {
roleIds: [] as string[]
};
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh }: {
settings: ApiSettings;
auth: AuthInfo;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
canManageGroups: boolean;
canAssignRoles: boolean;
onAuthRefresh: () => Promise<void>;
}) {
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
const [users, setUsers] = useState<UserAdminItem[]>([]);
const [groups, setGroups] = useState<GroupSummary[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
const [viewing, setViewing] = useState<UserAdminItem | null>(null);
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; password: string } | null>(null);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextUsers, nextGroups, nextRoles] = await Promise.all([fetchUsers(settings), fetchGroups(settings), fetchRoles(settings)]);
const [nextUsers, nextGroups, nextRoles] = await Promise.all([
loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers),
loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups),
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)]
);
usersRef.current = nextUsers;
groupsRef.current = nextGroups;
rolesRef.current = nextRoles;
setUsers(nextUsers);
setGroups(nextGroups);
setRoles(nextRoles.filter((role) => role.is_assignable));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
function openCreate() {
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new");
setError("");
}
function openEdit(user: UserAdminItem) {
setDraft({
const nextDraft = {
email: user.email,
displayName: user.display_name || "",
password: "",
@@ -74,12 +101,20 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
isActive: user.is_active,
groupIds: user.groups.map((group) => group.id),
roleIds: user.roles.map((role) => role.id)
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(user);
setError("");
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -93,7 +128,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
group_ids: canManageGroups ? draft.groupIds : [],
role_ids: canAssignRoles ? draft.roleIds : []
});
setSuccess(`Tenant membership created for ${response.user.email}.`);
setSuccess(i18nMessage("i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6", { value0: response.user.email }));
if (response.temporary_password) setTemporaryPassword({ email: response.user.email, password: response.temporary_password });
} else if (editing) {
const payload: Parameters<typeof updateUser>[2] = {};
@@ -102,13 +137,14 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
if (canManageGroups) payload.group_ids = draft.groupIds;
if (canAssignRoles) payload.role_ids = draft.roleIds;
await updateUser(settings, editing.id, payload);
setSuccess(`Access updated for ${editing.email}.`);
setSuccess(i18nMessage("i18n:govoplan-access.access_updated_for_value.87f22245", { value0: editing.email }));
if (editing.id === auth.user.id) await onAuthRefresh();
}
setEditing(null);
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function deactivate() {
@@ -117,73 +153,91 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
setError("");
try {
await updateUser(settings, deactivating.id, { is_active: false });
setSuccess(`${deactivating.email} deactivated in this tenant.`);
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2", { value0: deactivating.email }));
if (deactivating.id === auth.user.id) await onAuthRefresh();
setDeactivating(null);
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
{ id: "user", header: "User", 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: "Groups", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
{ id: "roles", header: "Direct roles", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "scope_count", header: "Permissions", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "All" : String(row.effective_scopes.length) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
</div> }
], [canAssignRoles, canManageGroups, canSuspend, canUpdate]);
{ 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: "roles", header: "i18n:govoplan-access.direct_roles.c4db7156", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
{ 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: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
</div> }],
[canAssignRoles, canManageGroups, canSuspend, canUpdate]);
return (
<>
<AdminPageLayout title="Tenant users" description="Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant user" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenant users found." /></div>
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<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>
<Dialog open={editing !== null} title={editing === "new" ? "Add tenant user" : "Edit tenant user"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "Saving…" : "Save user"}</Button></>}>
<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}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" && (
<FormField label="Initial password">
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })} />
</FormField>
)}
<FormField label="Membership status"><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">Active</option><option value="inactive">Inactive</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>
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>}
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.</p>}
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3</label>}
{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">
<div><span className="form-label">Groups</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="No groups exist yet." /></div>
<div><span className="form-label">Direct roles</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="No assignable roles exist." /></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>
<p className="muted small-note">Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.</p>
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
</Dialog>
<Dialog open={Boolean(viewing)} title="Tenant user details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<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>}>
{viewing && <dl className="admin-details-grid">
<div><dt>User</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>Email</dt><dd>{viewing.email}</dd></div>
<div><dt>Membership</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Global account</dt><dd>{viewing.account_is_active ? "Active" : "Inactive"}</dd></div>
<div><dt>Groups</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>Direct roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
<div><dt>Effective permissions</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "All tenant permissions" : viewing.effective_scopes.join(", ") || "None"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
<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>
<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>
<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>
<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>
</dl>}
</Dialog>
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">Transmit it through a separate secure channel.</p></>}
<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>}>
{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>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate tenant membership" message={`Deactivate ${deactivating?.email} in the active tenant? Historical ownership remains intact.`} confirmLabel="Deactivate user" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>
);
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c" message={i18nMessage("i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_user.9ddc7096" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name);
}
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
if (builtinDelta !== 0) return builtinDelta;
return left.name.localeCompare(right.name);
}

View File

@@ -0,0 +1,33 @@
import { mergeDeltaRows, type DeltaDeletedItem } from "@govoplan/core-webui";
export type AdminDeltaResponse = {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>(
current: TItem[],
key: string,
getDeltaWatermark: (key: string) => string | null,
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void,
fetchDelta: (since: string | null) => Promise<TResponse>,
rowsFromResponse: (response: TResponse) => TItem[],
getKey: (item: TItem) => string,
deletedResourceType: string,
sort?: (left: TItem, right: TItem) => number
): Promise<TItem[]> {
let nextWatermark = getDeltaWatermark(key);
let merged = current;
let hasMore = false;
do {
const response = await fetchDelta(nextWatermark);
const rows = rowsFromResponse(response);
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
return merged;
}

View File

@@ -0,0 +1,630 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
"i18n:govoplan-access.access.2f81a22d": "Access",
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
"i18n:govoplan-access.account.85dfa32c": "Account",
"i18n:govoplan-access.accounts.36bae316": "Accounts",
"i18n:govoplan-access.action.97c89a4d": "Action",
"i18n:govoplan-access.actions.c3cd636a": "Actions",
"i18n:govoplan-access.active.a733b809": "Active",
"i18n:govoplan-access.actor.cbd19b5c": "Actor",
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Add group",
"i18n:govoplan-access.add_role.d8d5d55c": "Add role",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user",
"i18n:govoplan-access.add_tenant.b8e32af0": "Add tenant",
"i18n:govoplan-access.admin.4e7afebc": "Admin",
"i18n:govoplan-access.administration_unavailable.b86d4cb5": "Administration unavailable",
"i18n:govoplan-access.changes.8aa57de6": "Changes",
"i18n:govoplan-access.all_tenant_permissions.cca8b6e4": "All tenant permissions",
"i18n:govoplan-access.all.6a720856": "All",
"i18n:govoplan-access.allow_when_system_allows.4c5178cb": "Allow when system allows",
"i18n:govoplan-access.allowed_scopes.d94515ff": "Allowed scopes",
"i18n:govoplan-access.allowed.77c7b490": "Allowed",
"i18n:govoplan-access.api_key_details.f70c16be": "API key details",
"i18n:govoplan-access.api_key_secret.00b16050": "API key secret",
"i18n:govoplan-access.api_key_value_created.0ccdfbb2": "API key {value0} created.",
"i18n:govoplan-access.api_key_value_revoked.b4431f0e": "API key {value0} revoked.",
"i18n:govoplan-access.api_keys.94fcf3c2": "API keys",
"i18n:govoplan-access.apply_retention_policy.9e5d32b4": "Apply retention policy",
"i18n:govoplan-access.apply_retention.5b991811": "Apply retention",
"i18n:govoplan-access.assignable.a88debc5": "Assignable",
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
"i18n:govoplan-access.assignments.057d58c7": "Assignments",
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
"i18n:govoplan-access.audit.fa1703dd": "Audit",
"i18n:govoplan-access.available.ce372771": ", available",
"i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
"i18n:govoplan-access.built_in.20f409cc": "Built-in",
"i18n:govoplan-access.campaigns.01a23a28": "Campaigns",
"i18n:govoplan-access.campaigns.2282ffeb": "campaigns,",
"i18n:govoplan-access.cancel.77dfd213": "Cancel",
"i18n:govoplan-access.central_users.91ac1b51": "Central users",
"i18n:govoplan-access.close.bbfa773e": "Close",
"i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended.",
"i18n:govoplan-access.create_api_key.d7b30388": "Create API key",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Create group",
"i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Create role",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
"i18n:govoplan-access.create_tenant.4dbd55d9": "Create tenant",
"i18n:govoplan-access.created.accf40c8": "Created",
"i18n:govoplan-access.creating.94d7d8ee": "Creating…",
"i18n:govoplan-access.custom_groups.1f7b7c8f": "Custom groups",
"i18n:govoplan-access.custom_groups.453a605c": "custom groups",
"i18n:govoplan-access.custom_roles.d48dc976": "custom roles",
"i18n:govoplan-access.custom_roles.e78ef63d": "Custom roles",
"i18n:govoplan-access.custom_tenant_groups.570ee603": "Custom tenant groups",
"i18n:govoplan-access.custom_tenant_roles.a738c37c": "Custom tenant roles",
"i18n:govoplan-access.custom.081ae3fd": "Custom",
"i18n:govoplan-access.deactivate_account.fd9fd676": "Deactivate account",
"i18n:govoplan-access.deactivate_global_account.66d92736": "Deactivate global account",
"i18n:govoplan-access.deactivate_group.f1b8ceea": "Deactivate group",
"i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c": "Deactivate tenant membership",
"i18n:govoplan-access.deactivate_user.9ddc7096": "Deactivate user",
"i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f": "Deactivate {value0}? All sessions and tenant access will stop. Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381": "Deactivate {value0} in the active tenant? Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd": "Deactivate {value0}? Memberships remain stored, but role inheritance stops.",
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Default locale",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
"i18n:govoplan-access.denied.63b16bd4": "Denied",
"i18n:govoplan-access.description.55f8ebc8": "Description",
"i18n:govoplan-access.direct_roles.c4db7156": "Direct roles",
"i18n:govoplan-access.display_name.c7874aaa": "Display name",
"i18n:govoplan-access.dry_run.485a3d15": "Dry run",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Edit group",
"i18n:govoplan-access.edit_role.61dd63e9": "Edit role",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user",
"i18n:govoplan-access.edit_tenant.e2ba43f9": "Edit tenant",
"i18n:govoplan-access.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-access.effective_permissions.17c0fe8a": "Effective permissions",
"i18n:govoplan-access.email.84add5b2": "Email",
"i18n:govoplan-access.expires.a99be3da": "Expires",
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
"i18n:govoplan-access.file_connections.1e362326": "File connections",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
"i18n:govoplan-access.files.6ce6c512": "Files",
"i18n:govoplan-access.general.9239ee2c": "General",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
"i18n:govoplan-access.global_account_value_updated.0de76b0e": "Global account {value0} updated.",
"i18n:govoplan-access.global_account.e1b00cf5": "Global account",
"i18n:govoplan-access.global": "Global",
"i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f": "Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here.",
"i18n:govoplan-access.group_assignments.e534bb56": "Group assignments",
"i18n:govoplan-access.group_details.df844ae3": "Group details",
"i18n:govoplan-access.group_file_connections.73985bda": "Group file connections",
"i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f": "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override.",
"i18n:govoplan-access.group_mail_profile_policy.d98ef5a2": "Group mail profile policy",
"i18n:govoplan-access.group_mail_profiles.ebf1b5ba": "Group mail profiles",
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
"i18n:govoplan-access.group_retention.57cdcdaa": "Group retention",
"i18n:govoplan-access.group_templates": "Group templates",
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66": "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04": "Group-scoped retention limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_value_created.5a39a341": "Group {value0} created.",
"i18n:govoplan-access.group_value_deactivated.8512bf9c": "Group {value0} deactivated.",
"i18n:govoplan-access.group_value_updated.3d97d5b2": "Group {value0} updated.",
"i18n:govoplan-access.group.171a0606": "Group",
"i18n:govoplan-access.group.ea5a3834": "GROUP",
"i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309": "Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles.",
"i18n:govoplan-access.groups.07551586": "groups,",
"i18n:govoplan-access.groups.ae9629f4": "Groups",
"i18n:govoplan-access.i_have_recorded_it.7522da18": "I have recorded it",
"i18n:govoplan-access.inactive.09af574c": "Inactive",
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
"i18n:govoplan-access.inherited_roles.8def9f05": "Inherited roles",
"i18n:govoplan-access.initial_password.2278be8c": "Initial password",
"i18n:govoplan-access.initial_tenant_owner.682291a9": "Initial tenant owner",
"i18n:govoplan-access.inspect_audit_event.5776c1b2": "Inspect audit event",
"i18n:govoplan-access.inspect_value.9d5d1071": "Inspect {value0}",
"i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153": "Install and enable the Files module to manage file connector profiles and policies.",
"i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a": "Install and enable the Mail module to manage mail server profiles and profile policies.",
"i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7": "Instance-level mail server profiles and policy limits inherited by every tenant.",
"i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224": "Instance-wide privacy retention policy and lower-level override permissions.",
"i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d": "Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users.",
"i18n:govoplan-access.is_shown_once.af2b1235": "is shown once.",
"i18n:govoplan-access.last_login.43dab84f": "Last login",
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leave empty to generate",
"i18n:govoplan-access.locale.8970f0e6": "Locale",
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
"i18n:govoplan-access.maintenance.94de303b": "Maintenance",
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
"i18n:govoplan-access.management.63cecca6": "Management",
"i18n:govoplan-access.members.1cb449c1": "Members",
"i18n:govoplan-access.membership_status.b77fc732": "Membership status",
"i18n:govoplan-access.membership.53bc9670": "Membership",
"i18n:govoplan-access.modules.04e9462c": "Modules",
"i18n:govoplan-access.name.709a2322": "Name",
"i18n:govoplan-access.no_administrative_audit_records_found.8d128767": "No administrative audit records found.",
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.",
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
"i18n:govoplan-access.no_roles_found.70f7c0c9": "No roles found.",
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "No tenant users found.",
"i18n:govoplan-access.no_tenants_found.72d04cf4": "No tenants found.",
"i18n:govoplan-access.no.816c52fd": "No",
"i18n:govoplan-access.none.6eef6648": "None",
"i18n:govoplan-access.object.2883f191": "Object",
"i18n:govoplan-access.objects.72a83add": "Objects",
"i18n:govoplan-access.owner.89ff3122": "Owner",
"i18n:govoplan-access.packages.0a999012": "Packages",
"i18n:govoplan-access.permissions.d06d5557": "Permissions",
"i18n:govoplan-access.platform_administration": "Platform administration",
"i18n:govoplan-access.prefix.90eceb01": "Prefix",
"i18n:govoplan-access.protected.28531336": "Protected",
"i18n:govoplan-access.reload.cce71553": "Reload",
"i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d": "Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.",
"i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3": "Require password change when account settings are implemented",
"i18n:govoplan-access.required.2e5396fd": ", required",
"i18n:govoplan-access.required.7c65879a": " · required",
"i18n:govoplan-access.retention_dry_run_completed.91895aee": "Retention dry run completed.",
"i18n:govoplan-access.retention_execution.84b7105d": "Retention execution",
"i18n:govoplan-access.retention_policy_applied.7fa4e050": "Retention policy applied.",
"i18n:govoplan-access.retention.c7199d9e": "Retention",
"i18n:govoplan-access.revoke_api_key.3160aa7e": "Revoke API key",
"i18n:govoplan-access.revoke_key.acb203e7": "Revoke key",
"i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd": "Revoke {value0}? Existing clients will immediately lose access.",
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
"i18n:govoplan-access.role_details.a16b5d9f": "Role details",
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
"i18n:govoplan-access.role.c3f104d1": "Role",
"i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa": "Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source.",
"i18n:govoplan-access.roles.47dcc27d": "Roles",
"i18n:govoplan-access.root.77e7e78b": "ROOT",
"i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c": "Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.",
"i18n:govoplan-access.save_account.0b761f5c": "Save account",
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
"i18n:govoplan-access.save_group.36ca6865": "Save group",
"i18n:govoplan-access.save_role.16fe10d1": "Save role",
"i18n:govoplan-access.save_tenant.9eb2ac74": "Save tenant",
"i18n:govoplan-access.save_user.0d071b89": "Save user",
"i18n:govoplan-access.saving.56a2285c": "Saving…",
"i18n:govoplan-access.saving.ae7e8875": "Saving...",
"i18n:govoplan-access.scope.4651a34e": "Scope",
"i18n:govoplan-access.scopes.c23540e5": "Scopes",
"i18n:govoplan-access.select_user.b8a1d9de": "Select user",
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
"i18n:govoplan-access.slug.094da9b9": "Slug",
"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.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.03a74b32": "Suspend {value0}",
"i18n:govoplan-access.suspended.794696a7": "Suspended",
"i18n:govoplan-access.system_audit.69c6b424": "System audit",
"i18n:govoplan-access.system_governance_overrides.97cdf3ce": "System governance overrides",
"i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1": "System-level administrative history. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.system_mail_profile_policy.7edd7fcf": "System mail profile policy",
"i18n:govoplan-access.system_mail_profiles.5af3eb64": "System mail profiles",
"i18n:govoplan-access.system_managed_value.eb564eb1": "System managed{value0}",
"i18n:govoplan-access.system_permissions.53ff0ab2": "System permissions",
"i18n:govoplan-access.system_profiles.98adae54": "System profiles",
"i18n:govoplan-access.system_retention_policy.7027f6ba": "System retention policy",
"i18n:govoplan-access.system_retention.4191e7f7": "System retention",
"i18n:govoplan-access.system_role_details.3d6a8f15": "System role details",
"i18n:govoplan-access.system_role_value_created.9c1a6bc8": "System role {value0} created.",
"i18n:govoplan-access.system_role_value_deleted.7b18a6f8": "System role {value0} deleted.",
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
"i18n:govoplan-access.system_role.91762640": "System role",
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
"i18n:govoplan-access.system.29d43743": "SYSTEM",
"i18n:govoplan-access.system.bc0792d8": "System",
"i18n:govoplan-access.temporary_password.62d60628": "Temporary password",
"i18n:govoplan-access.tenant_api_keys.4b1d81f8": "Tenant API keys",
"i18n:govoplan-access.tenant_audit.492b9138": "Tenant audit",
"i18n:govoplan-access.tenant_context.b401a2ad": "Tenant context",
"i18n:govoplan-access.tenant_custom.4307081e": "Tenant custom",
"i18n:govoplan-access.tenant_details.5976ba72": "Tenant details",
"i18n:govoplan-access.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
"i18n:govoplan-access.tenant_general_settings.db1c3ba8": "Tenant general settings",
"i18n:govoplan-access.tenant_groups.47e6cc05": "Tenant groups",
"i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff": "Tenant-level administrative history for the active tenant. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f": "Tenant-level mail server profiles and policy limits for the active tenant.",
"i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c": "Tenant-level privacy and retention limits for the active tenant.",
"i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77": "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override.",
"i18n:govoplan-access.tenant_locale.8fc19914": "Tenant locale",
"i18n:govoplan-access.tenant_languages_help": "Tenant languages can only be selected from languages enabled by the system. Users can choose from the tenant-enabled set.",
"i18n:govoplan-access.tenant_mail_profile_policy.239298a1": "Tenant mail profile policy",
"i18n:govoplan-access.tenant_mail_profiles.5132a623": "Tenant mail profiles",
"i18n:govoplan-access.tenant_managed.843eed93": "Tenant managed",
"i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6": "Tenant membership created for {value0}.",
"i18n:govoplan-access.tenant_memberships.451de736": "Tenant memberships",
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Tenant role templates",
"i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details",
"i18n:govoplan-access.tenant_users.cb800b38": "Tenant users",
"i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
"i18n:govoplan-access.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
"i18n:govoplan-access.tenant.3ca93c78": "Tenant",
"i18n:govoplan-access.tenant.affa34d7": "TENANT",
"i18n:govoplan-access.tenants.1f7ae776": "Tenants",
"i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f": "The access admin route requires the platform auth-change callback.",
"i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc": "The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.",
"i18n:govoplan-access.the_secret_for.c60737ef": "The secret for",
"i18n:govoplan-access.the_selected_account.1211bfb9": "the selected account",
"i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7": "The selected user has no tenant permissions available to an API key.",
"i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f": "This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.",
"i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e": "This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.",
"i18n:govoplan-access.this_is_shown_once_for.b0f0f526": "This is shown once for",
"i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f": "This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.",
"i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715": "This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed.",
"i18n:govoplan-access.time.6c82e6dd": "Time",
"i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b": "Transmit it through a separate secure channel.",
"i18n:govoplan-access.type.3deb7456": "Type",
"i18n:govoplan-access.updated.f2f8570d": "Updated",
"i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Used as this tenant's locale default for tenant-aware views and future formatting defaults.",
"i18n:govoplan-access.user_assignments.bc7cc801": "User assignments",
"i18n:govoplan-access.user_file_connections.996444a0": "User file connections",
"i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0": "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override.",
"i18n:govoplan-access.user_mail_profile_policy.529e035b": "User mail profile policy",
"i18n:govoplan-access.user_mail_profiles.f54a845a": "User mail profiles",
"i18n:govoplan-access.user_profiles.57730285": "User profiles",
"i18n:govoplan-access.user_retention_policy.2776f485": "User retention policy",
"i18n:govoplan-access.user_retention.f0966bbf": "User retention",
"i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2": "User-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf": "User-scoped profiles and policy limits for campaign owners in the active tenant.",
"i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b": "User-scoped retention limits for campaigns owned by users in the active tenant.",
"i18n:govoplan-access.user.6eb0c612": "USER",
"i18n:govoplan-access.user.9f8a2389": "User",
"i18n:govoplan-access.users.57f2b181": "Users",
"i18n:govoplan-access.users.81651889": "users,",
"i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2": "{value0} deactivated in this tenant.",
"i18n:govoplan-access.value_deactivated.ed3d027c": "{value0} deactivated.",
"i18n:govoplan-access.value_suspended.31731a28": "{value0} suspended.",
"i18n:govoplan-access.value_value.0e2772ed": "{value0} — {value1}",
"i18n:govoplan-access.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-access.yes.5397e058": "Yes",
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
},
"de": {
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
"i18n:govoplan-access.access.2f81a22d": "Zugriff",
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
"i18n:govoplan-access.account.85dfa32c": "Account",
"i18n:govoplan-access.accounts.36bae316": "Accounts",
"i18n:govoplan-access.action.97c89a4d": "Action",
"i18n:govoplan-access.actions.c3cd636a": "Aktionen",
"i18n:govoplan-access.active.a733b809": "Aktiv",
"i18n:govoplan-access.actor.cbd19b5c": "Actor",
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen",
"i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen",
"i18n:govoplan-access.add_tenant.b8e32af0": "Mandant hinzufügen",
"i18n:govoplan-access.admin.4e7afebc": "Administration",
"i18n:govoplan-access.administration_unavailable.b86d4cb5": "Administration unavailable",
"i18n:govoplan-access.changes.8aa57de6": "Änderungen",
"i18n:govoplan-access.all_tenant_permissions.cca8b6e4": "Alle Mandantenberechtigungen",
"i18n:govoplan-access.all.6a720856": "All",
"i18n:govoplan-access.allow_when_system_allows.4c5178cb": "Allow when system allows",
"i18n:govoplan-access.allowed_scopes.d94515ff": "Allowed scopes",
"i18n:govoplan-access.allowed.77c7b490": "Allowed",
"i18n:govoplan-access.api_key_details.f70c16be": "API key details",
"i18n:govoplan-access.api_key_secret.00b16050": "API key secret",
"i18n:govoplan-access.api_key_value_created.0ccdfbb2": "API key {value0} created.",
"i18n:govoplan-access.api_key_value_revoked.b4431f0e": "API key {value0} revoked.",
"i18n:govoplan-access.api_keys.94fcf3c2": "API-Schlüssel",
"i18n:govoplan-access.apply_retention_policy.9e5d32b4": "Apply retention policy",
"i18n:govoplan-access.apply_retention.5b991811": "Aufbewahrung anwenden",
"i18n:govoplan-access.assignable.a88debc5": "Zuweisbar",
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
"i18n:govoplan-access.assignments.057d58c7": "Zuweisungen",
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
"i18n:govoplan-access.audit.fa1703dd": "Audit",
"i18n:govoplan-access.available.ce372771": ", available",
"i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
"i18n:govoplan-access.built_in.20f409cc": "Eingebaut",
"i18n:govoplan-access.campaigns.01a23a28": "Kampagnen",
"i18n:govoplan-access.campaigns.2282ffeb": "campaigns,",
"i18n:govoplan-access.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-access.central_users.91ac1b51": "Central users",
"i18n:govoplan-access.close.bbfa773e": "Schließen",
"i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended.",
"i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen",
"i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Rolle erstellen",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
"i18n:govoplan-access.create_tenant.4dbd55d9": "Mandant erstellen",
"i18n:govoplan-access.created.accf40c8": "Erstellt",
"i18n:govoplan-access.creating.94d7d8ee": "Creating…",
"i18n:govoplan-access.custom_groups.1f7b7c8f": "Custom groups",
"i18n:govoplan-access.custom_groups.453a605c": "custom groups",
"i18n:govoplan-access.custom_roles.d48dc976": "custom roles",
"i18n:govoplan-access.custom_roles.e78ef63d": "Custom roles",
"i18n:govoplan-access.custom_tenant_groups.570ee603": "Custom tenant groups",
"i18n:govoplan-access.custom_tenant_roles.a738c37c": "Custom tenant roles",
"i18n:govoplan-access.custom.081ae3fd": "Benutzerdefiniert",
"i18n:govoplan-access.deactivate_account.fd9fd676": "Deactivate account",
"i18n:govoplan-access.deactivate_global_account.66d92736": "Deactivate global account",
"i18n:govoplan-access.deactivate_group.f1b8ceea": "Deactivate group",
"i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c": "Mandantenmitgliedschaft deaktivieren",
"i18n:govoplan-access.deactivate_user.9ddc7096": "Benutzer deaktivieren",
"i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f": "Deactivate {value0}? All sessions and tenant access will stop. Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381": "Deactivate {value0} in the active tenant? Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd": "Deactivate {value0}? Memberships remain stored, but role inheritance stops.",
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Standardsprache",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
"i18n:govoplan-access.denied.63b16bd4": "Denied",
"i18n:govoplan-access.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-access.direct_roles.c4db7156": "Direkte Rollen",
"i18n:govoplan-access.display_name.c7874aaa": "Anzeigename",
"i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten",
"i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten",
"i18n:govoplan-access.edit_tenant.e2ba43f9": "Mandant bearbeiten",
"i18n:govoplan-access.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-access.effective_permissions.17c0fe8a": "Wirksame Berechtigungen",
"i18n:govoplan-access.email.84add5b2": "E-Mail",
"i18n:govoplan-access.expires.a99be3da": "Expires",
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
"i18n:govoplan-access.files.6ce6c512": "Dateien",
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
"i18n:govoplan-access.global_account_value_updated.0de76b0e": "Global account {value0} updated.",
"i18n:govoplan-access.global_account.e1b00cf5": "Globales Konto",
"i18n:govoplan-access.global": "Global",
"i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f": "Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here.",
"i18n:govoplan-access.group_assignments.e534bb56": "Gruppenzuweisungen",
"i18n:govoplan-access.group_details.df844ae3": "Gruppendetails",
"i18n:govoplan-access.group_file_connections.73985bda": "Gruppen-Dateiverbindungen",
"i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f": "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override.",
"i18n:govoplan-access.group_mail_profile_policy.d98ef5a2": "Group mail profile policy",
"i18n:govoplan-access.group_mail_profiles.ebf1b5ba": "Gruppen-Mailprofile",
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
"i18n:govoplan-access.group_retention.57cdcdaa": "Gruppenaufbewahrung",
"i18n:govoplan-access.group_templates": "Gruppenvorlagen",
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66": "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04": "Group-scoped retention limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_value_created.5a39a341": "Group {value0} created.",
"i18n:govoplan-access.group_value_deactivated.8512bf9c": "Group {value0} deactivated.",
"i18n:govoplan-access.group_value_updated.3d97d5b2": "Group {value0} updated.",
"i18n:govoplan-access.group.171a0606": "Gruppe",
"i18n:govoplan-access.group.ea5a3834": "GROUP",
"i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309": "Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles.",
"i18n:govoplan-access.groups.07551586": "groups,",
"i18n:govoplan-access.groups.ae9629f4": "Gruppen",
"i18n:govoplan-access.i_have_recorded_it.7522da18": "Ich habe es notiert",
"i18n:govoplan-access.inactive.09af574c": "Inaktiv",
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
"i18n:govoplan-access.inherited_roles.8def9f05": "Inherited roles",
"i18n:govoplan-access.initial_password.2278be8c": "Initiales Passwort",
"i18n:govoplan-access.initial_tenant_owner.682291a9": "Initial tenant owner",
"i18n:govoplan-access.inspect_audit_event.5776c1b2": "Inspect audit event",
"i18n:govoplan-access.inspect_value.9d5d1071": "Inspect {value0}",
"i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153": "Installieren und aktivieren Sie das Dateimodul, um Dateiverbindungsprofile und Richtlinien zu verwalten.",
"i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a": "Install and enable the Mail module to manage mail server profiles and profile policies.",
"i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7": "Instance-level mail server profiles and policy limits inherited by every tenant.",
"i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224": "Instance-wide privacy retention policy and lower-level override permissions.",
"i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d": "Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users.",
"i18n:govoplan-access.is_shown_once.af2b1235": "is shown once.",
"i18n:govoplan-access.last_login.43dab84f": "Letzte Anmeldung",
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leer lassen zum Generieren",
"i18n:govoplan-access.locale.8970f0e6": "Locale",
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
"i18n:govoplan-access.maintenance.94de303b": "Wartung",
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
"i18n:govoplan-access.management.63cecca6": "Management",
"i18n:govoplan-access.members.1cb449c1": "Members",
"i18n:govoplan-access.membership_status.b77fc732": "Mitgliedschaftsstatus",
"i18n:govoplan-access.membership.53bc9670": "Mitgliedschaft",
"i18n:govoplan-access.modules.04e9462c": "Module",
"i18n:govoplan-access.name.709a2322": "Name",
"i18n:govoplan-access.no_administrative_audit_records_found.8d128767": "No administrative audit records found.",
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.",
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
"i18n:govoplan-access.no_roles_found.70f7c0c9": "Keine Rollen gefunden.",
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "Keine Mandantenbenutzer gefunden.",
"i18n:govoplan-access.no_tenants_found.72d04cf4": "No tenants found.",
"i18n:govoplan-access.no.816c52fd": "Nein",
"i18n:govoplan-access.none.6eef6648": "Keine",
"i18n:govoplan-access.object.2883f191": "Object",
"i18n:govoplan-access.objects.72a83add": "Objects",
"i18n:govoplan-access.owner.89ff3122": "Eigentümer",
"i18n:govoplan-access.packages.0a999012": "Pakete",
"i18n:govoplan-access.permissions.d06d5557": "Berechtigungen",
"i18n:govoplan-access.platform_administration": "Plattformadministration",
"i18n:govoplan-access.prefix.90eceb01": "Prefix",
"i18n:govoplan-access.protected.28531336": "Protected",
"i18n:govoplan-access.reload.cce71553": "Neu laden",
"i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d": "Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.",
"i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3": "Passwortwechsel verlangen, sobald Kontoeinstellungen umgesetzt sind",
"i18n:govoplan-access.required.2e5396fd": ", required",
"i18n:govoplan-access.required.7c65879a": " · required",
"i18n:govoplan-access.retention_dry_run_completed.91895aee": "Retention dry run completed.",
"i18n:govoplan-access.retention_execution.84b7105d": "Retention execution",
"i18n:govoplan-access.retention_policy_applied.7fa4e050": "Retention policy applied.",
"i18n:govoplan-access.retention.c7199d9e": "Retention",
"i18n:govoplan-access.revoke_api_key.3160aa7e": "Revoke API key",
"i18n:govoplan-access.revoke_key.acb203e7": "Revoke key",
"i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd": "Revoke {value0}? Existing clients will immediately lose access.",
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
"i18n:govoplan-access.role_details.a16b5d9f": "Rollendetails",
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
"i18n:govoplan-access.role.c3f104d1": "Rolle",
"i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa": "Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source.",
"i18n:govoplan-access.roles.47dcc27d": "Roles",
"i18n:govoplan-access.root.77e7e78b": "ROOT",
"i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c": "Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.",
"i18n:govoplan-access.save_account.0b761f5c": "Save account",
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
"i18n:govoplan-access.save_group.36ca6865": "Gruppe speichern",
"i18n:govoplan-access.save_role.16fe10d1": "Rolle speichern",
"i18n:govoplan-access.save_tenant.9eb2ac74": "Mandant speichern",
"i18n:govoplan-access.save_user.0d071b89": "Benutzer speichern",
"i18n:govoplan-access.saving.56a2285c": "Saving…",
"i18n:govoplan-access.saving.ae7e8875": "Saving...",
"i18n:govoplan-access.scope.4651a34e": "Geltungsbereich",
"i18n:govoplan-access.scopes.c23540e5": "Scopes",
"i18n:govoplan-access.select_user.b8a1d9de": "Select user",
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
"i18n:govoplan-access.slug.094da9b9": "Slug",
"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.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.03a74b32": "Suspend {value0}",
"i18n:govoplan-access.suspended.794696a7": "Suspended",
"i18n:govoplan-access.system_audit.69c6b424": "System audit",
"i18n:govoplan-access.system_governance_overrides.97cdf3ce": "System governance overrides",
"i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1": "System-level administrative history. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.system_mail_profile_policy.7edd7fcf": "System mail profile policy",
"i18n:govoplan-access.system_mail_profiles.5af3eb64": "System-Mailprofile",
"i18n:govoplan-access.system_managed_value.eb564eb1": "System managed{value0}",
"i18n:govoplan-access.system_permissions.53ff0ab2": "System permissions",
"i18n:govoplan-access.system_profiles.98adae54": "System profiles",
"i18n:govoplan-access.system_retention_policy.7027f6ba": "System retention policy",
"i18n:govoplan-access.system_retention.4191e7f7": "Systemaufbewahrung",
"i18n:govoplan-access.system_role_details.3d6a8f15": "System role details",
"i18n:govoplan-access.system_role_value_created.9c1a6bc8": "System role {value0} created.",
"i18n:govoplan-access.system_role_value_deleted.7b18a6f8": "System role {value0} deleted.",
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
"i18n:govoplan-access.system_role.91762640": "System role",
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
"i18n:govoplan-access.system.29d43743": "SYSTEM",
"i18n:govoplan-access.system.bc0792d8": "System",
"i18n:govoplan-access.temporary_password.62d60628": "Temporäres Passwort",
"i18n:govoplan-access.tenant_api_keys.4b1d81f8": "Mandanten-API-Schlüssel",
"i18n:govoplan-access.tenant_audit.492b9138": "Mandanten-Audit",
"i18n:govoplan-access.tenant_context.b401a2ad": "Tenant context",
"i18n:govoplan-access.tenant_custom.4307081e": "Mandanten-spezifisch",
"i18n:govoplan-access.tenant_details.5976ba72": "Tenant details",
"i18n:govoplan-access.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
"i18n:govoplan-access.tenant_general_settings.db1c3ba8": "Tenant general settings",
"i18n:govoplan-access.tenant_groups.47e6cc05": "Mandantengruppen",
"i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff": "Tenant-level administrative history for the active tenant. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f": "Tenant-level mail server profiles and policy limits for the active tenant.",
"i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c": "Tenant-level privacy and retention limits for the active tenant.",
"i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77": "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override.",
"i18n:govoplan-access.tenant_locale.8fc19914": "Tenant locale",
"i18n:govoplan-access.tenant_languages_help": "Mandantensprachen koennen nur aus den systemweit aktivierten Sprachen gewaehlt werden. Benutzer koennen aus den fuer den Mandanten aktivierten Sprachen waehlen.",
"i18n:govoplan-access.tenant_mail_profile_policy.239298a1": "Tenant mail profile policy",
"i18n:govoplan-access.tenant_mail_profiles.5132a623": "Mandanten-Mailprofile",
"i18n:govoplan-access.tenant_managed.843eed93": "Tenant managed",
"i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6": "Tenant membership created for {value0}.",
"i18n:govoplan-access.tenant_memberships.451de736": "Tenant memberships",
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Mandantenrollenvorlagen",
"i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails",
"i18n:govoplan-access.tenant_users.cb800b38": "Mandantenbenutzer",
"i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
"i18n:govoplan-access.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
"i18n:govoplan-access.tenant.3ca93c78": "Mandant",
"i18n:govoplan-access.tenant.affa34d7": "TENANT",
"i18n:govoplan-access.tenants.1f7ae776": "Mandanten",
"i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f": "The access admin route requires the platform auth-change callback.",
"i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc": "The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.",
"i18n:govoplan-access.the_secret_for.c60737ef": "The secret for",
"i18n:govoplan-access.the_selected_account.1211bfb9": "the selected account",
"i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7": "The selected user has no tenant permissions available to an API key.",
"i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f": "This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.",
"i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e": "This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.",
"i18n:govoplan-access.this_is_shown_once_for.b0f0f526": "Dies wird einmalig angezeigt für",
"i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f": "This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.",
"i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715": "This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed.",
"i18n:govoplan-access.time.6c82e6dd": "Time",
"i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b": "Übermitteln Sie es über einen separaten sicheren Kanal.",
"i18n:govoplan-access.type.3deb7456": "Typ",
"i18n:govoplan-access.updated.f2f8570d": "Aktualisiert",
"i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Used as this tenant's locale default for tenant-aware views and future formatting defaults.",
"i18n:govoplan-access.user_assignments.bc7cc801": "Benutzerzuweisungen",
"i18n:govoplan-access.user_file_connections.996444a0": "Benutzer-Dateiverbindungen",
"i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0": "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override.",
"i18n:govoplan-access.user_mail_profile_policy.529e035b": "User mail profile policy",
"i18n:govoplan-access.user_mail_profiles.f54a845a": "Benutzer-Mailprofile",
"i18n:govoplan-access.user_profiles.57730285": "User profiles",
"i18n:govoplan-access.user_retention_policy.2776f485": "User retention policy",
"i18n:govoplan-access.user_retention.f0966bbf": "Benutzeraufbewahrung",
"i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2": "User-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf": "User-scoped profiles and policy limits for campaign owners in the active tenant.",
"i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b": "User-scoped retention limits for campaigns owned by users in the active tenant.",
"i18n:govoplan-access.user.6eb0c612": "USER",
"i18n:govoplan-access.user.9f8a2389": "Benutzer",
"i18n:govoplan-access.users.57f2b181": "Benutzer",
"i18n:govoplan-access.users.81651889": "users,",
"i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2": "{value0} deactivated in this tenant.",
"i18n:govoplan-access.value_deactivated.ed3d027c": "{value0} deactivated.",
"i18n:govoplan-access.value_suspended.31731a28": "{value0} suspended.",
"i18n:govoplan-access.value_value.0e2772ed": "{value0} — {value1}",
"i18n:govoplan-access.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-access.yes.5397e058": "Ja",
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
}
};

View File

@@ -1,26 +1,33 @@
import { createElement, lazy } from "react";
import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
import { adminReadScopes } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
if (!onAuthChange) {
throw new Error("The access admin route requires the platform auth-change callback.");
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
}
return createElement(AdminPage, { settings, auth, onAuthChange });
}
export const accessModule: PlatformWebModule = {
id: "access",
label: "Access",
label: "i18n:govoplan-access.access.2f81a22d",
version: "1.0.0",
translations,
navItems: [
{ to: "/admin", label: "Admin", iconName: "admin", anyOf: adminReadScopes, order: 900 }
],
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
routes: [
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }
]
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }]
};
export default accessModule;
export default accessModule;