WebUI module split; docs
This commit is contained in:
62
README.md
62
README.md
@@ -1,32 +1,68 @@
|
|||||||
# govoplan-core
|
# govoplan-core
|
||||||
|
|
||||||
Reusable core components for the Government Operational Platform GovOPlaN.
|
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, tenant and RBAC infrastructure, governance policy, audit/auth helpers, module discovery, migration registration, and the shared WebUI shell. Feature code is supplied by installed modules.
|
||||||
|
|
||||||
This repository owns platform-level contracts and infrastructure that should not be coupled to a consuming product module:
|
## Repository ownership
|
||||||
|
|
||||||
- backend module manifests and registry validation
|
Core owns:
|
||||||
- server runner integration for routes, metadata and module availability
|
|
||||||
- database session/base primitives and module migration registration
|
|
||||||
- tenant/access/RBAC models and permission evaluation
|
|
||||||
- stable principal DTOs, token helpers and tenant datastore abstractions
|
|
||||||
- shared WebUI package `@govoplan/core-webui` for API/CSRF/auth helpers, core types, generic UI components, login UI and shell layout primitives
|
|
||||||
|
|
||||||
Modules register backend routes, permissions, nav items, frontend package metadata, SQLAlchemy metadata and migration locations through their `govoplan.modules` manifest. The core runner aggregates those contributions and exposes the combined platform.
|
- `govoplan_core.server.app:app`, the FastAPI entry point used by uvicorn
|
||||||
|
- `GovoplanServerConfig`, module discovery, registry validation, and route aggregation
|
||||||
|
- SQLAlchemy base/session helpers and module migration registration
|
||||||
|
- tenant/account/session/RBAC/governance/audit models and services
|
||||||
|
- core API routes for auth, admin, platform metadata, audit, and system health
|
||||||
|
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
|
||||||
|
|
||||||
## Development
|
Feature modules own their backend routers, models, migrations, permissions, frontend packages, nav items, and route contributions. Core should not import feature pages directly; it imports module manifests and renders their route contributions.
|
||||||
|
|
||||||
Install the backend core and module checkouts through the core development environment:
|
## Governance docs
|
||||||
|
|
||||||
|
Canonical policy documents live in `docs/`:
|
||||||
|
|
||||||
|
- [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md)
|
||||||
|
- [SYSTEM_GOVERNANCE_MANIFEST.md](docs/SYSTEM_GOVERNANCE_MANIFEST.md)
|
||||||
|
|
||||||
|
Modules may define module-specific permissions and policy behavior, but the platform-level permission model and governance hierarchy belong here.
|
||||||
|
|
||||||
|
## Backend development
|
||||||
|
|
||||||
|
Create or activate the core virtual environment, then install core and sibling modules from this repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Install WebUI dependencies from the core WebUI runner, which links the module WebUI packages from the neighboring module repositories:
|
Run the platform server from core. The default config loads the access/core module only; product configs can enable installed modules.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
GOVOPLAN_SERVER_CONFIG=app.govoplan_config:get_server_config \
|
||||||
|
./.venv/bin/python -m uvicorn govoplan_core.server.app:app --reload --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements-dev.txt` currently links the local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. Production deployments should use tagged git dependencies or published packages.
|
||||||
|
|
||||||
|
## WebUI development
|
||||||
|
|
||||||
|
Install and run from the core WebUI host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core/webui
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install
|
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install
|
||||||
|
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Production deployments should use tagged git dependencies or published package versions, not runtime file copying.
|
The host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances.
|
||||||
|
|
||||||
|
## Module contract
|
||||||
|
|
||||||
|
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||||
|
|
||||||
|
- permissions and role templates
|
||||||
|
- API routers
|
||||||
|
- SQLAlchemy metadata and migration locations
|
||||||
|
- nav metadata and frontend package metadata
|
||||||
|
- resource ACL providers and tenant summary/delete-veto providers
|
||||||
|
|
||||||
|
WebUI modules export a `PlatformWebModule` with nav items and route contributions. Core renders those routes with `settings` and `auth` context.
|
||||||
|
|||||||
291
docs/RBAC_MANIFEST.md
Normal file
291
docs/RBAC_MANIFEST.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Multi Seal Mail - Current RBAC and Resource-Access Model
|
||||||
|
|
||||||
|
**Updated:** 2026-06-16
|
||||||
|
**Current migration head:** `f5a6b7c8d9e0`
|
||||||
|
|
||||||
|
## Authorization Equation
|
||||||
|
|
||||||
|
An operation is permitted only when every applicable layer allows it:
|
||||||
|
|
||||||
|
```text
|
||||||
|
effective role/API-key capability
|
||||||
|
AND resource ownership/share access
|
||||||
|
AND workflow state
|
||||||
|
AND active governance/policy constraints
|
||||||
|
```
|
||||||
|
|
||||||
|
RBAC answers what an actor may do. ACLs answer which resource the actor may do it to. Workflow state and policy decide whether the operation is currently valid.
|
||||||
|
|
||||||
|
## Identity and Scope
|
||||||
|
|
||||||
|
```text
|
||||||
|
Account global login identity
|
||||||
|
+- User membership tenant-local identity
|
||||||
|
+- direct tenant roles
|
||||||
|
+- active group memberships
|
||||||
|
| +- inherited tenant roles
|
||||||
|
+- tenant-local API keys
|
||||||
|
|
||||||
|
Account
|
||||||
|
+- direct system-role assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
A browser session has one active tenant membership. System privileges do not silently grant tenant data access. API keys remain tenant-local and receive the intersection of their configured scopes and their owner's live tenant scopes on every request.
|
||||||
|
|
||||||
|
## Wildcards
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant:* every canonical tenant permission
|
||||||
|
system:* every canonical system permission
|
||||||
|
* legacy alias interpreted as tenant:* only
|
||||||
|
```
|
||||||
|
|
||||||
|
Tenant wildcards never grant system permissions.
|
||||||
|
|
||||||
|
## Canonical Tenant Permissions - 53
|
||||||
|
|
||||||
|
### Campaigns
|
||||||
|
|
||||||
|
```text
|
||||||
|
campaign:read
|
||||||
|
campaign:create
|
||||||
|
campaign:update
|
||||||
|
campaign:copy
|
||||||
|
campaign:archive
|
||||||
|
campaign:delete
|
||||||
|
campaign:share
|
||||||
|
campaign:validate
|
||||||
|
campaign:build
|
||||||
|
campaign:review
|
||||||
|
campaign:send_test
|
||||||
|
campaign:queue
|
||||||
|
campaign:control
|
||||||
|
campaign:send
|
||||||
|
campaign:retry
|
||||||
|
campaign:reconcile
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recipients
|
||||||
|
|
||||||
|
```text
|
||||||
|
recipients:read
|
||||||
|
recipients:write
|
||||||
|
recipients:import
|
||||||
|
recipients:export
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
```text
|
||||||
|
files:read
|
||||||
|
files:download
|
||||||
|
files:upload
|
||||||
|
files:organize
|
||||||
|
files:share
|
||||||
|
files:delete
|
||||||
|
files:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reports and Audit
|
||||||
|
|
||||||
|
```text
|
||||||
|
reports:read
|
||||||
|
reports:export
|
||||||
|
reports:send
|
||||||
|
audit:read
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Servers
|
||||||
|
|
||||||
|
```text
|
||||||
|
mail_servers:read
|
||||||
|
mail_servers:use
|
||||||
|
mail_servers:test
|
||||||
|
mail_servers:write
|
||||||
|
mail_servers:manage_credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tenant Administration
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin:users:read
|
||||||
|
admin:users:create
|
||||||
|
admin:users:update
|
||||||
|
admin:users:suspend
|
||||||
|
|
||||||
|
admin:groups:read
|
||||||
|
admin:groups:write
|
||||||
|
admin:groups:manage_members
|
||||||
|
|
||||||
|
admin:roles:read
|
||||||
|
admin:roles:write
|
||||||
|
admin:roles:assign
|
||||||
|
|
||||||
|
admin:api_keys:read
|
||||||
|
admin:api_keys:create
|
||||||
|
admin:api_keys:revoke
|
||||||
|
|
||||||
|
admin:settings:read
|
||||||
|
admin:settings:write
|
||||||
|
admin:policies:read
|
||||||
|
admin:policies:write
|
||||||
|
```
|
||||||
|
|
||||||
|
## Canonical System Permissions - 18
|
||||||
|
|
||||||
|
```text
|
||||||
|
system:tenants:read
|
||||||
|
system:tenants:create
|
||||||
|
system:tenants:update
|
||||||
|
system:tenants:suspend
|
||||||
|
|
||||||
|
system:accounts:read
|
||||||
|
system:accounts:create
|
||||||
|
system:accounts:update
|
||||||
|
system:accounts:suspend
|
||||||
|
|
||||||
|
system:roles:read
|
||||||
|
system:roles:write
|
||||||
|
system:roles:assign
|
||||||
|
|
||||||
|
system:access:read
|
||||||
|
system:access:assign
|
||||||
|
|
||||||
|
system:audit:read
|
||||||
|
system:settings:read
|
||||||
|
system:settings:write
|
||||||
|
system:governance:read
|
||||||
|
system:governance:write
|
||||||
|
```
|
||||||
|
|
||||||
|
`system:access:*` remains as a compatibility/read and assignment boundary for cross-tenant/system access handling. It is not a separate primary UI area.
|
||||||
|
|
||||||
|
## Default Tenant Roles
|
||||||
|
|
||||||
|
- **Owner:** `tenant:*`. At least one active operational owner must remain.
|
||||||
|
- **Tenant administrator:** settings, policies, users, groups, roles and API keys plus read access to campaigns/files/reports/audit. Real delivery remains separately delegable.
|
||||||
|
- **Administrator (legacy):** all tenant permissions for upgraded installations.
|
||||||
|
- **Access administrator:** membership and assignment management within delegation limits.
|
||||||
|
- **Campaign manager:** prepare, validate and build campaigns; no review approval or real delivery by default.
|
||||||
|
- **Reviewer:** inspect and approve prepared campaign messages.
|
||||||
|
- **Sender:** mock-test, queue, control, send, retry and reconcile prepared campaigns; can use/test approved mail profiles.
|
||||||
|
- **File manager:** managed file operations without campaign delivery rights.
|
||||||
|
- **Viewer:** read campaigns, recipients, files and reports.
|
||||||
|
- **Auditor:** read campaigns, recipient evidence, reports and audit records; export detailed evidence.
|
||||||
|
|
||||||
|
## Default System Roles
|
||||||
|
|
||||||
|
- **System owner:** `system:*`, protected. At least one active account must retain it.
|
||||||
|
- **System administrator:** all specific system permissions, editable and not protected.
|
||||||
|
- **System auditor:** read-only system registry/settings/governance/audit role, editable.
|
||||||
|
|
||||||
|
## Delegation Ceiling
|
||||||
|
|
||||||
|
For role definition, assignment and API-key creation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
requested scopes subset of actor delegateable scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. Tenant roles may contain tenant scopes only.
|
||||||
|
2. System roles may contain system scopes only.
|
||||||
|
3. Definition rights and assignment rights are separate.
|
||||||
|
4. Group definition and group membership management are separate.
|
||||||
|
5. API-key scopes are intersected with the owner's current effective scopes on every request.
|
||||||
|
6. Suspended accounts, users, tenants or groups stop contributing access immediately.
|
||||||
|
7. Administrative updates are field-sensitive; a user with only status authority cannot change role assignments.
|
||||||
|
|
||||||
|
## Campaign Ownership and ACLs
|
||||||
|
|
||||||
|
A campaign has exactly one owner:
|
||||||
|
|
||||||
|
```text
|
||||||
|
owner user OR owner group
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional active shares may target users or groups with `read` or `write`.
|
||||||
|
|
||||||
|
Resolution:
|
||||||
|
|
||||||
|
- owner user: read and write;
|
||||||
|
- member of owner group: read and write;
|
||||||
|
- explicit read share: read;
|
||||||
|
- explicit write share: read and write;
|
||||||
|
- `tenant:*`: tenant-wide ACL bypass;
|
||||||
|
- ordinary campaign permission without ownership/share: no object access.
|
||||||
|
|
||||||
|
ACLs do not add capabilities. A write share still needs the specific permission for update, validation, review, send, report, retry or reconciliation.
|
||||||
|
|
||||||
|
## Sensitive Recipient Boundary
|
||||||
|
|
||||||
|
Recipient-complete campaign JSON, message data and job detail require `recipients:read`. Recipient edits require `recipients:write`; exports require `recipients:export`; import is reserved for the dedicated recipient import/list workflow.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Permission | Operations |
|
||||||
|
|---|---|
|
||||||
|
| `files:read` | list, search, inspect, resolve metadata |
|
||||||
|
| `files:download` | download file bytes and generated ZIP archives |
|
||||||
|
| `files:upload` | upload files and ZIP contents |
|
||||||
|
| `files:organize` | create folders, rename, move, copy and bulk rename |
|
||||||
|
| `files:share` | create/revoke file shares |
|
||||||
|
| `files:delete` | delete/hide files and folders subject to retention |
|
||||||
|
| `files:admin` | tenant-wide administration of user/group file spaces |
|
||||||
|
|
||||||
|
## Mail Servers
|
||||||
|
|
||||||
|
| Permission | Boundary |
|
||||||
|
|---|---|
|
||||||
|
| `mail_servers:read` | profile metadata and effective policy visibility |
|
||||||
|
| `mail_servers:use` | select an approved profile without reading secrets |
|
||||||
|
| `mail_servers:test` | run server-side connection tests |
|
||||||
|
| `mail_servers:write` | define/edit profiles in allowed scopes |
|
||||||
|
| `mail_servers:manage_credentials` | create/replace SMTP/IMAP secrets or campaign-level credentials where policy allows |
|
||||||
|
|
||||||
|
Reusable encrypted profiles now exist. Effective usability is also constrained by hierarchical mail-profile policy, ownership, allowed/forced profile sets, credential inheritance/override rules and allow/deny patterns.
|
||||||
|
|
||||||
|
## Sessions, API Keys and CSRF
|
||||||
|
|
||||||
|
- Browser login creates an HttpOnly session cookie and a separate readable CSRF cookie.
|
||||||
|
- Unsafe cookie-authenticated requests require matching CSRF cookie/header and stored CSRF hash.
|
||||||
|
- API keys remain supported for CLI/automation and do not use browser CSRF.
|
||||||
|
- Login responses still expose a compatibility session token in the response body; the WebUI does not persist it.
|
||||||
|
|
||||||
|
## Legacy Compatibility
|
||||||
|
|
||||||
|
Runtime aliases remain only for names that are no longer canonical, including:
|
||||||
|
|
||||||
|
```text
|
||||||
|
campaign:write
|
||||||
|
attachments:read
|
||||||
|
attachments:write
|
||||||
|
admin:users
|
||||||
|
admin:users:write
|
||||||
|
admin:api_keys:write
|
||||||
|
admin:settings
|
||||||
|
system:tenants:write
|
||||||
|
system:access:write
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical scopes are not widened by runtime alias expansion after migration.
|
||||||
|
|
||||||
|
## Deferred Permission Families
|
||||||
|
|
||||||
|
Add these only with their corresponding implemented features:
|
||||||
|
|
||||||
|
```text
|
||||||
|
templates:*
|
||||||
|
address_books:*
|
||||||
|
recipient_lists:*
|
||||||
|
connectors:*
|
||||||
|
dsar:*
|
||||||
|
system:monitoring:read
|
||||||
|
system:backups:run
|
||||||
|
system:backups:restore
|
||||||
|
system:updates:apply
|
||||||
|
system:updates:rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
A separate `retention:*` family is not currently canonical because retention is managed through system settings and tenant policy scopes. Add it only if retention operation duties need separation from general policy/settings administration.
|
||||||
178
docs/SYSTEM_GOVERNANCE_MANIFEST.md
Normal file
178
docs/SYSTEM_GOVERNANCE_MANIFEST.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Multi Seal Mail - Current System and Tenant Governance Model
|
||||||
|
|
||||||
|
**Updated:** 2026-06-16
|
||||||
|
**Current migration head:** `f5a6b7c8d9e0`
|
||||||
|
|
||||||
|
## Governance Rule
|
||||||
|
|
||||||
|
System policy is authoritative for tenants and all lower levels. Each lower level may only narrow what it inherits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system
|
||||||
|
-> tenant
|
||||||
|
-> user or group owner
|
||||||
|
-> campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Lower levels do not widen privileges, allowed profiles, retention durations or credential rights granted by a higher level.
|
||||||
|
|
||||||
|
## Administration Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
SYSTEM
|
||||||
|
- Settings
|
||||||
|
- Retention
|
||||||
|
- Mail servers
|
||||||
|
- Tenants
|
||||||
|
- Users
|
||||||
|
- Groups
|
||||||
|
- System roles
|
||||||
|
- Tenant roles
|
||||||
|
- Audit
|
||||||
|
|
||||||
|
TENANT
|
||||||
|
- Settings boundary
|
||||||
|
- Users
|
||||||
|
- Groups
|
||||||
|
- Roles
|
||||||
|
- API keys
|
||||||
|
- Mail servers
|
||||||
|
- Retention
|
||||||
|
- Audit
|
||||||
|
|
||||||
|
USER
|
||||||
|
- User mail
|
||||||
|
- User retention
|
||||||
|
|
||||||
|
GROUP
|
||||||
|
- Group mail
|
||||||
|
- Group retention
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no separate System access page. Compatibility access scopes remain in the backend for assignment/read boundaries.
|
||||||
|
|
||||||
|
## Tenant Governance
|
||||||
|
|
||||||
|
System settings define tenant defaults and whether tenants may narrow selected options. Tenant overrides can only restrict:
|
||||||
|
|
||||||
|
- custom groups;
|
||||||
|
- custom roles;
|
||||||
|
- tenant API keys.
|
||||||
|
|
||||||
|
The backend enforces that tenant governance cannot widen system-denied privileges.
|
||||||
|
|
||||||
|
## Mail-Profile Governance
|
||||||
|
|
||||||
|
Mail server profiles may exist at these scopes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system
|
||||||
|
tenant
|
||||||
|
user
|
||||||
|
group
|
||||||
|
campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Effective campaign profile availability follows campaign ownership. A campaign owned by a user resolves through system, tenant, that user and campaign policy. A group-owned campaign resolves through system, tenant, that group and campaign policy.
|
||||||
|
|
||||||
|
Policy semantics:
|
||||||
|
|
||||||
|
- higher levels define the maximum available profile set;
|
||||||
|
- lower levels can further restrict the set;
|
||||||
|
- forced profiles mean the lower level must choose from the forced set;
|
||||||
|
- a forced set with one profile effectively enforces that profile;
|
||||||
|
- campaign-level profile creation is allowed only if the effective policy permits it;
|
||||||
|
- credentials may be inherited from the authoritative profile or supplied by a lower level only when override is allowed;
|
||||||
|
- deny patterns always win over allow patterns;
|
||||||
|
- empty or `*` allowlist means allow all except denied;
|
||||||
|
- non-empty allowlist means at least one allow rule must match and no deny rule may match.
|
||||||
|
|
||||||
|
Pattern targets:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SMTP hostname
|
||||||
|
IMAP hostname
|
||||||
|
envelope sender
|
||||||
|
From header
|
||||||
|
recipient domains
|
||||||
|
```
|
||||||
|
|
||||||
|
Ownership transfer is intentionally deferred as a two-step workflow: original owner initiates, new owner accepts and reselects/repairs the mail profile if their effective policy requires it.
|
||||||
|
|
||||||
|
## Retention Governance
|
||||||
|
|
||||||
|
Retention policy is hierarchical:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system -> tenant -> user/group -> campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Managed fields:
|
||||||
|
|
||||||
|
- raw campaign JSON retention days;
|
||||||
|
- generated EML retention days;
|
||||||
|
- stored report detail retention days;
|
||||||
|
- mock mailbox retention days;
|
||||||
|
- audit detail retention days.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- system may set concrete defaults or unlimited retention;
|
||||||
|
- system exposes allow-limiting toggles per field;
|
||||||
|
- tenants, users/groups and campaigns may only shorten inherited retention where the parent allows limiting;
|
||||||
|
- blank lower-level values inherit;
|
||||||
|
- mock mailbox retention is currently system-level because mock mailbox records do not yet carry tenant/campaign ownership metadata;
|
||||||
|
- dry-run/apply retention actions report affected classes before destructive cleanup.
|
||||||
|
|
||||||
|
## Role Definitions and Assignments
|
||||||
|
|
||||||
|
### System roles
|
||||||
|
|
||||||
|
System roles define instance-wide permissions. `system:*` is stored as one wildcard and displayed as granting the full system catalogue. System owner is protected.
|
||||||
|
|
||||||
|
### Tenant roles
|
||||||
|
|
||||||
|
Tenant roles can be system-governed templates or tenant-local definitions, subject to system tenant-governance settings and actor delegation ceilings. Wildcard counts are expanded against the canonical tenant catalogue.
|
||||||
|
|
||||||
|
## Audit Access
|
||||||
|
|
||||||
|
Audit access remains scope-separated:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system audit -> system:audit:read
|
||||||
|
tenant audit -> active tenant + audit:read
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit pages use server pagination, filtering and bounded grids.
|
||||||
|
|
||||||
|
## Tenant Switching
|
||||||
|
|
||||||
|
Tenant switching preserves the current URL when possible and falls back when a route/resource is not accessible in the new tenant context.
|
||||||
|
|
||||||
|
The tenant selector is hidden for ordinary single-tenant accounts and visible for multi-tenant or system tenant-management contexts.
|
||||||
|
|
||||||
|
## DataGrid Contract in Administration
|
||||||
|
|
||||||
|
Admin lists use bounded container grids:
|
||||||
|
|
||||||
|
- one flexible fill column;
|
||||||
|
- fixed total table width;
|
||||||
|
- compact action/status/count columns;
|
||||||
|
- resizable text/date columns;
|
||||||
|
- no intrinsic content growth;
|
||||||
|
- sticky headers where needed;
|
||||||
|
- server pagination for audit.
|
||||||
|
|
||||||
|
## Still Deferred
|
||||||
|
|
||||||
|
- real SMTP/IMAP test-bed verification and operator runbook;
|
||||||
|
- recipient import with column mapping;
|
||||||
|
- Seafile/external connector governance;
|
||||||
|
- system/tenant/group/user file-space hierarchy and external storage hierarchy;
|
||||||
|
- session/device revocation UI;
|
||||||
|
- backup/restore, monitoring and update procedures;
|
||||||
|
- DSAR workflows and evidence bundle verifier;
|
||||||
|
- campaign ownership transfer workflow;
|
||||||
|
- policy impact analysis before delete/disable/unshare/change;
|
||||||
|
- LDAP/OIDC/SAML provisioning;
|
||||||
|
- destructive tenant erasure orchestration.
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchMe } from "./api/auth";
|
import { fetchMe } from "./api/auth";
|
||||||
import { fetchPlatformModules } from "./api/platform";
|
import { fetchPlatformModules } from "./api/platform";
|
||||||
import { loadApiSettings, saveApiSettings } from "./api/client";
|
import { loadApiSettings, saveApiSettings } from "./api/client";
|
||||||
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
|
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
import { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
|
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||||
import { adminReadScopes } from "./utils/permissions";
|
import { adminReadScopes } from "./utils/permissions";
|
||||||
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules } from "./platform/modules";
|
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||||
import PlaceholderPage from "./features/PlaceholderPage";
|
|
||||||
import { getCampaign } from "@govoplan/campaign-webui";
|
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||||
const CampaignListPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignListPage })));
|
|
||||||
const CampaignWorkspace = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.CampaignWorkspace })));
|
|
||||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||||
const TemplatesPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.TemplatesPage })));
|
|
||||||
const FilesPage = lazy(() => import("@govoplan/files-webui").then((module) => ({ default: module.FilesPage })));
|
|
||||||
const OperatorQueuePage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.OperatorQueuePage })));
|
|
||||||
const AddressBookPage = lazy(() => import("@govoplan/campaign-webui").then((module) => ({ default: module.AddressBookPage })));
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
|
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
|
||||||
@@ -30,6 +22,7 @@ export default function App() {
|
|||||||
|
|
||||||
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||||
|
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||||
|
|
||||||
function updateSettings(next: ApiSettings) {
|
function updateSettings(next: ApiSettings) {
|
||||||
setSettings(next);
|
setSettings(next);
|
||||||
@@ -152,33 +145,17 @@ export default function App() {
|
|||||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/campaigns" element={
|
{moduleRoutes.map((route) => (
|
||||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
<Route
|
||||||
<CampaignListPage settings={settings} />
|
key={route.path}
|
||||||
</PermissionBoundary>
|
path={route.path}
|
||||||
} />
|
element={
|
||||||
<Route path="/campaigns/:campaignId/*" element={
|
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
||||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
|
{route.render({ settings, auth })}
|
||||||
<CampaignResourceRoute settings={settings} auth={auth} />
|
</PermissionBoundary>
|
||||||
</PermissionBoundary>
|
}
|
||||||
} />
|
/>
|
||||||
<Route path="/templates" element={<TemplatesPage />} />
|
))}
|
||||||
<Route path="/files" element={
|
|
||||||
<PermissionBoundary auth={auth} anyOf={["files:file:read"]} fallback={defaultRoute}>
|
|
||||||
<FilesPage settings={settings} auth={auth} />
|
|
||||||
</PermissionBoundary>
|
|
||||||
} />
|
|
||||||
<Route path="/address-book" element={<AddressBookPage />} />
|
|
||||||
<Route path="/operator" element={
|
|
||||||
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"]} fallback={defaultRoute}>
|
|
||||||
<OperatorQueuePage settings={settings} />
|
|
||||||
</PermissionBoundary>
|
|
||||||
} />
|
|
||||||
<Route path="/reports" element={
|
|
||||||
<PermissionBoundary auth={auth} anyOf={["campaigns:report:read"]} fallback={defaultRoute}>
|
|
||||||
<PlaceholderPage title="Reports" />
|
|
||||||
</PermissionBoundary>
|
|
||||||
} />
|
|
||||||
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
||||||
<Route path="/admin" element={
|
<Route path="/admin" element={
|
||||||
<PermissionBoundary auth={auth} anyOf={adminReadScopes} fallback={defaultRoute}>
|
<PermissionBoundary auth={auth} anyOf={adminReadScopes} fallback={defaultRoute}>
|
||||||
@@ -191,15 +168,3 @@ export default function App() {
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
|
||||||
const { campaignId = "" } = useParams();
|
|
||||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
|
||||||
const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, campaignId, tenantId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResourceAccessBoundary probe={probe} resetKey={`${tenantId}:${campaignId}`} fallback="/campaigns">
|
|
||||||
<CampaignWorkspace settings={settings} auth={auth} />
|
|
||||||
</ResourceAccessBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import { Navigate } from "react-router-dom";
|
|||||||
import type { AuthInfo } from "../types";
|
import type { AuthInfo } from "../types";
|
||||||
import { isApiError } from "../api/client";
|
import { isApiError } from "../api/client";
|
||||||
import DismissibleAlert from "./DismissibleAlert";
|
import DismissibleAlert from "./DismissibleAlert";
|
||||||
import { hasAnyScope } from "../utils/permissions";
|
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
|
|
||||||
type PermissionBoundaryProps = {
|
type PermissionBoundaryProps = {
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
anyOf: string[];
|
anyOf?: string[];
|
||||||
|
allOf?: string[];
|
||||||
fallback: string;
|
fallback: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PermissionBoundary({ auth, anyOf, fallback, children }: PermissionBoundaryProps) {
|
export function PermissionBoundary({ auth, anyOf = [], allOf = [], fallback, children }: PermissionBoundaryProps) {
|
||||||
return hasAnyScope(auth, anyOf) ? <>{children}</> : <Navigate to={fallback} replace />;
|
if (allOf.length && !allOf.every((scope) => hasScope(auth, scope))) return <Navigate to={fallback} replace />;
|
||||||
|
if (anyOf.length && !hasAnyScope(auth, anyOf)) return <Navigate to={fallback} replace />;
|
||||||
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResourceAccessBoundaryProps = {
|
type ResourceAccessBoundaryProps = {
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ export default function DataGrid<T>({
|
|||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startWidth: currentWidth,
|
startWidth: currentWidth,
|
||||||
minWidth: effectiveColumnMinWidth(column),
|
minWidth: effectiveColumnMinWidth(column),
|
||||||
maxWidth: Math.max(effectiveColumnMinWidth(column), column.maxWidth ?? 2000),
|
maxWidth: effectiveColumnMaxWidth(column),
|
||||||
baseWidths,
|
baseWidths,
|
||||||
rightResizeTargets,
|
rightResizeTargets,
|
||||||
bufferTarget: canUseBuffer ? {
|
bufferTarget: canUseBuffer ? {
|
||||||
@@ -1223,7 +1223,7 @@ function sanitizePersistedColumnState<T>(
|
|||||||
const column = columnMap.get(columnId);
|
const column = columnMap.get(columnId);
|
||||||
if (!column || !Number.isFinite(width)) return [];
|
if (!column || !Number.isFinite(width)) return [];
|
||||||
const minimum = effectiveColumnMinWidth(column);
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||||
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
|
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1246,7 +1246,7 @@ function sanitizePersistedColumnState<T>(
|
|||||||
|
|
||||||
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
|
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
|
||||||
const minimum = effectiveColumnMinWidth(column);
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||||
if (savedWidth !== undefined) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
|
if (savedWidth !== undefined) return `${Math.min(maximum, Math.max(minimum, savedWidth))}px`;
|
||||||
if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
|
if (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
|
||||||
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
if (column.width) return columnTrackWithMinimum(column.width, minimum);
|
||||||
@@ -1291,7 +1291,7 @@ function resizeTargetForColumn<T>(column: DataGridColumn<T>, baseWidths: Record<
|
|||||||
columnId: column.id,
|
columnId: column.id,
|
||||||
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
|
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
|
||||||
minWidth: minimum,
|
minWidth: minimum,
|
||||||
maxWidth: Math.max(minimum, column.maxWidth ?? 2000)
|
maxWidth: effectiveColumnMaxWidth(column, minimum)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1303,6 +1303,11 @@ function effectiveColumnMinWidth<T>(column: DataGridColumn<T>): number {
|
|||||||
return Math.max(column.minWidth ?? 0, affordanceWidth);
|
return Math.max(column.minWidth ?? 0, affordanceWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function effectiveColumnMaxWidth<T>(column: DataGridColumn<T>, minimum = effectiveColumnMinWidth(column)): number {
|
||||||
|
if (column.maxWidth !== undefined) return Math.max(minimum, column.maxWidth);
|
||||||
|
return Math.max(minimum, isFlexibleWidth(column.width) ? BUFFER_MAX_WIDTH : 2000);
|
||||||
|
}
|
||||||
|
|
||||||
function totalColumnWidth<T>(columns: DataGridColumn<T>[], widths: Record<string, number>): number {
|
function totalColumnWidth<T>(columns: DataGridColumn<T>[], widths: Record<string, number>): number {
|
||||||
return columns.reduce((total, column) => total + (widths[column.id] ?? columnPixelWidth(column)), 0);
|
return columns.reduce((total, column) => total + (widths[column.id] ?? columnPixelWidth(column)), 0);
|
||||||
}
|
}
|
||||||
@@ -1450,7 +1455,7 @@ function isFlexibleWidth(width?: string | number): boolean {
|
|||||||
|
|
||||||
function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number {
|
function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number {
|
||||||
const minimum = effectiveColumnMinWidth(column);
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
|
const maximum = effectiveColumnMaxWidth(column, minimum);
|
||||||
const configured = measuredWidth
|
const configured = measuredWidth
|
||||||
?? savedWidth
|
?? savedWidth
|
||||||
?? (typeof column.width === "number" ? column.width : parsePixelWidth(column.width))
|
?? (typeof column.width === "number" ? column.width : parsePixelWidth(column.width))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Activity, FileText, Folder, Form, LayoutDashboard, MailCheck, Users, ty
|
|||||||
import campaignModule from "@govoplan/campaign-webui";
|
import campaignModule from "@govoplan/campaign-webui";
|
||||||
import filesModule from "@govoplan/files-webui";
|
import filesModule from "@govoplan/files-webui";
|
||||||
import mailModule from "@govoplan/mail-webui";
|
import mailModule from "@govoplan/mail-webui";
|
||||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||||
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
|
|
||||||
export const shellNavItems: PlatformNavItem[] = [
|
export const shellNavItems: PlatformNavItem[] = [
|
||||||
@@ -73,6 +73,10 @@ export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavIte
|
|||||||
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])].sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])].sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||||
|
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
|
}
|
||||||
|
|
||||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = legacyModules): PlatformNavItem[] {
|
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = legacyModules): PlatformNavItem[] {
|
||||||
return navItemsForModules(modules).filter((item) => {
|
return navItemsForModules(modules).filter((item) => {
|
||||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export type PlatformNavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformRouteContext = {
|
export type PlatformRouteContext = {
|
||||||
|
settings: ApiSettings;
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ export type PlatformRouteContribution = {
|
|||||||
path: string;
|
path: string;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
order?: number;
|
||||||
render: (context: PlatformRouteContext) => ReactNode;
|
render: (context: PlatformRouteContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user