WebUI module split; docs

This commit is contained in:
2026-06-24 10:18:47 +02:00
parent 30c11a6dcf
commit 44516a03aa
8 changed files with 558 additions and 74 deletions

View File

@@ -1,32 +1,68 @@
# 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
- 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
Core owns:
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
cd /mnt/DATA/git/govoplan-core
./.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
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 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
View 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.

View 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.

View File

@@ -1,26 +1,18 @@
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchMe } from "./api/auth";
import { fetchPlatformModules } from "./api/platform";
import { loadApiSettings, saveApiSettings } from "./api/client";
import type { ApiSettings, AuthInfo, LoginResponse, PlatformModuleInfo } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
import { PermissionBoundary } from "./components/AccessBoundary";
import { adminReadScopes } from "./utils/permissions";
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules } from "./platform/modules";
import PlaceholderPage from "./features/PlaceholderPage";
import { getCampaign } from "@govoplan/campaign-webui";
import { firstAccessibleRoute, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
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 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() {
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
@@ -30,6 +22,7 @@ export default function App() {
const webModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
function updateSettings(next: ApiSettings) {
setSettings(next);
@@ -152,33 +145,17 @@ export default function App() {
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/campaigns" element={
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
<CampaignListPage settings={settings} />
</PermissionBoundary>
} />
<Route path="/campaigns/:campaignId/*" element={
<PermissionBoundary auth={auth} anyOf={["campaigns:campaign:read"]} fallback={defaultRoute}>
<CampaignResourceRoute settings={settings} auth={auth} />
</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>
} />
{moduleRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
{route.render({ settings, auth })}
</PermissionBoundary>
}
/>
))}
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
<Route path="/admin" element={
<PermissionBoundary auth={auth} anyOf={adminReadScopes} fallback={defaultRoute}>
@@ -191,15 +168,3 @@ export default function App() {
</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>
);
}

View File

@@ -3,17 +3,20 @@ import { Navigate } from "react-router-dom";
import type { AuthInfo } from "../types";
import { isApiError } from "../api/client";
import DismissibleAlert from "./DismissibleAlert";
import { hasAnyScope } from "../utils/permissions";
import { hasAnyScope, hasScope } from "../utils/permissions";
type PermissionBoundaryProps = {
auth: AuthInfo;
anyOf: string[];
anyOf?: string[];
allOf?: string[];
fallback: string;
children: ReactNode;
};
export function PermissionBoundary({ auth, anyOf, fallback, children }: PermissionBoundaryProps) {
return hasAnyScope(auth, anyOf) ? <>{children}</> : <Navigate to={fallback} replace />;
export function PermissionBoundary({ auth, anyOf = [], allOf = [], fallback, children }: PermissionBoundaryProps) {
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 = {

View File

@@ -670,7 +670,7 @@ export default function DataGrid<T>({
startX: event.clientX,
startWidth: currentWidth,
minWidth: effectiveColumnMinWidth(column),
maxWidth: Math.max(effectiveColumnMinWidth(column), column.maxWidth ?? 2000),
maxWidth: effectiveColumnMaxWidth(column),
baseWidths,
rightResizeTargets,
bufferTarget: canUseBuffer ? {
@@ -1223,7 +1223,7 @@ function sanitizePersistedColumnState<T>(
const column = columnMap.get(columnId);
if (!column || !Number.isFinite(width)) return [];
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))]];
})
);
@@ -1246,7 +1246,7 @@ function sanitizePersistedColumnState<T>(
function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): string {
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 (typeof column.width === "number") return `${Math.min(maximum, Math.max(minimum, column.width))}px`;
if (column.width) return columnTrackWithMinimum(column.width, minimum);
@@ -1291,7 +1291,7 @@ function resizeTargetForColumn<T>(column: DataGridColumn<T>, baseWidths: Record<
columnId: column.id,
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
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);
}
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 {
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 {
const minimum = effectiveColumnMinWidth(column);
const maximum = Math.max(minimum, column.maxWidth ?? 2000);
const maximum = effectiveColumnMaxWidth(column, minimum);
const configured = measuredWidth
?? savedWidth
?? (typeof column.width === "number" ? column.width : parsePixelWidth(column.width))

View File

@@ -2,7 +2,7 @@ import { Activity, FileText, Folder, Form, LayoutDashboard, MailCheck, Users, ty
import campaignModule from "@govoplan/campaign-webui";
import filesModule from "@govoplan/files-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";
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));
}
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[] {
return navItemsForModules(modules).filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;

View File

@@ -122,6 +122,7 @@ export type PlatformNavItem = {
};
export type PlatformRouteContext = {
settings: ApiSettings;
auth: AuthInfo;
};
@@ -129,6 +130,7 @@ export type PlatformRouteContribution = {
path: string;
anyOf?: string[];
allOf?: string[];
order?: number;
render: (context: PlatformRouteContext) => ReactNode;
};