feat: add access module boundary migrations
This commit is contained in:
35
README.md
35
README.md
@@ -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
|
||||
|
||||
157
docs/ACCESS_MODULE_BOUNDARY.md
Normal file
157
docs/ACCESS_MODULE_BOUNDARY.md
Normal 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.
|
||||
177
docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md
Normal file
177
docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md
Normal 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.
|
||||
97
docs/OPENDESK_IDENTITY_BOUNDARY.md
Normal file
97
docs/OPENDESK_IDENTITY_BOUNDARY.md
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.4"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
21
src/govoplan_access/auth/__init__.py
Normal file
21
src/govoplan_access/auth/__init__.py
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
376
src/govoplan_access/backend/configuration_provider.py
Normal file
376
src/govoplan_access/backend/configuration_provider.py
Normal 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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
256
src/govoplan_access/backend/explanation.py
Normal file
256
src/govoplan_access/backend/explanation.py
Normal 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",
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
1
src/govoplan_access/backend/migrations/__init__.py
Normal file
1
src/govoplan_access/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
156
src/govoplan_access/backend/semantic.py
Normal file
156
src/govoplan_access/backend/semantic.py
Normal 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()
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
112
webui/src/features/admin/FileConnectorsPanel.tsx
Normal file
112
webui/src/features/admin/FileConnectorsPanel.tsx
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
33
webui/src/features/admin/utils/deltaRows.ts
Normal file
33
webui/src/features/admin/utils/deltaRows.ts
Normal 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;
|
||||
}
|
||||
630
webui/src/i18n/generatedTranslations.ts
Normal file
630
webui/src/i18n/generatedTranslations.ts
Normal 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."
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user