67 Commits

Author SHA1 Message Date
d487726f4d chore(release): bump Core to 0.1.13 2026-07-22 10:40:27 +02:00
e6fc07da37 chore(release): bundle Campaign 0.1.10 2026-07-22 10:38:37 +02:00
e6d589eb07 fix(release): package Core migration runtime 2026-07-22 10:34:34 +02:00
59610e21d2 chore(release): record reviewed 0.1.12 migration heads 2026-07-22 09:06:56 +02:00
cece71d945 feat(webui): synchronize external DataGrid queries 2026-07-22 09:03:11 +02:00
22e8183846 fix(webui): translate MetricCard content 2026-07-22 08:48:42 +02:00
aa111a5fe1 chore(release): bump Core to 0.1.12 2026-07-22 08:41:54 +02:00
e6062fe9e4 fix(webui): enforce full-result DataGrid queries 2026-07-22 08:05:11 +02:00
987ca894ed chore(release): record reviewed 0.1.11 migration heads 2026-07-22 04:42:30 +02:00
4caa326878 chore(core): bump version to 0.1.11 2026-07-22 03:41:24 +02:00
8c4c4456c6 feat(core): define auditable poll response retirement 2026-07-22 03:31:05 +02:00
6abe292ac8 feat(core): define governed poll participation contract 2026-07-22 03:21:03 +02:00
fea2807754 feat(core): define atomic poll option ordering 2026-07-22 03:20:28 +02:00
22646c614c feat(core): add bounded people picker foundation 2026-07-22 03:01:56 +02:00
17376332a2 feat(core): configure bounded scheduling cancellation notices 2026-07-22 02:58:09 +02:00
0946bc84a9 feat(core): support explicit public module routes 2026-07-22 02:58:03 +02:00
a18499cbb5 feat(core): require scoped user workflows 2026-07-22 01:55:24 +02:00
36d7b73bb5 fix(webui): allow card content to overflow 2026-07-22 01:49:24 +02:00
b89a2d15f1 chore(core): bump version to 0.1.10 2026-07-21 20:47:54 +02:00
7f923afdad docs(core): refine function-bound postbox encryption 2026-07-21 20:47:54 +02:00
a7683c5d4a feat(core): add resilient fixed-window throttling 2026-07-21 20:47:54 +02:00
41ad057f7e feat(webui): standardize discard and table actions 2026-07-21 20:47:54 +02:00
bf0729eb59 feat(core): add authenticated baseline role templates 2026-07-21 20:47:54 +02:00
c4b90181e0 fix(webui): localize contextual Mail help 2026-07-21 19:15:56 +02:00
55ed194a99 fix(webui): respect configured documentation access 2026-07-21 19:01:00 +02:00
b3b0cf0fca feat(webui): open contextual configured handbooks 2026-07-21 18:42:31 +02:00
fa9119bea7 security(webui): block remote mail preview content 2026-07-21 17:52:02 +02:00
70ca772138 test: gate Mail on Campaign access interface 2026-07-21 17:51:35 +02:00
2eae5c4df6 test: align module contracts with Mail-owned delivery 2026-07-21 17:15:19 +02:00
57fe6c6006 security(connectors): reject tunneled metadata addresses 2026-07-21 16:54:10 +02:00
713afdb39b fix(installer): enlist table retirement transaction 2026-07-21 16:44:20 +02:00
77f8d15d17 docs: clarify fail-closed connector SDKs 2026-07-21 15:53:22 +02:00
fda99d40eb docs: clarify institutional identity ownership 2026-07-21 15:46:55 +02:00
5ab1af803b docs: align technical roadmap to reference program 2026-07-21 15:45:39 +02:00
0845e99cf6 Block SDK-managed secondary connector peers 2026-07-21 15:43:04 +02:00
28a0a596a6 Fail closed for unpinned connector transports 2026-07-21 15:36:32 +02:00
ae74189588 fix(webui): complete central admin control styles 2026-07-21 14:01:56 +02:00
09b5009187 feat(webui): extend shared explorer tree actions 2026-07-21 13:58:38 +02:00
2ca61059dc refactor(webui): describe organization function actions 2026-07-21 13:51:19 +02:00
865901f090 Centralize shared layout style contracts 2026-07-21 13:46:59 +02:00
2ac1e64daa refactor(webui): share outside-dismiss behavior 2026-07-21 13:35:09 +02:00
7526c5ebb2 fix(webui): centralize form control layout 2026-07-21 13:31:32 +02:00
8e1f64c790 feat(webui): add central selection list 2026-07-21 13:30:39 +02:00
66e4783d2e feat(webui): add central icon button 2026-07-21 13:23:59 +02:00
7af86b42eb Centralize explorer work-surface styling 2026-07-21 13:19:20 +02:00
ad202f1267 Centralize shared small-note styling 2026-07-21 13:19:20 +02:00
6526f37aae feat(webui): centralize resource access explanations 2026-07-21 13:18:30 +02:00
9dabd9356d Document Core test bootstrap lint exemptions 2026-07-21 13:11:24 +02:00
6502775bf7 Block connector limited-broadcast targets 2026-07-21 13:02:29 +02:00
b2492b820f Harden private connector address validation 2026-07-21 12:55:01 +02:00
78d9ae48b2 feat(webui): centralize disabled button reasons 2026-07-21 12:38:45 +02:00
4cb3e94de3 feat(webui): expose central pagination bar 2026-07-21 12:36:49 +02:00
9131838b98 refactor(webui): generalize central selection lists 2026-07-21 12:34:35 +02:00
8e9eb6e1f5 feat(webui): centralize contextual table actions 2026-07-21 12:22:35 +02:00
249bf63eb8 fix(release): remove unavailable tagged webui packages 2026-07-21 12:22:07 +02:00
248e3dc70e test(release): isolate catalog contract projection 2026-07-21 12:21:38 +02:00
230ecf42b0 Enforce deployment security boundaries 2026-07-21 12:10:05 +02:00
825791e9b0 Harden outbound connector transports 2026-07-21 12:09:44 +02:00
7184b6cdd6 Dispose replaced migration database handles 2026-07-21 03:22:59 +02:00
ea8c600dce Cover Notifications in WebUI permutations 2026-07-21 03:18:08 +02:00
1839693575 Manage nested dialogs through a central stack 2026-07-21 03:18:08 +02:00
183bf7aef0 Ignore generated file drop test builds 2026-07-21 03:18:08 +02:00
37a5dfb182 Use managed Files in Campaign smoke fixtures 2026-07-21 03:18:08 +02:00
844f934379 Redact devserver database credentials 2026-07-21 03:18:08 +02:00
a98475f7bc Harden installer runtime secrets 2026-07-21 03:18:08 +02:00
1153c9dd36 Clean Core security audit findings 2026-07-21 03:18:07 +02:00
7eef52776c docs: link product vision to technical roadmap 2026-07-20 20:48:47 +02:00
107 changed files with 7799 additions and 761 deletions

2
.gitignore vendored
View File

@@ -138,11 +138,13 @@ dist
# Local WebUI test/build scratch directories
.component-test-build/
.file-drop-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.file-drop-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/

View File

@@ -5,7 +5,11 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan

View File

@@ -192,15 +192,60 @@ prefer `FILE_STORAGE_*`.
| Setting | Default | Notes |
| --- | --- | --- |
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
Interactive password login is enabled with fixed-window limits of 10 failures
per normalized identity and 100 failures per direct client over 900 seconds.
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
when Redis is reachable so replicas share state; a bounded process-local
fallback keeps development and Redis outages functional, with per-process
enforcement until Redis recovers.
### Outbound Connector Egress
| Setting | Default | Notes |
| --- | --- | --- |
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when pinned HTTP(S), DAV, SMTP, or IMAP transports must reach internal addresses. It does not enable an SDK transport that cannot pin every peer. |
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
Production-like configuration validation requires the private-network choice to
be explicit. HTTP connector downloads are streamed up to the configured bound,
and credential-bearing DAV redirects remain confined to their configured
origin.
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
connect to the same approved address record while retaining the original host
for HTTP Host, TLS SNI, and certificate verification. Live SMB and S3 access
fails closed in both public-only and private-network deployments: the current
SDK transports cannot pin every initial and secondary peer or revalidate every
SDK-managed redirect/referral. An explicit IP endpoint does not bypass this
rule. Production deployments should still enforce the same decision at their
worker/container egress firewall or outbound proxy as a second boundary.
File connector TLS verification may be disabled only in dev/test. A custom CA
bundle must be an existing regular file whose resolved absolute path is listed
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
credentials are supported only in deployment-owned connector JSON and require
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
must use encrypted stored credentials or a scoped secret-provider reference.
Public URLs are currently supplied by deployment/reverse-proxy configuration and
module settings. Do not hardcode them in core; configuration packages should ask
for portal, WebUI, postbox, and notification URLs when they become relevant.
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
aligned with the reverse proxy and do not expose the application server directly
through the same trusted address range.
### Module Catalogs, Licenses, And Trust Roots

View File

@@ -1,13 +1,23 @@
# GovOPlaN Master Roadmap
This roadmap is the durable product north star and sequencing guide for
GovOPlaN as a modular platform for administrative operations. It keeps the
product moving without turning every possible public-sector need into an
immediate implementation track.
This roadmap is the technical and module-sequencing companion for GovOPlaN as
a modular platform for administrative operations. It translates the
cross-product outcome horizons into dependency waves without turning every
possible public-sector need into an immediate implementation track.
Use this document for product direction, sequencing, and module routing. Issues
are the active backlog; this document is durable planning context and should be
mirrored to the Gitea wiki.
Use this document for technical sequencing, module routing, and implementation
gates. Issues are the active backlog; this document is durable architecture
planning context and should be mirrored to the Gitea wiki.
The meta repository's
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
describes the corresponding cross-product stakeholder visions, configurable
service and operating configurations, connected outcome stories, and
capability horizons. The selected five-stage delivery sequence and its gates
are in the meta repository's
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
## Product Thesis
@@ -113,7 +123,8 @@ pattern exists.
## Focus Rules
1. Build one reference journey per wave.
1. Build one selected reference journey stage at a time; a later capability
cluster is not an active program merely because it appears below.
2. Do not implement a module because the repository exists.
3. Do not add module-to-module imports for optional behavior.
4. Every new domain module must justify its own semantics beyond `cases`,
@@ -138,11 +149,15 @@ pattern exists.
| Internal work queues and tasks | `govoplan-tasks` |
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
| Canonical subjects and account links | `govoplan-identity` |
| Organizational structures, units, and functions | `govoplan-organizations` |
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
| Service directory/catalog | `govoplan-portal` |
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
| Roles, permissions, tenants, policy, audit | `govoplan-access`, `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| External software integration | `govoplan-connectors` |
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
| Reports, BI, and management visibility | `govoplan-reporting` |
@@ -189,56 +204,82 @@ an editor applies a high-impact configuration change.
## Reference Journeys
The roadmap should be driven by three journeys.
The active sequence is selected. Workflow remains deliberately deferred and is
not a dependency of these journeys.
### Journey 1: Permit To Payment
### Journey 1: Campaign Demonstration Composition
This is the primary public-administration journey.
Campaign is the first complete proof of modular composition. Campaign owns
intent, recipient snapshots, personalization, execution state, and delivery
evidence. Mail owns reusable profiles, credentials, protocol policy, and
provider execution; Campaign stores only a selected profile reference. Files
owns storage, connector profiles, file policy, and provenance.
1. A person applies for a permit through the public portal.
2. The applicant uploads required files and submits structured form data.
3. Submission creates a case, a workflow instance, and an internal task.
4. Completing the task creates a postbox message, a notification, and an email
notification with an appointment proposal.
5. The applicant accepts an appointment, which updates the calendar and the
workflow state.
6. During the appointment, the case is opened and the permit is generated from
a governed template.
7. The payment is processed and linked to the case and accounting handoff.
8. The permit, payment evidence, communication history, audit trail, retention
state, and records evidence remain available according to policy.
The technical gate is a pinned Campaign/Mail/Files composition with central
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
semantics. Readers must not receive backend paths, worker claims, secrets, or
raw provider diagnostics.
This journey proves the platform can coordinate modules without core knowing
module internals.
### Journey 2: Function-Bound Postbox Delivery
### Journey 2: Training To Certificate
Postbox accepts delivery to an addressable postbox or a function in an
organizational unit. Organizations owns units and functions, Identity owns
subjects, IDM owns identity-to-function assignments and upstream sync, and
Access resolves current roles, delegation, acting context, and permission.
This is the best university-administration and internal-administration journey.
Campaign consumes a typed delivery-target capability without importing Postbox
or identity internals. Reassignment changes future access without moving the
message; vacancy or ambiguous acting context fails visibly; delivery, access,
and correction remain auditable.
1. Course or training offer is planned.
2. Room, trainer, resource, and capacity are booked.
3. Participants register or are assigned.
4. Attendance is tracked.
5. Certificate or participation confirmation is issued.
6. Evidence remains available through records, files, audit, and docs.
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
This journey keeps `booking`, `resources`, `learning`, and `certificates`
focused instead of becoming broad ERP replacements.
An authenticated user follows an opaque, short-lived launch reference from HIS
or another specialist system. GovOPlaN re-authorizes the actor, resolves a
curated data context server-side, displays source/freshness/version, and renders
one reproducible document and report.
### Journey 3: Report To Resolution
Templates owns definition/version/schema/rendering, Reporting owns source
selection/parameters/execution/export, Files owns generated bytes, and
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
or trusted raw personal data. Retries are idempotent and generation evidence
connects source, snapshot/reference, transformation, definition, parameters,
output checksum, actor, and policy.
This is the internal operations and municipal issue-reporting journey.
### Journey 4: Governed University BI Path
1. A person reports an issue.
2. The issue is triaged into helpdesk, facilities, assets, or a case.
3. Work is assigned, tracked, and escalated.
4. Evidence, communication, and status updates are preserved.
5. Reports show workload, SLA, recurring problems, and completion.
Starting from the Journey 3 source contract, one bounded university dataset is
catalogued, staged by snapshot or watermark, validated, transformed through a
versioned lineage graph, and exposed as a policy-aware analytical data product.
The result must preserve official-key mappings, organizational and reporting
date semantics, quality findings, quarantine/replay, transparent calculation,
and reproducible promotion between development, test, and production.
This journey prevents `helpdesk`, `issue-reporting`, `facilities`, and `assets`
from becoming disconnected ticket silos.
Reporting consumes the product. Create `govoplan-datasources` or
`govoplan-dataflow` only after the concrete path proves repeated ownership that
does not belong to connectors, Reporting, or the producing domain module.
## Roadmap Waves
### Journey 5: Collaborative Document Lifecycle
An uploaded or generated artifact becomes a DMS document. Files continues to
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
comments, review, approval, comparison, and recovery; a collaboration connector
owns provider-specific protocol behavior; Records owns later classification,
hold, archive, and disposal.
The gate requires no silent lost updates, short-lived and currently authorized
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
immutable accepted renditions, and a Records-ready handoff with stable content
and provenance.
## Capability Dependency Waves
The waves below remain a dependency and ownership catalogue for the wider
product vision. They are not the active delivery order. The five selected
journeys above and the meta roadmap decide what is implemented now; other
clusters remain dormant until a selected journey consumes them or they are
explicitly reprioritized.
### Wave 0: Platform Spine
@@ -248,9 +289,13 @@ Refine:
- `govoplan-core`: module discovery, capabilities, events, migrations, release
catalog, configuration package runtime, WebUI shell.
- `govoplan-access`: identities, sessions, API keys, users, groups, roles,
memberships, function assignments, delegation, RBAC decisions.
- `govoplan-tenancy`: tenant and organizational-unit boundaries.
- `govoplan-identity`: canonical identities and account links.
- `govoplan-organizations`: organizational structures, units, and functions.
- `govoplan-idm`: identity-to-function assignments, directory synchronization,
preview, conflicts, and reconciliation.
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
function-to-role projection, delegation, acting context, and RBAC decisions.
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
directory, assurance, and later encrypted postbox key access.
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
@@ -530,17 +575,30 @@ Before a module becomes release-included, it needs:
- smoke test or permutation test
- no required imports from optional modules
## Priority Order Summary
## Technical Dependency Order Summary
1. Stabilize the platform spine.
2. Deliver permit-to-payment MVP.
3. Build booking and resource operations.
4. Add learning and certificates.
5. Add issue reporting and helpdesk.
6. Add records, DMS, search, and transparency.
7. Add procurement, contracts, grants, and finance handoff.
8. Add committee and consultation workflows.
9. Expand integration, dataflow, reporting, and operations.
Use this active order while respecting the ownership and implementation gates
in the capability waves:
1. Keep the platform/release spine green and extend connector, identity,
external-effect, provenance, documentation, focused-view, recovery, and
version contracts only as the current journey requires.
2. Complete and package Campaign with Mail-owned profiles, Files, target
delivery/recovery, central UI, and adaptive documentation.
3. Implement function-bound Postbox delivery through
OrganizationsIdentityIDMAccess and consume it from Campaign through a
typed capability.
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
launch.
5. Extend that concrete source into one governed university analytical data
product before generalizing data-source or dataflow ownership.
6. Implement Files-backed DMS versions and one provider-neutral collaborative
editing lifecycle, then connect Records handoff.
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
activate another capability cluster only when the current journey needs it
or the product roadmap explicitly reprioritizes it.
8. Resume Workflow only by explicit product decision and constrain it with
stable actions from one demonstrated package.
## Deliberate Deferrals
@@ -548,7 +606,8 @@ Defer these until a reference journey proves the need:
- full ERP replacement
- native project management beyond connector support
- broad BI/dataflow platform
- an unbounded general-purpose dataflow platform; the bounded governed BI
reference journey is selected
- every possible public-sector protocol adapter
- rich LMS behavior beyond training administration
- full qualified digital signing/trust services beyond the identity-trust and
@@ -645,19 +704,24 @@ Release composition and tag-only repository handling are documented in
## Next Practical Work
The next planning step should create or update Gitea issues for Wave 0 and Wave
1 only. Later waves should stay as roadmap context until the permit-to-payment
MVP is demonstrable.
The active cross-product story is
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
Module repositories own implementation issues; do not clone their state here.
Recommended immediate issue buckets:
Immediate issue buckets:
- platform spine hardening
- configuration package preflight and rollback
- forms-runtime MVP
- portal submission MVP
- cases/workflow/tasks integration MVP
- template-generated decision document
- postbox/notification handoff
- appointment/booking handoff
- payment evidence handoff
- configured documentation for the reference process
- fail-closed connector destination pinning and private-network deployment
control for every real transport
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
- immediate audited secret deletion when a provider/profile is removed
- Campaign central-component and role-safe UI acceptance
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
security/acceptance documentation
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
restore proof for the pinned Campaign reference composition
Once that gate is demonstrable, activate the existing Postbox model, access,
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
and DMS collaboration remain durable selected direction, but should be
decomposed only as the preceding stage stabilizes or a bounded independent
contract can be implemented without pre-deciding target-system choices.

View File

@@ -453,6 +453,18 @@ The manifest should declare:
- navigation metadata using serializable icon names
- uninstall guard providers for data, migration, worker, or scheduler vetoes
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
only when every authenticated tenant member must receive that narrow baseline
while the contributing module is installed. Access derives the explicit grant
from the active manifest set during authorization without mutating the request
transaction. It may materialize a non-assignable role row for administration,
but no per-user assignment is required and role edits cannot remove the
baseline.
This is not a shortcut for feature authorization: keep the template narrow and
continue to enforce each domain action's own permission and resource policy.
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
templates are rejected by registry validation.
Backend nav metadata must use icon-name strings, not frontend components:
```python

View File

@@ -57,6 +57,11 @@ The trust layer should provide:
- key rotation and epoch tracking
- recovery policy hooks
Recovery must be organizationally governed. A server-held universal plaintext
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
grant needs an explicit assurance profile, authority/quorum, audit trail, and
user-visible consequence.
## Role And Function Postboxes
Role-bound access needs special handling. A postbox can be bound to an
@@ -75,6 +80,30 @@ rewrapping service:
Key epochs are required when role membership changes. Older messages may remain
readable according to policy, but new access must use the current epoch.
The function-bound container exists independently of membership. It may remain
vacant and continue to receive ciphertext without falling back to an unrelated
personal mailbox. Zero, one, or several incumbents are valid states. Each
incumbent receives an independently auditable, device-bound wrapped-key path;
the postbox is never copied into their account ownership.
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
encryption permits the normal rotation path to rewrap per-message data keys
rather than rewrite large ciphertext objects; a security policy may require
full content re-encryption for selected compromise or cryptographic-profile
events. The history available to a new incumbent must be selected policy (all
retained history, a bounded historical window, or assignment-time content) and
recorded with the grant.
Delegation is a time-bounded represented-function grant, not a copy or
substitution of the postbox. Expiry or withdrawal stops future key release and
actions. It cannot revoke plaintext already decrypted, printed, exported, or
captured outside the platform. Multiple simultaneous incumbents and delegates
remain distinguishable in key-fetch and action evidence.
Postbox content and signed manifests are immutable. Correction or replacement
creates a linked new object/version; it never silently substitutes ciphertext
or evidence that another actor may already have inspected.
## External Recipients
External recipients may need one-time or time-limited access without a full

View File

@@ -40,10 +40,16 @@ set +a
The command reports all known blockers at once. Production-like/self-hosted
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development
`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
catalog source is configured.
Connector process-secret names and custom CA files are deployment-owned through
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
either boundary.
## Production-Like Dev Stack
Use the local production-like wrapper for repeatable rehearsal:

21
docs/THROTTLING.md Normal file
View File

@@ -0,0 +1,21 @@
# Shared fixed-window throttling
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
become store keys. A configured Redis instance provides atomic counters shared
across API workers; development, a missing Redis configuration, and temporary
Redis outages use a bounded process-local fallback. When Redis fails, local
attempts are still mirrored so losing the distributed store does not reset the
active worker's protection window.
Callers define one or more `ThrottleDimension` values with a controlled
namespace, a subject and a positive limit. They must call `check` before an
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
dimension after successful verification. A blocked decision includes a
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
The first consumer is Scheduling's anonymous participation password challenge.
Its namespace is `poll-participation-password`; its subject combines tenant,
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
login throttle predates this primitive and should be migrated onto it in a
separate compatibility-preserving slice.

View File

@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
## Confirmed Implementation Decisions
@@ -191,9 +195,10 @@ explicitly retains the exception.
Decision: Scheduling requests provide a concrete reference application of the
universal placement and component rules.
- The `Scheduling requests` header owns one `Add` action.
- Its left panel is used for the creation view only, not as a permanent second
creation affordance.
- The persistent left panel stacks `My scheduling requests` and `Scheduling
requests for me`; it is list context, not a second creation affordance.
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
the shared view/create/edit surface in the right main panel.
- Basic information, Calendar integration, candidate slots, and participants
use the central `Card` component as four logical sections.
- Candidate slots and participants use the central `DataGrid`, including its
@@ -207,6 +212,59 @@ universal placement and component rules.
Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants.
### DUE-011: Field Help, Discard, And Table Action Contracts
Decision: the central components own these interactions; modules compose them
instead of reproducing their behavior.
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
compositions use `FieldLabel` explicitly when the meaning or limitation is
not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
the same shared unsaved-changes dialog. A browser tab/window unload remains a
browser-controlled confirmation because browsers do not permit a custom
modal at that boundary.
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
complete logical row set and applies filtering and sorting before slicing a
page. Server mode receives only the loaded page, requires `onQueryChange`,
and the backend applies every emitted filter/sort before pagination while
returning `totalRows` for the filtered result. Server list filters declare
their complete option domain instead of deriving it from the loaded page.
External filter affordances such as summary-count shortcuts update the
grid's `query` contract; the grid header controls and backend query therefore
always display and execute the same filter state.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
listed here. Module repositories keep an equivalent register in their durable
UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
#### Alert Exception Register
No `window.alert` or global `alert` exception is authorized.
## Implementation Sequence
| Phase | Scope | Output |
@@ -277,6 +335,14 @@ Every new or changed admin/configuration surface should answer:
- Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively?
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
recorded with its rationale and accessible-name source?
- Do explicit Discard and dirty navigation use the shared unsaved-changes
registration/dialog rather than a page-local confirmation?
- Does every row retain the table's action set in the same order, disabling
unavailable actions and reserving the same empty-row slots?
- Is feedback rendered with a central dialog/alert component, with no
unauthorized `window.alert` or global `alert` call?
- If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees?

View File

@@ -81,8 +81,8 @@
],
"recorded_at": "2026-07-11T01:39:45Z",
"release": "0.1.7",
"track": "release",
"squash_policy": "reviewed-manual"
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
@@ -165,8 +165,380 @@
],
"recorded_at": "2026-07-20T02:45:41Z",
"release": "0.1.8",
"track": "release",
"squash_policy": "reviewed-manual"
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "4d5e6f7a9203"
},
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"4d5e6f7a9203"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T02:42:27Z",
"release": "0.1.11",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T07:06:21Z",
"release": "0.1.12",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T08:39:02Z",
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1

View File

@@ -84,7 +84,15 @@
"provides_interfaces": [
{
"name": "mail.campaign_delivery",
"version": "1.4.0"
"version": "0.2.0"
}
],
"requires_interfaces": [
{
"name": "campaigns.access",
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
"optional": true
}
],
"artifact_integrity": {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.9"
version = "0.1.13"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -27,6 +27,12 @@ where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main"
@@ -42,3 +48,7 @@ dev = [
"httpx==0.28.1",
"httpx2>=2.5,<3",
]
[tool.ruff.lint.per-file-ignores]
"tests/test_api_smoke.py" = ["E402"]
"tests/test_module_system.py" = ["E402"]

View File

@@ -201,7 +201,7 @@ class AuthGroupsResponse(BaseModel):
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
"""Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package.
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
capability registry.
"""
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status

View File

@@ -1,8 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Mapping
from typing import Any, Literal
from typing import Literal
from govoplan_core.security.permissions import scopes_grant
from govoplan_core.security.redaction import contains_plain_secret
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
storage: str
ui_managed: bool
risk: ConfigurationRisk
secret_handling: SecretHandling = "none"
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
required_scopes: tuple[str, ...] = ()
dry_run_required: bool = False
validation_required: bool = True
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
maintenance_required: bool = False
maintenance_satisfied: bool = False
rollback_history_required: bool = False
secret_handling: SecretHandling = "none"
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
audit_event: str | None = None
policy_explanation: str | None = None
blockers: tuple[str, ...] = ()
@@ -240,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings",
ui_managed=True,
risk="high",
secret_handling="reference_only",
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("mail_servers:manage_credentials",),
validation_required=True,
audit_event="mail_server_profile.credential_updated",
@@ -256,14 +255,14 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings",
ui_managed=True,
risk="high",
secret_handling="reference_only",
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("files:file:admin",),
dry_run_required=True,
policy_explanation_required=True,
audit_event="files.connector_profile.updated",
two_person_approval_required=True,
rollback_history_required=True,
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
),
ConfigurationFieldSafety(
key="DATABASE_URL",
@@ -273,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
),
@@ -285,11 +284,179 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
two_person_approval_required=True,
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
label="Private-network connector access",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
label="Structured connector response limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
label="Connector file-transfer limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
label="Connector secret environment allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
label="Connector CA bundle allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
label="HTTP request-body limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_HSTS_SECONDS",
label="HTTP Strict Transport Security duration",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_ENABLED",
label="Interactive login throttling",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Disabling the deployment login throttle weakens protection against password guessing.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
label="Login failures per identity",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Shared through Redis when available, with a bounded process-local fallback.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
label="Login failures per client",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
label="Login throttle window",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Fixed counter window shared by identity and direct-client limits.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
label="Login throttle Redis retry interval",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="medium",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_TRUSTED_HOSTS",
label="Trusted HTTP hosts",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Host-header validation is deployment-managed and must cover every public API host.",
),
ConfigurationFieldSafety(
key="FORWARDED_ALLOW_IPS",
label="Trusted reverse proxy addresses",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
label="Module package catalog trusted keys",
@@ -298,7 +465,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
),
ConfigurationFieldSafety(
@@ -309,7 +476,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
notes="Configuration package trust roots are deployment-managed.",
),
)
@@ -414,9 +581,9 @@ def _configuration_change_safety_state(
approval_satisfied = not approval_required or approval_count >= 2
if approval_required and not approval_satisfied:
blockers.append("two_person_approval_required")
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
if field.secret_handling == "reference_only" and _contains_plain_secret(value): # noqa: S105 # nosec B105 - policy vocabulary.
blockers.append("secret_reference_required")
if field.secret_handling == "env_only" and value is not None:
if field.secret_handling == "env_only" and value is not None: # noqa: S105 # nosec B105 - policy vocabulary.
blockers.append("env_only_secret")
if field.rollback_history_required:
warnings.append("rollback_history_required")
@@ -466,7 +633,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
parts.append("requires two-person approval")
if field.maintenance_required:
parts.append("requires maintenance mode")
if field.secret_handling != "none":
if field.secret_handling != "none": # noqa: S105 # nosec B105 - policy vocabulary.
parts.append(f"uses {field.secret_handling} secret handling")
return "; ".join(parts) + "."

View File

@@ -3,8 +3,10 @@ from __future__ import annotations
import base64
import json
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from cryptography.fernet import Fernet
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_outbound_connector_policy(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
@@ -224,6 +227,14 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
if runtime.production_like and not trusted_hosts:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
elif "*" in trusted_hosts and runtime.production_like:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
if runtime.production_like and "*" in forwarded_allow_ips:
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
@@ -249,6 +260,59 @@ def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCol
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
def _validate_outbound_connector_policy(
env: Mapping[str, str],
runtime: _RuntimeProfile,
collector: _ConfigIssueCollector,
) -> None:
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
collector.add(
"error",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
"Private-network connector access must be an explicit deployment decision.",
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
)
for key in (
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
):
value = _clean(env.get(key))
if not value:
continue
try:
parsed = int(value)
except ValueError:
parsed = 0
if parsed <= 0:
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
secret_env_names = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
if item.strip()
]
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"Connector secret environment allowlist contains an invalid variable name.",
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
)
ca_bundle_paths = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
if item.strip()
]
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
"Connector CA bundle allowlist contains a non-absolute path.",
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
)
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
if not runtime.production or not catalog_source:
@@ -276,8 +340,30 @@ CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
CORS_ORIGINS=https://govoplan.example.org
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
# restricted to the actual reverse proxy address or network.
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax
AUTH_COOKIE_DOMAIN=
@@ -320,9 +406,26 @@ REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=0
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files

View File

@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
def executor(execute_session: object, _module_id: str) -> None:
if not hasattr(execute_session, "get_bind"):
if not hasattr(execute_session, "connection"):
raise RuntimeError("No database session is available for destructive table retirement.")
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
live_inspector = inspect(execute_bind)
# Enlist schema retirement in the caller's active transaction. An
# Engine returned by Session.get_bind() may acquire a second
# connection, separating the DROP from module-specific secret
# scrubbing/audit writes and deadlocking on their uncommitted locks.
execute_connection = execute_session.connection() # type: ignore[attr-defined]
live_inspector = inspect(execute_connection)
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
if not live_tables:
return
metadata = live_tables[0].metadata
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True)
metadata.drop_all(bind=execute_connection, tables=live_tables, checkfirst=True)
return MigrationRetirementPlan(
supported=True,

View File

@@ -15,7 +15,8 @@ import re
import shlex
import shutil
import sqlite3
import subprocess
import stat
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
import sys
import tomllib
from typing import Any, Literal
@@ -579,7 +580,7 @@ def _prepare_module_install_run(
) -> _ModuleInstallRunState:
run_id = _run_id()
run_dir = effective_runtime_dir / "runs" / run_id
run_dir.mkdir(parents=True, exist_ok=False)
_create_private_installer_run_dir(run_dir)
commands = structured_install_commands(
plan,
webui_root=webui_root,
@@ -2445,8 +2446,8 @@ def _migration_provider_modules(
target_metadata: Mapping[str, Mapping[str, object]],
) -> dict[str, tuple[tuple[str, str], ...]]:
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
for module_id, metadata in target_metadata.items():
for provided in metadata.get("provides_interfaces", ()):
for module_id, module_metadata in target_metadata.items():
for provided in module_metadata.get("provides_interfaces", ()):
if not isinstance(provided, Mapping):
continue
name = provided.get("name") if isinstance(provided.get("name"), str) else None
@@ -3828,14 +3829,47 @@ def _restore_external_database_snapshot(
def _write_installer_secret(path: Path, value: str | None) -> str | None:
if not value:
return None
path.write_text(value, encoding="utf-8")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
path.chmod(0o600)
descriptor = os.open(path, flags, 0o600)
except OSError as exc:
logger.debug("Could not restrict installer secret file permissions for %s: %s", path, exc, exc_info=True)
raise ModuleInstallerError(f"Could not create private installer secret file: {path.name}") from exc
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(value)
except OSError as exc:
path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Could not write private installer secret file: {path.name}") from exc
try:
mode = stat.S_IMODE(path.stat().st_mode)
except OSError as exc:
path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Could not verify private installer secret file: {path.name}") from exc
if mode != 0o600:
path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Installer secret file permissions are not private: {path.name}")
return path.name
def _create_private_installer_run_dir(path: Path) -> None:
path.mkdir(parents=True, mode=0o700, exist_ok=False)
try:
# mkdir's requested mode is still reduced by the process umask. Set the
# exact owner-only mode so the directory remains usable with a strict
# deployment umask while never retaining group/other access.
path.chmod(0o700)
mode = stat.S_IMODE(path.stat().st_mode)
except OSError as exc:
path.rmdir()
raise ModuleInstallerError("Could not secure the installer run directory") from exc
if mode != 0o700:
path.rmdir()
raise ModuleInstallerError("Installer run directory permissions are not exactly owner-only")
def _database_url_from_external_snapshot(run_dir: Path, raw: Mapping[str, object]) -> str | None:
secret_name = raw.get("database_url_secret")
if isinstance(secret_name, str) and secret_name:

View File

@@ -106,7 +106,8 @@ def installer_notification_body(event_kind: str, request: Mapping[str, object])
run_id = _result_run_id(request)
if run_id:
return ". Run: ".join((sentence, run_id))
return sentence + "."
# This helper returns plain notification text, not an HTTP/HTML response.
return sentence + "." # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string
def installer_notification_priority(status: str) -> int:

View File

@@ -6,7 +6,6 @@ from datetime import UTC, datetime
import json
import os
from pathlib import Path
from typing import Any
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization

View File

@@ -45,6 +45,7 @@ class RoleTemplate:
level: PermissionLevel = "tenant"
managed: bool = True
protected: bool = False
default_authenticated: bool = False
@dataclass(frozen=True, slots=True)
@@ -69,6 +70,15 @@ class FrontendRoute:
order: int = 100
@dataclass(frozen=True, slots=True)
class PublicFrontendRoute:
"""Explicitly allowlisted route that can render without authentication."""
path: str
component: str
order: int = 100
@dataclass(frozen=True, slots=True)
class FrontendModule:
module_id: str
@@ -79,6 +89,7 @@ class FrontendModule:
asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = ()
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
@@ -236,6 +247,35 @@ class DocumentationTopic:
metadata: Mapping[str, Any] = field(default_factory=dict)
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
"""Return fail-closed authoring issues for a user-facing workflow topic.
Topic conditions are alternatives. Every alternative therefore needs an
explicit scope constraint; otherwise one unscoped alternative would make
the workflow visible regardless of the actor's task authority.
"""
raw_kind = topic.metadata.get("kind")
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
if kind != "workflow" or "user" not in topic.documentation_types:
return ()
if not topic.conditions:
return ("user workflow topics must declare at least one scope-conditioned alternative",)
unscoped_alternatives = tuple(
index
for index, condition in enumerate(topic.conditions, start=1)
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
)
if not unscoped_alternatives:
return ()
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
return (
"every user workflow condition alternative must declare required_scopes or any_scopes; "
f"unscoped alternative(s): {alternatives}",
)
@dataclass(frozen=True, slots=True)
class DocumentationContext:
registry: object

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
# Identity search is deliberately absent here. ``identity.search`` is an
# instance-wide canonical-identity directory and has no tenant/principal
# visibility contract. Ordinary task pickers must use principal-aware search
# providers instead.
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
class PeopleSearchError(ValueError):
"""Stable, non-diagnostic error raised by people-search providers."""
@dataclass(frozen=True, slots=True)
class PersonSearchCandidate:
"""A policy-filtered selection target returned by a directory provider."""
selection_key: str
kind: str
reference_id: str
display_name: str
email: str | None = None
source_module: str | None = None
source_label: str | None = None
source_ref: str | None = None
source_revision: str | None = None
description: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PeopleSearchGroup:
key: str
label: str
candidates: tuple[PersonSearchCandidate, ...] = ()
@runtime_checkable
class PeopleSearchProvider(Protocol):
"""Server-side, principal-aware people/delivery-target search.
Implementations must derive visibility from ``principal`` and may only
return records the principal can discover in its active tenant and policy
context. Callers remain responsible for authorizing the task for which the
picker is used (for example, creating a scheduling request).
"""
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> Sequence[PeopleSearchGroup]:
...
def people_search_providers(
registry: object | None,
*,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchProvider, ...]:
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
return ()
providers: list[PeopleSearchProvider] = []
for capability_name in capability_names:
if not registry.has_capability(capability_name):
continue
capability = registry.capability(capability_name)
if isinstance(capability, PeopleSearchProvider):
providers.append(capability)
return tuple(providers)
def search_visible_people(
registry: object | None,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchGroup, ...]:
"""Collect grouped results without importing optional feature modules.
``limit`` is enforced per provider so one directory cannot starve another
result group. Providers are expected to return at most that many candidates
in total. Duplicate group keys are merged and candidate selection keys are
de-duplicated while preserving provider order.
"""
normalized_query = str(query or "").strip()
normalized_limit = max(1, min(int(limit), 100))
ordered_group_keys: list[str] = []
labels: dict[str, str] = {}
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
candidate_keys_by_group: dict[str, set[str]] = {}
for provider in people_search_providers(registry, capability_names=capability_names):
groups = provider.search_people(
session,
principal,
query=normalized_query,
limit=normalized_limit,
)
provider_candidate_count = 0
for group in groups:
if not group.key:
continue
if group.key not in candidates_by_group:
ordered_group_keys.append(group.key)
labels[group.key] = group.label
candidates_by_group[group.key] = []
candidate_keys_by_group[group.key] = set()
for candidate in group.candidates:
if provider_candidate_count >= normalized_limit:
break
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
continue
candidate_keys_by_group[group.key].add(candidate.selection_key)
candidates_by_group[group.key].append(candidate)
provider_candidate_count += 1
return tuple(
PeopleSearchGroup(
key=group_key,
label=labels[group_key],
candidates=tuple(candidates_by_group[group_key]),
)
for group_key in ordered_group_keys
if candidates_by_group[group_key]
)
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
normalized_kind = str(kind).strip().casefold()
normalized_reference = str(reference_id).strip()
normalized_email = str(email or "").strip().casefold()
if not normalized_kind or not normalized_reference:
raise ValueError("Person selection keys require a kind and reference id.")
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
__all__ = [
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
"PeopleSearchError",
"PeopleSearchGroup",
"PeopleSearchProvider",
"PersonSearchCandidate",
"people_search_providers",
"person_selection_key",
"search_visible_people",
]

View File

@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionOrderCommand:
"""Complete active option order supplied by an owning module.
Providers must reject partial, duplicate, or unknown option collections.
An exact repeat is an idempotent no-op. Reordering changes positions only;
option identities and their existing answers remain valid.
"""
option_ids: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class PollInvitationCommand:
respondent_id: str | None = None
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollResponseRetirementCommand:
"""Retire live responses while preserving their auditable history.
Owning modules provide every server-trusted identity that can refer to the
participant. Providers must soft-delete matching live responses, retain
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
"""
respondent_ids: tuple[str, ...] = ()
invitation_id: str | None = None
reason: str = "participant_removed"
idempotency_key: str = ""
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionRef:
id: str
@@ -138,6 +166,16 @@ class PollResponseRef:
answers: tuple[PollAnswerRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PollResponseRetirementRef:
"""Outcome of an auditable response-retirement request."""
response_ids: tuple[str, ...] = ()
retired_at: datetime | None = None
newly_retired_count: int = 0
replayed: bool = False
@runtime_checkable
class PollSchedulingProvider(Protocol):
def create_poll(
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
...
def reorder_options(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionOrderCommand,
) -> PollRef:
"""Atomically replace the complete active option order."""
...
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
...
@runtime_checkable
class PollResponseRetirementProvider(Protocol):
"""Optional extension for owning modules that remove participants."""
def retire_responses(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollResponseRetirementCommand,
) -> PollResponseRetirementRef:
...
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
) -> PollResponseSubmissionProvider | None:
provider = poll_scheduling_provider(registry)
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
def poll_response_retirement_provider(
registry: object | None,
) -> PollResponseRetirementProvider | None:
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
return None
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
return (
capability
if isinstance(capability, PollResponseRetirementProvider)
else None
)

View File

@@ -0,0 +1,272 @@
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.poll import (
PollAnswerRequest,
PollInvitationRef,
PollOptionRequest,
PollResponseRef,
)
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
# Policy-attestation identifier, not a credential or credential default.
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
PARTICIPATION_POLICY_VERSION = 1
def participation_token_fingerprint(token: str) -> str:
"""Return a non-reversible identifier suitable for audit/throttle keys."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@dataclass(frozen=True, slots=True)
class PollResponseGatewayRef:
"""Stable identity of the module resource governing a participation link."""
module_id: str
resource_type: str
resource_id: str
@dataclass(frozen=True, slots=True)
class PollParticipationPolicy:
"""Generic response rules snapshotted onto one signed invitation.
``single_choice`` treats every non-``unavailable`` availability answer as
a selection. Capacity is reserved only by ``available`` answers (and by
selected answers for non-availability polls), so ``maybe`` never consumes
a place.
"""
version: int = PARTICIPATION_POLICY_VERSION
single_choice: bool = False
allow_maybe: bool = True
max_participants_per_option: int | None = None
allow_comments: bool = False
participant_email_required: bool = False
anonymous_password_required: bool = False
@dataclass(frozen=True, slots=True)
class PollGovernedInvitationCommand:
gateway: PollResponseGatewayRef
policy: PollParticipationPolicy
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollGovernedResponseCommand:
"""Submission already authorized by the named in-process gateway.
Passwords never cross this boundary. A gateway that owns a password
verifier reports the completed check through ``verified_requirements``.
Poll independently re-enforces the remaining snapshotted rules while
holding its Poll-row lock.
"""
respondent_id: str | None = None
respondent_label: str | None = None
participant_email: str | None = None
participant_is_authenticated: bool = False
answers: tuple[PollAnswerRequest, ...] = ()
comment: str | None = None
verified_requirements: frozenset[str] = frozenset()
idempotency_key: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollGovernedResponseRef:
response: PollResponseRef
participant_email: str | None = None
comment: str | None = None
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollOptionMutationRef:
id: str
position: int
replayed: bool = False
invalidated_response_count: int = 0
@dataclass(frozen=True, slots=True)
class PollInvitationRevocationRef:
id: str
revoked_at: datetime
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollInvitationExpiryRef:
id: str
expires_at: datetime | None
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollParticipationContextRef:
invitation_id: str
tenant_id: str
poll_id: str
gateway: PollResponseGatewayRef
policy: PollParticipationPolicy
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
response: PollGovernedResponseRef | None = None
@runtime_checkable
class PollParticipationGatewayProvider(Protocol):
def create_governed_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollGovernedInvitationCommand,
) -> PollInvitationRef:
...
def resolve_participation(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
respondent_id: str | None = None,
participant_email: str | None = None,
participant_is_authenticated: bool = False,
verified_requirements: frozenset[str] = frozenset(),
) -> PollParticipationContextRef:
"""Resolve and prefill one valid invitation for the exact gateway."""
...
def submit_governed_response(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
...
def resolve_authenticated_participation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
) -> PollParticipationContextRef:
"""Resolve one governed invitation without retaining its public token."""
...
def submit_authenticated_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
"""Submit atomically for the exact authenticated invitation identity."""
...
def add_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionRequest,
) -> PollOptionMutationRef:
...
def remove_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str,
) -> PollOptionMutationRef:
...
def revoke_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
) -> PollInvitationRevocationRef:
...
def update_invitation_expiry(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
expires_at: datetime | None,
) -> PollInvitationExpiryRef:
"""Expire or extend a non-revoked governed invitation in place."""
...
def poll_participation_gateway_provider(
registry: object | None,
) -> PollParticipationGatewayProvider | None:
"""Resolve the governed Poll gateway without importing its implementation."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
return None
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
__all__ = [
"ANONYMOUS_PASSWORD_REQUIREMENT",
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
"PARTICIPATION_POLICY_VERSION",
"PollGovernedInvitationCommand",
"PollGovernedResponseCommand",
"PollGovernedResponseRef",
"PollInvitationExpiryRef",
"PollInvitationRevocationRef",
"PollOptionMutationRef",
"PollParticipationContextRef",
"PollParticipationGatewayProvider",
"PollParticipationPolicy",
"PollResponseGatewayRef",
"participation_token_fingerprint",
"poll_participation_gateway_provider",
]

View File

@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
PublicFrontendRoute,
ResourceAclProvider,
RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryProvider,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
@@ -194,6 +196,7 @@ class PlatformRegistry:
available_capabilities=available_capabilities,
)
permissions = _collect_manifest_permissions(ordered)
_validate_public_frontend_route_uniqueness(ordered)
_validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
@@ -320,9 +323,34 @@ def _validate_role_template_scopes(
*,
known_scopes: set[str],
) -> None:
seen_templates: dict[tuple[str, str], str] = {}
for manifest in manifests:
for template in manifest.role_templates:
template_key = (template.level, template.slug)
previous_module = seen_templates.get(template_key)
if previous_module is not None:
raise RegistryError(
f"Duplicate {template.level} role template slug {template.slug!r} "
f"in modules {previous_module!r} and {manifest.id!r}"
)
seen_templates[template_key] = manifest.id
if template.default_authenticated and template.level != "tenant":
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be tenant-level"
)
if template.default_authenticated and not template.managed:
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be managed"
)
for scope in template.permissions:
if template.default_authenticated and (
scope in {"*", "tenant:*", "system:*"}
or _WILDCARD_RE.match(scope)
):
raise RegistryError(
f"Default authenticated role template {template.slug!r} "
"must use explicit permissions, not wildcard scopes"
)
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
@@ -344,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
for topic in manifest.documentation:
for issue in user_workflow_scope_condition_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
@@ -403,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
)
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes):
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component)
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
@@ -416,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_public_frontend_route_uniqueness(
manifests: tuple[ModuleManifest, ...],
) -> None:
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
for manifest in manifests:
if manifest.frontend is None:
continue
for route in manifest.frontend.public_routes:
previous = owners.get(route.path)
if previous is not None:
previous_module, _previous_route = previous
raise RegistryError(
f"Duplicate public frontend route {route.path!r} in modules "
f"{previous_module!r} and {manifest.id!r}"
)
owners[route.path] = (manifest.id, route)
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests:

View File

@@ -0,0 +1,349 @@
from __future__ import annotations
import hashlib
import logging
import re
import threading
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Protocol
from redis import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
_REDIS_INCREMENT_SCRIPT = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"""
@dataclass(frozen=True, slots=True)
class FixedWindowBucket:
count: int = 0
retry_after_seconds: int = 0
@dataclass(frozen=True, slots=True)
class ThrottleDimension:
"""One independently limited dimension without putting its value in keys."""
namespace: str
subject: str
limit: int
@dataclass(frozen=True, slots=True)
class ThrottleDecision:
allowed: bool
retry_after_seconds: int = 0
class FixedWindowStore(Protocol):
def read(self, key: str) -> FixedWindowBucket: ...
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
def delete(self, key: str) -> None: ...
class InMemoryFixedWindowStore:
"""Bounded process-local store for development and distributed-store loss."""
def __init__(
self,
*,
max_entries: int = 10_000,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._entries: dict[str, tuple[int, float]] = {}
self._lock = threading.Lock()
self._max_entries = max(2, max_entries)
self._clock = clock
def read(self, key: str) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
return FixedWindowBucket()
count, expires_at = entry
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
self._make_room(now=now, incoming_key=key)
count = 1
expires_at = now + window_seconds
else:
count = entry[0] + 1
expires_at = entry[1]
self._entries[key] = (count, expires_at)
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def delete(self, key: str) -> None:
with self._lock:
self._entries.pop(key, None)
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
entry = self._entries.get(key)
if entry is None:
return None
if entry[1] <= now:
self._entries.pop(key, None)
return None
return entry
def _make_room(self, *, now: float, incoming_key: str) -> None:
if incoming_key in self._entries or len(self._entries) < self._max_entries:
return
for key, (_count, expires_at) in tuple(self._entries.items()):
if expires_at <= now:
self._entries.pop(key, None)
while len(self._entries) >= self._max_entries:
self._entries.pop(next(iter(self._entries)))
class RedisFixedWindowStore:
"""Atomic Redis fixed-window counters shared across API workers."""
def __init__(self, redis_url: str) -> None:
self._client = Redis.from_url(
redis_url,
decode_responses=True,
socket_connect_timeout=0.25,
socket_timeout=0.25,
health_check_interval=30,
)
def read(self, key: str) -> FixedWindowBucket:
pipeline = self._client.pipeline(transaction=False)
pipeline.get(key)
pipeline.ttl(key)
raw_count, raw_ttl = pipeline.execute()
return FixedWindowBucket(
count=int(raw_count or 0),
retry_after_seconds=max(0, int(raw_ttl or 0)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
result = self._client.eval(
_REDIS_INCREMENT_SCRIPT,
1,
key,
window_seconds,
)
if not isinstance(result, (list, tuple)) or len(result) != 2:
raise RedisError("Unexpected fixed-window throttle response from Redis")
return FixedWindowBucket(
count=int(result[0]),
retry_after_seconds=max(1, int(result[1])),
)
def delete(self, key: str) -> None:
self._client.delete(key)
class ResilientFixedWindowStore:
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
def __init__(
self,
primary: FixedWindowStore | None,
fallback: FixedWindowStore,
*,
retry_seconds: int = 30,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._primary = primary
self._fallback = fallback
self._retry_seconds = max(1, retry_seconds)
self._clock = clock
self._primary_unavailable_until = 0.0
self._state_lock = threading.Lock()
def read(self, key: str) -> FixedWindowBucket:
fallback_result = self._fallback.read(key)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
return _stricter_bucket(primary.read(key), fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
fallback_result = self._fallback.increment(
key,
window_seconds=window_seconds,
)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
primary_result = primary.increment(
key,
window_seconds=window_seconds,
)
return _stricter_bucket(primary_result, fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def delete(self, key: str) -> None:
self._fallback.delete(key)
primary = self._available_primary()
if primary is None:
return
try:
primary.delete(key)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
def _available_primary(self) -> FixedWindowStore | None:
if self._primary is None:
return None
with self._state_lock:
if self._clock() < self._primary_unavailable_until:
return None
return self._primary
def _mark_primary_unavailable(self, exc: Exception) -> None:
should_log = False
with self._state_lock:
now = self._clock()
if now >= self._primary_unavailable_until:
should_log = True
self._primary_unavailable_until = now + self._retry_seconds
if should_log:
logger.warning(
"Distributed throttling is unavailable; using the process-local fallback (%s)",
type(exc).__name__,
)
def _stricter_bucket(
first: FixedWindowBucket,
second: FixedWindowBucket,
) -> FixedWindowBucket:
return FixedWindowBucket(
count=max(first.count, second.count),
retry_after_seconds=max(
first.retry_after_seconds,
second.retry_after_seconds,
),
)
class FixedWindowThrottle:
def __init__(
self,
store: FixedWindowStore,
*,
window_seconds: int,
key_prefix: str = "govoplan:throttle:v1",
) -> None:
self._store = store
self._window_seconds = max(1, window_seconds)
self._key_prefix = key_prefix.rstrip(":")
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=False)
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=True)
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
for dimension in tuple(dimensions):
self._store.delete(self._key(dimension))
def _decision(
self,
dimensions: tuple[ThrottleDimension, ...],
*,
increment: bool,
) -> ThrottleDecision:
if not dimensions:
raise ValueError("At least one throttle dimension is required")
blocked_retry_after = 0
for dimension in dimensions:
if dimension.limit < 1:
raise ValueError("Throttle limits must be positive")
key = self._key(dimension)
state = (
self._store.increment(key, window_seconds=self._window_seconds)
if increment
else self._store.read(key)
)
if state.count >= dimension.limit:
blocked_retry_after = max(
blocked_retry_after,
max(1, state.retry_after_seconds),
)
return ThrottleDecision(
allowed=blocked_retry_after == 0,
retry_after_seconds=blocked_retry_after,
)
def _key(self, dimension: ThrottleDimension) -> str:
namespace = dimension.namespace.strip().casefold()
if not _NAMESPACE_PATTERN.fullmatch(namespace):
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
return f"{self._key_prefix}:{namespace}:{subject_digest}"
def build_fixed_window_throttle(
*,
redis_url: str | None,
window_seconds: int,
redis_retry_seconds: int = 30,
max_local_entries: int = 10_000,
key_prefix: str = "govoplan:throttle:v1",
) -> FixedWindowThrottle:
primary = (
RedisFixedWindowStore(redis_url)
if redis_url is not None and redis_url.strip()
else None
)
store = ResilientFixedWindowStore(
primary,
InMemoryFixedWindowStore(max_entries=max_local_entries),
retry_seconds=redis_retry_seconds,
)
return FixedWindowThrottle(
store,
window_seconds=window_seconds,
key_prefix=key_prefix,
)
__all__ = [
"FixedWindowBucket",
"FixedWindowStore",
"FixedWindowThrottle",
"InMemoryFixedWindowStore",
"RedisFixedWindowStore",
"ResilientFixedWindowStore",
"ThrottleDecision",
"ThrottleDimension",
"build_fixed_window_throttle",
]

View File

@@ -65,7 +65,7 @@ def bootstrap_dev_data(
api_key_secret: str | None = None,
tenant_slug: str = "default",
user_email: str = "admin@example.local",
user_password: str = "dev-admin",
user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:

View File

@@ -1,12 +1,13 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, replace
import json
import logging
import os
from pathlib import Path
import re
import sysconfig
from typing import Any
from alembic import command
@@ -215,7 +216,10 @@ def _registered_module_registry(
server_config = get_server_config()
active_database_url = database_url or settings.database_url
if active_database_url:
configure_database(active_database_url)
# Registry planning may target a different database than the currently
# configured handle. The global handle is replaced either way, so
# dispose its superseded pool instead of leaking open DBAPI connections.
configure_database(active_database_url, dispose_previous=True)
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
@@ -459,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
installed_runtime_root,
]
for candidate in candidates:
if candidate is None:
@@ -634,7 +640,7 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int:
quoted = _quoted_table_name(connection, table_name)
statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
statement = text(f"SELECT COUNT(*) FROM {quoted}") # noqa: S608 # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
return int(connection.execute(statement).scalar_one())

View File

@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any
from sqlalchemy.engine import make_url
from sqlalchemy.exc import ArgumentError
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleManifest
@@ -112,6 +115,14 @@ def validate_sqlite_database_url(database_url: str) -> None:
)
def redacted_database_url(database_url: str) -> str:
try:
return make_url(database_url).render_as_string(hide_password=True)
except (ArgumentError, TypeError, ValueError):
scheme = database_url.partition(":")[0].strip()
return f"{scheme}:<redacted>" if scheme else "<redacted>"
def _env_truthy(value: str | None) -> bool:
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
@@ -297,11 +308,11 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
print(f"Runtime root: {state.runtime_root}")
if state.database_url:
print(f"Database: {state.database_url}")
print(f"Database: {redacted_database_url(state.database_url)}")
if state.database_url.startswith("postgresql"):
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
if pgtools_url:
print(f"PostgreSQL tools URL: {pgtools_url}")
print(f"PostgreSQL tools URL: {redacted_database_url(pgtools_url)}")
if state.bootstrap_db_path is not None:
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")

View File

@@ -5,6 +5,12 @@ import urllib.request
from dataclasses import dataclass
from typing import Mapping
from govoplan_core.security.outbound_http import (
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
@dataclass(frozen=True, slots=True)
class HttpFetchResponse:
@@ -40,17 +46,26 @@ def fetch_http(
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
max_bytes: int | None = None,
) -> HttpFetchResponse:
request = urllib.request.Request(
validate_http_url(url, label=label),
validated_url = validate_outbound_http_url(url, label=label)
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
validated_url,
headers=dict(headers or {}),
method=method,
)
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = dict(response.headers.items())
return HttpFetchResponse(
status=int(getattr(response, "status", 0)),
headers=dict(response.headers.items()),
body=response.read(),
headers=response_headers,
body=bounded_response_bytes(
response,
headers=response_headers,
max_bytes=max_bytes,
label=f"{label} response",
),
)
@@ -62,5 +77,29 @@ def fetch_http_text(
method: str = "GET",
headers: Mapping[str, str] | None = None,
encoding: str = "utf-8",
max_bytes: int | None = None,
) -> str:
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers).text(encoding)
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding)
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, *, label: str) -> None:
super().__init__()
self._label = label
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
previous = urllib.parse.urlparse(req.full_url)
redirected = urllib.parse.urlparse(candidate)
if previous.scheme.lower() == "https" and redirected.scheme.lower() != "https":
return None
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
new_request.remove_header(header)
return new_request
def _http_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
scheme = parsed.scheme.lower()
return scheme, (parsed.hostname or "").lower(), parsed.port or (443 if scheme == "https" else 80)

View File

@@ -0,0 +1,408 @@
from __future__ import annotations
import ipaddress
import errno
import http.client
import os
import socket
import urllib.parse
import urllib.request
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import BinaryIO, Final
DEFAULT_STRUCTURED_RESPONSE_BYTES: Final = 16 * 1024 * 1024
DEFAULT_FILE_TRANSFER_BYTES: Final = 512 * 1024 * 1024
_READ_CHUNK_BYTES: Final = 64 * 1024
_TRUE_VALUES: Final = frozenset({"1", "true", "yes", "on"})
_FALSE_VALUES: Final = frozenset({"0", "false", "no", "off"})
_IPV4_LIMITED_BROADCAST: Final = ipaddress.IPv4Address("255.255.255.255")
_IPV4_COMPATIBLE_NETWORK: Final = ipaddress.IPv6Network("::/96")
_NAT64_WELL_KNOWN_NETWORK: Final = ipaddress.IPv6Network("64:ff9b::/96")
_KNOWN_METADATA_ADDRESSES: Final = frozenset(
{
ipaddress.ip_address("100.100.100.200"),
ipaddress.ip_address("169.254.169.254"),
ipaddress.ip_address("fd00:ec2::254"),
}
)
class OutboundHttpError(RuntimeError):
"""Base error for deployment-wide outbound HTTP policy failures."""
class OutboundHttpBlocked(OutboundHttpError):
"""Raised when a URL targets an address forbidden by deployment policy."""
class OutboundResponseTooLarge(OutboundHttpError):
"""Raised before a connector can retain an oversized remote response."""
@dataclass(frozen=True, slots=True)
class OutboundHttpPolicy:
allow_private_networks: bool
structured_response_bytes: int
file_transfer_bytes: int
def outbound_http_policy(environ: Mapping[str, str] | None = None) -> OutboundHttpPolicy:
env = os.environ if environ is None else environ
return OutboundHttpPolicy(
allow_private_networks=_private_network_default(env),
structured_response_bytes=_positive_int(
env.get("GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES"),
default=DEFAULT_STRUCTURED_RESPONSE_BYTES,
name="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
),
file_transfer_bytes=_positive_int(
env.get("GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES"),
default=DEFAULT_FILE_TRANSFER_BYTES,
name="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
),
)
def validate_outbound_http_url(
value: str,
*,
label: str = "Connector URL",
policy: OutboundHttpPolicy | None = None,
) -> str:
parsed = urllib.parse.urlparse(str(value).strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise OutboundHttpBlocked(f"{label} must be an absolute HTTP(S) URL")
if parsed.username or parsed.password:
raise OutboundHttpBlocked(f"{label} must not include embedded credentials")
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
except ValueError as exc:
raise OutboundHttpBlocked(f"{label} has an invalid port") from exc
validate_outbound_host(parsed.hostname, port=port, label=label, policy=policy)
return urllib.parse.urlunparse(parsed)
def validate_unpinned_sdk_http_url(
value: str,
*,
label: str,
policy: OutboundHttpPolicy | None = None,
) -> str:
"""Fail closed when an SDK cannot connect to a prevalidated DNS answer."""
active_policy = policy or outbound_http_policy()
validate_outbound_http_url(value, label=label, policy=active_policy)
_raise_unpinned_transport(label)
def validate_unpinned_sdk_host(
hostname: str,
*,
port: int,
label: str,
policy: OutboundHttpPolicy | None = None,
) -> None:
"""Validate a host and then reject an SDK that may select another peer."""
active_policy = policy or outbound_http_policy()
validate_outbound_host(hostname, port=port, label=label, policy=active_policy)
_raise_unpinned_transport(label)
def _raise_unpinned_transport(label: str) -> None:
raise OutboundHttpBlocked(
f"{label} uses an SDK that cannot pin every connection peer or revalidate SDK-managed redirects/referrals; "
"it is disabled until that transport supports connection-time DNS/IP pinning"
)
def validate_outbound_host(
hostname: str,
*,
port: int,
label: str = "Connector host",
policy: OutboundHttpPolicy | None = None,
) -> tuple[str, ...]:
active_policy = policy or outbound_http_policy()
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
addresses = tuple(dict.fromkeys(str(item[4][0]).split("%", 1)[0] for item in records if item[4]))
return addresses
def create_outbound_connection(
hostname: str,
port: int,
timeout: float | object | None = None,
source_address: tuple[str, int] | None = None,
socket_options: Iterable[tuple[object, ...]] | None = None,
*,
label: str = "Connector host",
policy: OutboundHttpPolicy | None = None,
) -> socket.socket:
"""Resolve, validate, and connect to the exact approved address records.
Hostname resolution happens exactly once for this connection attempt. The
returned socket connects directly to one of those validated sockaddr
records, while higher protocol layers retain the original hostname for
HTTP Host, TLS SNI, and certificate verification.
"""
active_policy = policy or outbound_http_policy()
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
effective_timeout = None if timeout is socket._GLOBAL_DEFAULT_TIMEOUT else timeout # type: ignore[attr-defined]
last_error: OSError | None = None
for family, socktype, proto, _canonname, sockaddr in records:
sock: socket.socket | None = None
try:
sock = socket.socket(family, socktype, proto)
sock.settimeout(effective_timeout) # type: ignore[arg-type]
if source_address is not None:
bind_address: tuple[object, ...] = source_address
if family == socket.AF_INET6 and len(source_address) == 2:
bind_address = (source_address[0], source_address[1], 0, 0)
sock.bind(bind_address)
for option in socket_options or ():
sock.setsockopt(*option)
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except OSError as exc:
if exc.errno != errno.ENOPROTOOPT:
raise
sock.connect(sockaddr)
return sock
except OSError as exc:
last_error = exc
if sock is not None:
sock.close()
if last_error is not None:
raise last_error
raise OutboundHttpBlocked(f"{label} hostname did not resolve to a usable address")
def build_outbound_http_opener(*handlers: urllib.request.BaseHandler) -> urllib.request.OpenerDirector:
"""Build a proxy-free urllib opener whose sockets use approved addresses."""
return urllib.request.build_opener(
urllib.request.ProxyHandler({}),
_OutboundHTTPHandler(),
_OutboundHTTPSHandler(),
*handlers,
)
def response_limit(kind: str, *, policy: OutboundHttpPolicy | None = None) -> int:
active_policy = policy or outbound_http_policy()
if kind == "structured":
return active_policy.structured_response_bytes
if kind == "file":
return active_policy.file_transfer_bytes
raise ValueError("Response kind must be 'structured' or 'file'")
def bounded_response_bytes(
stream: BinaryIO,
*,
headers: Mapping[str, str] | None = None,
max_bytes: int | None = None,
kind: str = "structured",
label: str = "Connector response",
) -> bytes:
configured_limit = response_limit(kind)
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
if limit <= 0:
raise ValueError("max_bytes must be positive")
declared_size = _content_length(headers or {})
if declared_size is not None and declared_size > limit:
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
body = bytearray()
while len(body) <= limit:
chunk = stream.read(min(_READ_CHUNK_BYTES, limit + 1 - len(body)))
if not chunk:
return bytes(body)
body.extend(chunk)
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
def bounded_chunks_bytes(
chunks: Iterable[bytes],
*,
headers: Mapping[str, str] | None = None,
max_bytes: int | None = None,
kind: str = "structured",
label: str = "Connector response",
) -> bytes:
configured_limit = response_limit(kind)
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
if limit <= 0:
raise ValueError("max_bytes must be positive")
declared_size = _content_length(headers or {})
if declared_size is not None and declared_size > limit:
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
body = bytearray()
for chunk in chunks:
if len(chunk) > limit - len(body):
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
body.extend(chunk)
return bytes(body)
def _private_network_default(environ: Mapping[str, str]) -> bool:
configured = environ.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
if configured is not None and configured.strip():
value = configured.strip().lower()
if value in _TRUE_VALUES:
return True
if value in _FALSE_VALUES:
return False
raise ValueError("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS must be true or false")
return environ.get("APP_ENV", "dev").strip().lower() in {"dev", "development", "test"}
def _positive_int(value: str | None, *, default: int, name: str) -> int:
if value is None or not value.strip():
return default
try:
parsed = int(value)
except ValueError as exc:
raise ValueError(f"{name} must be a positive integer") from exc
if parsed <= 0:
raise ValueError(f"{name} must be a positive integer")
return parsed
def _content_length(headers: Mapping[str, str]) -> int | None:
value = next((item for key, item in headers.items() if key.casefold() == "content-length"), None)
if value is None:
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
def _is_public_address(value: str) -> bool:
try:
address = ipaddress.ip_address(value)
except ValueError:
return False
if address.is_reserved or getattr(address, "is_site_local", False) or not address.is_global:
return False
return all(
embedded.is_global and not embedded.is_reserved
for embedded in _embedded_ipv4_addresses(address)
)
def _is_forbidden_special_address(value: str) -> bool:
"""Keep connector access away from host-local and non-unicast address space.
Enabling private-network connectors deliberately permits internal and
loopback destinations, but it must not expose link-local metadata services
or addresses that cannot identify a single remote peer.
"""
try:
address = ipaddress.ip_address(value)
except ValueError:
return True
candidates = (address, *_embedded_ipv4_addresses(address))
return any(
candidate in _KNOWN_METADATA_ADDRESSES
or candidate == _IPV4_LIMITED_BROADCAST
or candidate.is_link_local
or candidate.is_multicast
or candidate.is_unspecified
for candidate in candidates
)
def _embedded_ipv4_addresses(
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
) -> tuple[ipaddress.IPv4Address, ...]:
"""Return IPv4 destinations encoded by standard IPv6 transition forms."""
if isinstance(address, ipaddress.IPv4Address):
return ()
candidates: list[ipaddress.IPv4Address] = []
if address.ipv4_mapped is not None:
candidates.append(address.ipv4_mapped)
elif address in _IPV4_COMPATIBLE_NETWORK:
candidates.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
if address in _NAT64_WELL_KNOWN_NETWORK:
candidates.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
if address.sixtofour is not None:
candidates.append(address.sixtofour)
if address.teredo is not None:
candidates.extend(address.teredo)
return tuple(dict.fromkeys(candidates))
def _resolved_address_records(
hostname: str,
*,
port: int,
label: str,
policy: OutboundHttpPolicy,
) -> tuple[tuple[int, int, int, str, tuple[object, ...]], ...]:
host = str(hostname).strip().rstrip(".")
if not host:
raise OutboundHttpBlocked(f"{label} must include a hostname")
if not 1 <= int(port) <= 65535:
raise OutboundHttpBlocked(f"{label} has an invalid port")
try:
results = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise OutboundHttpBlocked(f"{label} hostname could not be resolved") from exc
records = tuple(results)
if not records:
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
addresses = tuple(str(item[4][0]).split("%", 1)[0] for item in records if item[4])
if not addresses:
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
if any(_is_forbidden_special_address(address) for address in addresses):
raise OutboundHttpBlocked(f"{label} resolves to a forbidden special-purpose network")
if not policy.allow_private_networks and any(not _is_public_address(address) for address in addresses):
raise OutboundHttpBlocked(
f"{label} resolves to a non-public network; "
"set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true for this deployment to permit it"
)
return records
def _create_outbound_connection_compat(
address: tuple[str, int],
timeout: float | object | None = socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore[attr-defined]
source_address: tuple[str, int] | None = None,
) -> socket.socket:
return create_outbound_connection(
address[0],
address[1],
timeout=timeout,
source_address=source_address,
label="Outbound HTTP connection",
)
class _OutboundHTTPConnection(http.client.HTTPConnection):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs) # type: ignore[arg-type]
self._create_connection = _create_outbound_connection_compat
class _OutboundHTTPSConnection(http.client.HTTPSConnection):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs) # type: ignore[arg-type]
self._create_connection = _create_outbound_connection_compat
class _OutboundHTTPHandler(urllib.request.HTTPHandler):
def http_open(self, req): # type: ignore[no-untyped-def]
return self.do_open(_OutboundHTTPConnection, req)
class _OutboundHTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req): # type: ignore[no-untyped-def]
return self.do_open(_OutboundHTTPSConnection, req, context=self._context)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
def sensitive_key_tokens(key: object) -> set[str]:
value = str(key).strip()
@@ -40,7 +40,7 @@ def contains_plain_secret(value: object) -> bool:
normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}):
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
return True
if isinstance(item, Mapping) and contains_plain_secret(item):
return True

View File

@@ -18,7 +18,7 @@ class SecretDecryptionError(RuntimeError):
pass
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
@runtime_checkable

View File

@@ -9,14 +9,21 @@ from typing import Any
from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
from govoplan_core.core.install_config import validate_runtime_configuration
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
_CONTENT_SECURITY_POLICY = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
_PRODUCTION_LIKE_ENVIRONMENTS = frozenset(
{"prod", "production", "self-hosted", "staging", "production-like", "production-like-dev"}
)
def _slow_request_threshold_ms() -> float:
@@ -27,6 +34,49 @@ def _slow_request_threshold_ms() -> float:
return 500.0
def _hsts_seconds() -> int:
default = "31536000" if os.getenv("APP_ENV", "dev").strip().lower() in {"prod", "production"} else "0"
raw = os.getenv("GOVOPLAN_HTTP_HSTS_SECONDS", default).strip()
try:
return max(0, int(raw))
except ValueError:
return int(default)
def _max_request_body_bytes() -> int:
default = 512 * 1024 * 1024
raw = os.getenv("GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES", str(default)).strip()
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
def _trusted_hosts() -> tuple[str, ...]:
return tuple(item.strip() for item in os.getenv("GOVOPLAN_TRUSTED_HOSTS", "").split(",") if item.strip())
def _validate_production_startup() -> None:
app_env = os.getenv("APP_ENV", "").strip().lower().replace("_", "-")
install_profile = os.getenv("GOVOPLAN_INSTALL_PROFILE", "").strip().lower().replace("_", "-")
if app_env not in _PRODUCTION_LIKE_ENVIRONMENTS and install_profile not in _PRODUCTION_LIKE_ENVIRONMENTS:
return
validation = validate_runtime_configuration()
if validation.errors:
raise RuntimeError(validation.to_text())
throttle_enabled = os.getenv("AUTH_LOGIN_THROTTLE_ENABLED", "true").strip().lower() not in {
"0",
"false",
"no",
"off",
}
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
logger.warning(
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
)
def create_govoplan_app(
*,
title: str,
@@ -37,9 +87,27 @@ def create_govoplan_app(
cors_origins: Iterable[str] = (),
health_payload: dict[str, Any] | None = None,
) -> FastAPI:
_validate_production_startup()
app = FastAPI(title=title, version=version, lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=_max_request_body_bytes())
trusted_hosts = _trusted_hosts()
if trusted_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(trusted_hosts))
app.state.govoplan_registry = registry
slow_request_threshold_ms = _slow_request_threshold_ms()
hsts_seconds = _hsts_seconds()
@app.middleware("http")
async def security_response_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault("Content-Security-Policy", _CONTENT_SECURITY_POLICY)
if hsts_seconds and request.url.scheme == "https":
response.headers.setdefault("Strict-Transport-Security", f"max-age={hsts_seconds}")
return response
@app.middleware("http")
async def request_correlation_context(request: Request, call_next):

View File

@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database
from govoplan_core.i18n import system_i18n_payload
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
}
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"order": route.order,
}
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
if frontend is None:
return None
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"routes": [_frontend_route_payload(route) for route in frontend.routes],
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
}
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
"""Return only assets and routes explicitly approved for signed-out use."""
return {
"module_id": frontend.module_id,
"package_name": frontend.package_name,
"asset_manifest": frontend.asset_manifest,
"asset_manifest_signature": frontend.asset_manifest_signature,
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
}
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
capabilities: list[str] = []
if manifest_id == "mail":
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
]
}
@router.get("/public-modules")
def public_modules(request: Request):
registry = _registry(request)
return {
"modules": [
{
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
if manifest.frontend is not None
and manifest.frontend.public_routes
]
}
@router.get("/permissions")
def permissions(request: Request):
registry = _registry(request)

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from starlette.responses import JSONResponse
class _RequestBodyTooLarge(RuntimeError):
pass
class RequestBodyLimitMiddleware:
def __init__(self, app: Any, *, max_bytes: int) -> None:
if max_bytes <= 0:
raise ValueError("max_bytes must be positive")
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
declared = _content_length(scope.get("headers", ()))
if declared is not None and declared > self.max_bytes:
await self._reject(scope, receive, send)
return
received = 0
response_started = False
async def limited_receive() -> dict[str, Any]:
nonlocal received
message = await receive()
if message.get("type") == "http.request":
received += len(message.get("body", b""))
if received > self.max_bytes:
raise _RequestBodyTooLarge
return message
async def tracked_send(message: dict[str, Any]) -> None:
nonlocal response_started
if message.get("type") == "http.response.start":
response_started = True
await send(message)
try:
await self.app(scope, limited_receive, tracked_send)
except _RequestBodyTooLarge:
if response_started:
raise
await self._reject(scope, receive, send)
async def _reject(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
response = JSONResponse(
{"detail": f"Request body exceeds the deployment limit of {self.max_bytes} bytes"},
status_code=413,
)
await response(scope, receive, send)
def _content_length(headers: Iterable[tuple[bytes, bytes]]) -> int | None:
for key, value in headers:
if bytes(key).lower() != b"content-length":
continue
try:
parsed = int(bytes(value))
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
return None

View File

@@ -47,6 +47,27 @@ class Settings(BaseSettings):
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,
ge=1,
alias="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
)
auth_login_throttle_client_limit: int = Field(
default=100,
ge=1,
alias="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
)
auth_login_throttle_window_seconds: int = Field(
default=15 * 60,
ge=1,
alias="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
)
auth_login_throttle_redis_retry_seconds: int = Field(
default=30,
ge=1,
alias="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
)
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
@@ -55,6 +76,12 @@ class Settings(BaseSettings):
ge=0,
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
)
scheduling_cancellation_notice_days: int = Field(
default=30,
ge=1,
le=90,
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
)
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production.

View File

@@ -100,6 +100,37 @@ class ApiSmokeTests(unittest.TestCase):
payload = response.json()
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
def _create_test_mail_profile(
self,
headers: dict[str, str],
*,
name: str,
include_imap: bool = False,
) -> str:
credentials = {
"smtp": {"username": "sender@example.org", "password": "test-secret"},
}
payload: dict[str, object] = {
"name": name,
"smtp": {
"host": "mock.smtp",
"port": 2525,
"security": "starttls",
},
"credentials": credentials,
}
if include_imap:
payload["imap"] = {
"host": "mock.imap",
"port": 993,
"security": "tls",
"sent_folder": "Sent",
}
credentials["imap"] = {"username": "sender@example.org", "password": "test-secret"}
response = self.client.post("/api/v1/mail/profiles", headers=headers, json=payload)
self.assertEqual(response.status_code, 201, response.text)
return str(response.json()["id"])
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
headers, _ = self._login()
payload = {
@@ -224,6 +255,7 @@ class ApiSmokeTests(unittest.TestCase):
external_id: str,
recipient_count: int = 1,
) -> tuple[str, str]:
mail_profile_id = self._create_test_mail_profile(headers, name=f"{external_id} delivery")
entries = [
{
"id": f"recipient-{index + 1}",
@@ -237,15 +269,7 @@ class ApiSmokeTests(unittest.TestCase):
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
"fields": [{"name": "first_name", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "mock.smtp",
"port": 2525,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -740,10 +764,40 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
snapshot = version.execution_snapshot or {}
self.assertIsNone(snapshot.get("smtp", {}).get("password"))
self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp")
self.assertEqual(snapshot["snapshot_version"], "5")
self.assertEqual(snapshot["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot)
self.assertNotIn("imap", snapshot)
self.assertTrue(snapshot["smtp_transport_revision"])
self.assertTrue(snapshot["imap_transport_revision"])
def test_mail_profile_can_inherit_server_without_credentials(self) -> None:
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 200, deleted.text)
self.assertFalse(deleted.json()["is_active"])
self.assertFalse(deleted.json()["smtp_password_configured"])
self.assertFalse(deleted.json()["imap_password_configured"])
from govoplan_audit.backend.db.models import AuditLog
with SessionLocal() as session:
profile = session.get(MailServerProfile, profile_id)
self.assertIsNotNone(profile)
assert profile is not None
self.assertIsNone(profile.smtp_password_encrypted)
self.assertIsNone(profile.imap_password_encrypted)
audit = (
session.query(AuditLog)
.filter(
AuditLog.action == "mail.profile_credentials_deleted",
AuditLog.object_id == profile_id,
)
.one()
)
self.assertEqual(audit.details["deleted_protocols"], ["smtp", "imap"])
self.assertEqual(audit.details["deletion_reason"], "profile_deactivated")
self.assertNotIn("secret", repr(audit.details))
def test_campaign_runtime_does_not_receive_mail_owned_profile_credentials(self) -> None:
headers, _ = self._login()
created_profile = self.client.post(
"/api/v1/mail/profiles",
@@ -775,15 +829,7 @@ class ApiSmokeTests(unittest.TestCase):
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
"fields": [{"name": "first_name", "type": "string", "required": True}],
"global_values": {},
"server": {
"mail_profile_id": profile_id,
"inherit_smtp_credentials": False,
"inherit_imap_credentials": False,
"credentials": {
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
},
},
"server": {"mail_profile_id": profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"to": [{"email": "recipient@example.org", "type": "to"}],
@@ -810,31 +856,43 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(built.status_code, 200, built.text)
from govoplan_campaign.backend.db.models import CampaignVersion
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config
from govoplan_campaign.backend.integrations import mail_integration
from govoplan_mail.backend.db.models import MailServerProfile
with SessionLocal() as session:
version = session.get(CampaignVersion, version_id)
self.assertIsNotNone(version)
assert version is not None
snapshot_payload = version.execution_snapshot or {}
self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp")
self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org")
self.assertIsNone(snapshot_payload.get("smtp", {}).get("password"))
self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap")
self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org")
self.assertIsNone(snapshot_payload.get("imap", {}).get("password"))
self.assertEqual(snapshot_payload["snapshot_version"], "5")
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot_payload)
self.assertNotIn("imap", snapshot_payload)
self.assertTrue(snapshot_payload["smtp_transport_revision"])
self.assertTrue(snapshot_payload["imap_transport_revision"])
snapshot = ExecutionSnapshot.model_validate(snapshot_payload)
smtp_runtime = runtime_smtp_config(session, version, snapshot)
imap_runtime = runtime_imap_config(session, version, snapshot)
self.assertEqual(smtp_runtime.host, "mock.smtp")
self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org")
self.assertEqual(smtp_runtime.password, "campaign-smtp-secret")
self.assertIsNotNone(imap_runtime)
assert imap_runtime is not None
self.assertEqual(imap_runtime.host, "mock.imap")
self.assertEqual(imap_runtime.username, "campaign-imap@example.org")
self.assertEqual(imap_runtime.password, "campaign-imap-secret")
profile = session.get(MailServerProfile, profile_id)
self.assertIsNotNone(profile)
assert profile is not None
integration = mail_integration()
summary = integration.campaign_profile_delivery_summary(
session,
tenant_id=profile.tenant_id,
campaign_id=version.campaign_id,
profile_id=profile_id,
)
self.assertTrue(summary["smtp_available"])
self.assertTrue(summary["imap_available"])
self.assertNotIn("mock.smtp", repr(summary))
self.assertNotIn("profile-smtp", repr(summary))
self.assertNotIn("secret", repr(summary))
for name in (
"smtp_config_from_profile",
"imap_config_from_profile",
"send_email_bytes",
"send_email_message",
):
self.assertFalse(hasattr(integration, name), name)
def test_health_schema_and_dev_mailbox_gates(self) -> None:
public_health = self.client.get("/health")
@@ -1811,12 +1869,21 @@ class ApiSmokeTests(unittest.TestCase):
tenant_id = str(login["tenant"]["id"])
env_keys = [
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
"GOVOPLAN_TEST_SMB_PASSWORD",
]
previous_env = {key: os.environ.get(key) for key in env_keys}
try:
os.environ["GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST"] = ",".join(
(
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
"GOVOPLAN_TEST_WEBDAV_PASSWORD",
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
"GOVOPLAN_TEST_SMB_PASSWORD",
)
)
os.environ["GOVOPLAN_TEST_SEAFILE_PASSWORD"] = "super-secret-seafile"
os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb"
@@ -2203,36 +2270,37 @@ class ApiSmokeTests(unittest.TestCase):
self.assertFalse(deleted_space.json()["is_active"])
self.assertIsNotNone(deleted_space.json()["deleted_at"])
import httpx
from govoplan_files.backend.storage.http_client import ConnectorHttpResponse
def seafile_request(method: str, url: str, **kwargs):
request = httpx.Request(method, url)
params = kwargs.get("params") or {}
if url == "http://127.0.0.1:9082/api2/auth-token/":
return httpx.Response(200, json={"token": "token-1"}, request=request)
return ConnectorHttpResponse(200, {}, b'{"token":"token-1"}')
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/":
self.assertEqual(params.get("p"), "/reports/summary.txt")
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
return httpx.Response(
return ConnectorHttpResponse(
200,
json={
"id": "file-1",
"name": "summary.txt",
"size": 14,
"type": "file",
"mtime": 1783425600,
"permission": "r",
},
request=request,
{},
json.dumps(
{
"id": "file-1",
"name": "summary.txt",
"size": 14,
"type": "file",
"mtime": 1783425600,
"permission": "r",
}
).encode(),
)
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"})
return httpx.Response(200, json="https://download.example.invalid/summary.txt", request=request)
return ConnectorHttpResponse(200, {}, b'"https://download.example.invalid/summary.txt"')
if url == "https://download.example.invalid/summary.txt":
return httpx.Response(200, content=b"summary report", headers={"content-type": "text/plain"}, request=request)
return httpx.Response(404, request=request)
return ConnectorHttpResponse(200, {"content-type": "text/plain"}, b"summary report")
return ConnectorHttpResponse(404, {}, b"")
with patch("govoplan_files.backend.storage.connector_browse.httpx.request", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=seafile_request):
with patch("govoplan_files.backend.storage.connector_browse.request_connector_bytes", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=seafile_request):
imported = self.client.post(
"/api/v1/files/connectors/profiles/seafile-dev/import",
headers=headers,
@@ -2259,18 +2327,16 @@ class ApiSmokeTests(unittest.TestCase):
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
def webdav_request(method: str, url: str, **kwargs):
request = httpx.Request(method, url)
self.assertEqual(method, "GET")
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
return httpx.Response(
return ConnectorHttpResponse(
200,
content=webdav_payload["content"],
headers={"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
request=request,
{"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
webdav_payload["content"],
)
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
nextcloud_import = self.client.post(
"/api/v1/files/connectors/profiles/user-nextcloud/import",
headers=headers,
@@ -2292,7 +2358,7 @@ class ApiSmokeTests(unittest.TestCase):
webdav_payload["content"] = b"nextcloud notice updated"
webdav_payload["etag"] = "\"nextcloud-etag-3\""
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
nextcloud_sync = self.client.post(
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
headers=headers,
@@ -2314,7 +2380,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"]))
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request):
with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
nextcloud_sync_unchanged = self.client.post(
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
headers=headers,
@@ -2333,74 +2399,13 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(unchanged["file"]["id"], nextcloud_file["id"])
self.assertEqual(unchanged["current_version_id"], synced["current_version_id"])
case = self
class _FakeSmbStat:
st_size = 18
st_mtime = 1783425600
st_mtime_ns = 1783425600000000000
class _FakeSmbEntry:
def __init__(self, name: str, is_dir: bool, size: int = 18) -> None:
self.name = name
self._is_dir = is_dir
self._stat = _FakeSmbStat()
self._stat.st_size = size
def is_dir(self) -> bool:
return self._is_dir
def stat(self):
return self._stat
class _FakeSmbScandir:
def __enter__(self):
return iter([
_FakeSmbEntry("Archive", True, 0),
_FakeSmbEntry("smb-notice.txt", False, 18),
])
def __exit__(self, exc_type, exc, traceback):
return False
class _FakeSmbFile:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
return False
def read(self, size: int) -> bytes:
case.assertGreater(size, 18)
return b"smb connector data"
class _FakeSmbClient:
def scandir(self, path: str, **kwargs):
case.assertEqual(path, r"\\127.0.0.1\files\root\shared")
case.assertEqual(kwargs["port"], 1445)
case.assertEqual(kwargs["username"], "govoplan")
case.assertEqual(kwargs["password"], "super-secret-smb")
case.assertTrue(kwargs["require_signing"])
return _FakeSmbScandir()
def stat(self, path: str, **kwargs):
case.assertEqual(path, r"\\127.0.0.1\files\root\shared\smb-notice.txt")
case.assertEqual(kwargs["port"], 1445)
return _FakeSmbStat()
def open_file(self, path: str, mode: str, **kwargs):
case.assertEqual(path, r"\\127.0.0.1\files\root\shared\smb-notice.txt")
case.assertEqual(mode, "rb")
case.assertEqual(kwargs["password"], "super-secret-smb")
return _FakeSmbFile()
fake_smb = _FakeSmbClient()
with patch("govoplan_files.backend.storage.connector_browse._smbclient_module", return_value=fake_smb):
with patch("govoplan_files.backend.storage.connector_browse._smbclient_module") as smb_sdk:
smb_browse = self.client.get("/api/v1/files/connectors/profiles/tenant-smb/browse?path=shared", headers=headers)
self.assertEqual(smb_browse.status_code, 200, smb_browse.text)
self.assertEqual([(item["kind"], item["path"]) for item in smb_browse.json()["items"]], [("folder", "shared/Archive"), ("file", "shared/smb-notice.txt")])
self.assertEqual(smb_browse.status_code, 400, smb_browse.text)
self.assertIn("redirects/referrals", smb_browse.json()["detail"])
smb_sdk.assert_not_called()
with patch("govoplan_files.backend.storage.connector_imports._smbclient_module", return_value=fake_smb):
with patch("govoplan_files.backend.storage.connector_imports._smbclient_module") as smb_import_sdk:
smb_import = self.client.post(
"/api/v1/files/connectors/profiles/tenant-smb/import",
headers=headers,
@@ -2412,12 +2417,9 @@ class ApiSmokeTests(unittest.TestCase):
"target_folder": "imports",
},
)
self.assertEqual(smb_import.status_code, 200, smb_import.text)
smb_file = smb_import.json()["files"][0]
self.assertEqual(smb_file["display_path"], "imports/smb-notice.txt")
self.assertEqual(smb_file["source_provenance"]["provider"], "smb")
self.assertEqual(smb_file["source_provenance"]["external_id"], "files:shared/smb-notice.txt")
self.assertEqual(smb_file["source_provenance"]["metadata"]["share"], "files")
self.assertEqual(smb_import.status_code, 400, smb_import.text)
self.assertIn("redirects/referrals", smb_import.json()["detail"])
smb_import_sdk.assert_not_called()
filtered = self.client.get("/api/v1/files/connectors/profiles?provider=smb", headers=headers)
self.assertEqual(filtered.status_code, 200, filtered.text)
@@ -2941,7 +2943,10 @@ class ApiSmokeTests(unittest.TestCase):
self.assertFalse(delta_payload["full"])
self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id])
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
self.assertEqual(delta_payload["jobs"][0]["last_error"], "SMTP outcome needs reconciliation")
self.assertEqual(
delta_payload["jobs"][0]["last_error"],
"SMTP delivery outcome requires operator reconciliation.",
)
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
@@ -3151,28 +3156,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_campaign_create_validate_build_and_mock_send(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="API smoke delivery", include_imap=True)
campaign_json = {
"version": "1.0",
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
"fields": [{"name": "first_name", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "smtp.example.invalid",
"port": 587,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
},
"imap": {
"enabled": True,
"host": "imap.example.invalid",
"port": 993,
"username": "sender@example.org",
"password": "test-secret",
"security": "tls",
},
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3244,20 +3234,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Managed attachments delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "smtp.example.invalid",
"port": 587,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3457,20 +3440,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Unlinked attachments delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "smtp.example.invalid",
"port": 587,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3578,20 +3554,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Frozen attachment delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"smtp": {
"host": "mock.smtp",
"port": 2525,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3743,18 +3712,35 @@ class ApiSmokeTests(unittest.TestCase):
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
headers, _ = self._login()
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Bulk review delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
"fields": [], "global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Warning test", "text": "Body"},
"attachments": {"base_path": ".", "base_paths": [], "global": [{
"id": "optional-missing", "base_dir": ".", "file_filter": "not-there.pdf",
"required": False, "missing_behavior": "warn", "zip": {"archive_id": "exclude"}
}], "zip": {"enabled": False, "archives": []}},
"attachments": {
"base_path": "review-files",
"base_paths": [{
"id": "managed-review-files",
"name": "Review files",
"source": f"managed:user:{user_id}",
"path": "review-files",
}],
"global": [{
"id": "optional-missing",
"base_path_id": "managed-review-files",
"base_dir": "review-files",
"file_filter": "not-there.pdf",
"required": False,
"missing_behavior": "warn",
"zip": {"archive_id": "exclude"},
}],
"zip": {"enabled": False, "archives": []},
},
"entries": {"inline": [{"id": "warning-entry", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"},
"delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True},
@@ -3779,19 +3765,41 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"])
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
headers, _ = self._login()
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Inactive recipients delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Hello", "text": "Active recipient only"},
"attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}},
"attachments": {
"base_path": "inactive-files",
"base_paths": [{
"id": "managed-inactive-files",
"name": "Inactive files",
"source": f"managed:user:{user_id}",
"path": "inactive-files",
}],
"global": [],
"zip": {"enabled": False, "archives": []},
},
"entries": {"inline": [
{"id": "active", "active": True, "to": [{"email": "active@example.org", "type": "to"}]},
{"id": "inactive", "active": False, "to": [], "attachments": [{"base_dir": "missing", "file_filter": "missing.pdf", "required": True}]},
{
"id": "inactive",
"active": False,
"to": [],
"attachments": [{
"base_path_id": "managed-inactive-files",
"base_dir": "inactive-files",
"file_filter": "missing.pdf",
"required": True,
}],
},
]},
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"},
"delivery": {"imap_append_sent": {"enabled": False}},
@@ -3820,12 +3828,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_recipient_address_fields_and_merge_modes(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="Recipient merge delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": [{"email": "global-from@example.org", "type": "to"}],
"allow_individual_from": True,
@@ -3886,12 +3895,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_multiple_from_addresses_are_rejected(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="Multiple sender validation delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": [
{"email": "first@example.org", "type": "to"},
@@ -3911,12 +3921,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="Duplicate ZIP validation delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "ZIP validation", "text": "Body"},
"attachments": {
@@ -3956,6 +3967,7 @@ class ApiSmokeTests(unittest.TestCase):
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="ZIP attachment delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
@@ -3965,7 +3977,7 @@ class ApiSmokeTests(unittest.TestCase):
{"name": "global_zip_password", "type": "password", "required": True},
],
"global_values": {"global_zip_password": "campaign-secret"},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -4152,14 +4164,15 @@ class ApiSmokeTests(unittest.TestCase):
def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None:
def test_execution_snapshot_detects_profile_drift_and_freezes_job_manifest(self) -> None:
headers, _ = self._login()
campaign_id, version_id = self._create_built_delivery_campaign(
headers,
external_id="snapshot-freeze",
)
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
from govoplan_mail.backend.db.models import MailServerProfile
with SessionLocal() as session:
version = session.get(CampaignVersion, version_id)
@@ -4167,23 +4180,24 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
snapshot = version.execution_snapshot
self.assertIsInstance(snapshot, dict)
self.assertEqual(snapshot["snapshot_version"], "3")
self.assertEqual(snapshot["snapshot_version"], "5")
self.assertEqual(snapshot["job_count"], 1)
self.assertEqual(snapshot["queueable_job_count"], 1)
self.assertTrue(snapshot["job_manifest_sha256"])
self.assertTrue(snapshot["smtp_config_fingerprint"])
self.assertIsNone(snapshot["smtp"].get("password"))
self.assertTrue(snapshot["smtp_transport_revision"])
self.assertNotIn("smtp", snapshot)
self.assertNotIn("imap", snapshot)
self.assertTrue(version.execution_snapshot_hash)
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertTrue(job.eml_sha256)
self.assertTrue(job.message_id_header)
# Simulate accidental/config-drift mutation after the build. Sending
# must still use the immutable mock SMTP snapshot, not raw_json.
mutated = json.loads(json.dumps(version.raw_json))
mutated["server"]["smtp"]["host"] = "smtp.example.invalid"
version.raw_json = mutated
session.add(version)
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
self.assertIsNotNone(profile)
assert profile is not None
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"}
profile.smtp_transport_revision = "manually-rotated-transport-revision"
session.add(profile)
session.commit()
sent = self.client.post(
@@ -4198,12 +4212,17 @@ class ApiSmokeTests(unittest.TestCase):
},
)
self.assertEqual(sent.status_code, 200, sent.text)
self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text)
self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text)
self.assertEqual(sent.json()["result"]["sent_count"], 0)
self.assertEqual(sent.json()["result"]["failed_count"], 1)
self.assertIn("changed after this campaign was built", sent.json()["result"]["results"][0]["message"])
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.send_status, "smtp_accepted")
self.assertEqual(job.send_status, "failed_permanent")
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
self.assertIn("changed after this campaign was built", attempt.error_message)
self.assertNotIn("smtp.example.invalid", attempt.error_message)
self.assertIsNone(attempt.smtp_response)
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
headers, _ = self._login()
@@ -4275,21 +4294,14 @@ class ApiSmokeTests(unittest.TestCase):
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
headers, _ = self._login()
from govoplan_mail.backend.dev.mock_mailbox import set_failures
mail_profile_id = self._create_test_mail_profile(headers, name="Partial refusal delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"smtp": {
"host": "mock.smtp",
"port": 2525,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -5780,6 +5792,7 @@ class ApiSmokeTests(unittest.TestCase):
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
owner_headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(owner_headers, name="ACL campaign delivery")
access_role = self._create_role(
owner_headers,
slug="campaign-collaborator",
@@ -5805,7 +5818,7 @@ class ApiSmokeTests(unittest.TestCase):
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
"template": {"subject": "ACL", "text": "ACL"},
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
@@ -6003,7 +6016,7 @@ class ApiSmokeTests(unittest.TestCase):
json={"campaign_json": inline_json},
)
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
self.assertIn("Campaign-local inline mail settings", blocked_inline.json()["detail"])
self.assertIn("may only reference", blocked_inline.json()["detail"])
reusable_json = detail.json()["raw_json"]
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
@@ -6124,7 +6137,7 @@ class ApiSmokeTests(unittest.TestCase):
}
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
self.assertEqual(blocked.status_code, 422, blocked.text)
self.assertIn("locked", blocked.json()["detail"])
self.assertIn("may only reference", blocked.json()["detail"])
campaign_json["server"] = {"mail_profile_id": profile_id}
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})

View File

@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
)
with TestClient(app) as client:
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
for path in (
"/api/v1/platform/status",
"/api/v1/platform/modules",
"/api/v1/platform/public-modules",
):
first = client.get(path)
self.assertEqual(200, first.status_code, first.text)
etag = first.headers.get("etag")

View File

@@ -4,8 +4,9 @@ import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.testclient import TestClient
from govoplan_core.audit.logging import audit_event, audit_operation_context
@@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase):
cors_origins=("*",),
)
def test_app_sets_safe_default_browser_headers_and_https_hsts(self) -> None:
with patch.dict(
"os.environ",
{"APP_ENV": "test", "GOVOPLAN_HTTP_HSTS_SECONDS": "86400"},
):
app = create_govoplan_app(
title="security header test",
version="test",
registry=PlatformRegistry(),
)
with TestClient(app, base_url="https://govoplan.example.test") as client:
response = client.get("/health")
self.assertEqual("nosniff", response.headers["X-Content-Type-Options"])
self.assertEqual("DENY", response.headers["X-Frame-Options"])
self.assertEqual("strict-origin-when-cross-origin", response.headers["Referrer-Policy"])
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
RuntimeError,
"GovOPlaN configuration validation: FAILED",
):
create_govoplan_app(
title="unsafe production test",
version="test",
registry=PlatformRegistry(),
)
def test_app_rejects_request_bodies_above_deployment_limit(self) -> None:
router = APIRouter()
@router.post("/body")
async def body(request: Request):
return {"size": len(await request.body())}
with patch.dict("os.environ", {"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES": "10"}):
app = create_govoplan_app(
title="request body limit test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client:
response = client.post("/body", content=b"12345678901")
streamed = client.post("/body", content=(chunk for chunk in (b"123456", b"78901")))
self.assertEqual(413, response.status_code, response.text)
self.assertIn("10 bytes", response.json()["detail"])
self.assertEqual(413, streamed.status_code, streamed.text)
def test_app_enforces_configured_trusted_hosts(self) -> None:
with patch.dict("os.environ", {"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test,*.internal.test"}):
app = create_govoplan_app(
title="trusted host test",
version="test",
registry=PlatformRegistry(),
)
with TestClient(app, base_url="https://govoplan.example.test") as client:
allowed = client.get("/health")
rejected = client.get("https://attacker.example.test/health")
self.assertEqual(200, allowed.status_code, allowed.text)
self.assertEqual(400, rejected.status_code, rejected.text)
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
try:

View File

@@ -7,8 +7,22 @@ import tempfile
import unittest
from pathlib import Path
from govoplan_core.devserver import redacted_database_url
class DevserverSmokeTests(unittest.TestCase):
def test_database_url_redaction_hides_passwords(self) -> None:
rendered = redacted_database_url(
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"
)
self.assertEqual(
rendered,
"postgresql+psycopg://govoplan:***@db.example.test/govoplan",
)
self.assertNotIn("database-secret", rendered)
self.assertEqual(redacted_database_url("://database-secret"), "<redacted>")
def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None:
repo_root = Path(__file__).resolve().parents[1]
src_root = repo_root / "src"

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationTopic,
ModuleManifest,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.registry import PlatformRegistry, RegistryError
def workflow_topic(
*,
conditions: tuple[DocumentationCondition, ...],
documentation_types: tuple[str, ...] = ("user",),
kind: str = "workflow",
) -> DocumentationTopic:
return DocumentationTopic(
id="example.workflow.task",
title="Complete a task",
summary="Complete the example task.",
documentation_types=documentation_types, # type: ignore[arg-type]
conditions=conditions,
metadata={"kind": kind},
)
class DocumentationTopicContractTests(unittest.TestCase):
def test_user_workflow_requires_scope_conditions(self) -> None:
topic = workflow_topic(conditions=())
self.assertEqual(
user_workflow_scope_condition_issues(topic),
("user workflow topics must declare at least one scope-conditioned alternative",),
)
with self.assertRaisesRegex(RegistryError, "scope-conditioned alternative"):
registry_for(topic).validate()
def test_every_condition_alternative_must_be_scope_conditioned(self) -> None:
topic = workflow_topic(
conditions=(
DocumentationCondition(required_scopes=("example:item:read",)),
DocumentationCondition(required_modules=("example",)),
)
)
with self.assertRaisesRegex(RegistryError, r"unscoped alternative\(s\): 2"):
registry_for(topic).validate()
def test_scoped_user_workflow_and_non_user_topics_are_accepted(self) -> None:
scoped = workflow_topic(
conditions=(
DocumentationCondition(required_scopes=("example:item:read",)),
DocumentationCondition(any_scopes=("example:item:write", "example:item:admin")),
)
)
admin_workflow = workflow_topic(
conditions=(),
documentation_types=("admin",),
)
user_reference = workflow_topic(conditions=(), kind="reference")
self.assertEqual(user_workflow_scope_condition_issues(scoped), ())
self.assertEqual(user_workflow_scope_condition_issues(admin_workflow), ())
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
registry_for(scoped, admin_workflow, user_reference).validate()
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=topics,
)
)
return registry
if __name__ == "__main__":
unittest.main()

View File

@@ -1,8 +1,23 @@
from __future__ import annotations
import io
import unittest
from unittest.mock import patch
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url
from govoplan_core.security.outbound_http import (
DEFAULT_FILE_TRANSFER_BYTES,
DEFAULT_STRUCTURED_RESPONSE_BYTES,
OutboundHttpBlocked,
OutboundResponseTooLarge,
bounded_chunks_bytes,
bounded_response_bytes,
create_outbound_connection,
outbound_http_policy,
validate_outbound_http_url,
validate_unpinned_sdk_host,
validate_unpinned_sdk_http_url,
)
class HttpFetchTests(unittest.TestCase):
@@ -22,6 +37,187 @@ class HttpFetchTests(unittest.TestCase):
with self.assertRaises(ValueError):
validate_http_url(value)
def test_outbound_policy_has_practical_bounded_defaults(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
self.assertFalse(policy.allow_private_networks)
self.assertEqual(DEFAULT_STRUCTURED_RESPONSE_BYTES, policy.structured_response_bytes)
self.assertEqual(DEFAULT_FILE_TRANSFER_BYTES, policy.file_transfer_bytes)
configured = outbound_http_policy(
{
"APP_ENV": "production",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "1024",
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "2048",
}
)
self.assertTrue(configured.allow_private_networks)
self.assertEqual(1024, configured.structured_response_bytes)
self.assertEqual(2048, configured.file_transfer_bytes)
def test_private_network_policy_is_deployment_wide(self) -> None:
denied_policy = outbound_http_policy({"APP_ENV": "production"})
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
), self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
validate_outbound_http_url("https://connector.example.test/path", policy=denied_policy)
allowed_policy = outbound_http_policy(
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
)
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
):
self.assertEqual(
"https://127.0.0.1/path",
validate_outbound_http_url("https://127.0.0.1/path", policy=allowed_policy),
)
def test_private_network_policy_still_blocks_non_peer_and_metadata_addresses(self) -> None:
policy = outbound_http_policy(
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
)
for address in (
"0.0.0.0",
"169.254.169.254",
"224.0.0.1",
"255.255.255.255",
"::",
"::ffff:255.255.255.255",
"64:ff9b::169.254.169.254",
"64:ff9b::255.255.255.255",
"fd00:ec2::254",
"fe80::1",
"ff02::1",
):
with self.subTest(address=address), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", (address, 443))],
), self.assertRaisesRegex(OutboundHttpBlocked, "forbidden special-purpose network"):
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
def test_public_policy_rejects_reserved_and_ipv4_embedded_ipv6_addresses(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
for address in (
"fec0::1",
"::127.0.0.1",
"64:ff9b::8.8.8.8",
"64:ff9b::127.0.0.1",
"2002:7f00:1::",
):
with self.subTest(address=address), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(10, 1, 6, "", (address, 443, 0, 0))],
), self.assertRaises(OutboundHttpBlocked):
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
def test_outbound_policy_rejects_mixed_public_and_private_dns_answers(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[
(2, 1, 6, "", ("93.184.216.34", 443)),
(2, 1, 6, "", ("10.0.0.4", 443)),
],
), self.assertRaises(OutboundHttpBlocked):
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
def test_bounded_response_rejects_declared_and_streamed_oversize_payloads(self) -> None:
with self.assertRaises(OutboundResponseTooLarge):
bounded_response_bytes(io.BytesIO(b"small"), headers={"Content-Length": "20"}, max_bytes=10)
with self.assertRaises(OutboundResponseTooLarge):
bounded_response_bytes(io.BytesIO(b"eleven-byte"), max_bytes=10)
self.assertEqual(b"ten-bytes!", bounded_response_bytes(io.BytesIO(b"ten-bytes!"), max_bytes=10))
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "5"}), self.assertRaises(
OutboundResponseTooLarge
):
bounded_response_bytes(io.BytesIO(b"123456"), max_bytes=10)
def test_bounded_chunks_rejects_an_oversized_chunk_before_retaining_it(self) -> None:
with self.assertRaises(OutboundResponseTooLarge):
bounded_chunks_bytes((b"one-large-chunk",), max_bytes=8)
def test_connection_time_resolution_blocks_dns_rebinding_before_socket_open(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
) as resolver, patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory:
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
with self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
create_outbound_connection("connector.example.test", 443, policy=policy)
self.assertEqual(2, resolver.call_count)
socket_factory.assert_not_called()
def test_unpinned_sdk_endpoints_fail_closed_in_public_and_private_modes(self) -> None:
for allow_private, address in ((False, "93.184.216.34"), (True, "10.0.0.5")):
with self.subTest(allow_private=allow_private):
policy = outbound_http_policy(
{
"APP_ENV": "production",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": str(allow_private).lower(),
}
)
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", (address, 443))],
), self.assertRaisesRegex(OutboundHttpBlocked, "until that transport supports.*DNS/IP pinning"):
validate_unpinned_sdk_http_url(
"https://objects.example.test",
label="S3 endpoint",
policy=policy,
)
def test_unpinned_host_sdk_fails_closed_for_dns_and_explicit_ip_targets(self) -> None:
policy = outbound_http_policy(
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
)
for host in ("files.internal.example", "10.0.0.5"):
with self.subTest(host=host), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("10.0.0.5", 445))],
), self.assertRaisesRegex(OutboundHttpBlocked, "redirects/referrals.*DNS/IP pinning"):
validate_unpinned_sdk_host(host, port=445, label="SMB endpoint", policy=policy)
def test_core_redirects_strip_credentials_cross_origin_and_reject_https_downgrades(self) -> None:
import urllib.request
request = urllib.request.Request(
"https://catalog.example.test/releases",
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
)
handler = _PolicyRedirectHandler(label="Catalog URL")
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
):
redirected = handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://cdn.example.test/releases",
)
downgrade = handler.redirect_request(
request,
None,
302,
"Found",
{},
"http://catalog.example.test/releases",
)
self.assertIsNotNone(redirected)
self.assertIsNone(redirected.get_header("Authorization"))
self.assertEqual("request-1", redirected.get_header("X-request-id"))
self.assertIsNone(downgrade)
if __name__ == "__main__":
unittest.main()

View File

@@ -21,6 +21,18 @@ class InstallConfigTests(unittest.TestCase):
with self.assertRaises(ValidationError):
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
self.assertEqual(
Settings(
SCHEDULING_CANCELLATION_NOTICE_DAYS="14"
).scheduling_cancellation_notice_days,
14,
)
for invalid in ("0", "91"):
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
result = validate_runtime_configuration({}, profile="self-hosted")
@@ -45,7 +57,9 @@ class InstallConfigTests(unittest.TestCase):
"CELERY_ENABLED": "true",
"REDIS_URL": "redis://redis.example.internal:6379/0",
"CORS_ORIGINS": "https://govoplan.example.org",
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org",
"AUTH_COOKIE_SECURE": "true",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
"FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
},
@@ -75,6 +89,35 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("DATABASE_URL", error_keys)
self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys)
def test_production_validation_rejects_wildcard_host_and_proxy_trust(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "production",
"GOVOPLAN_TRUSTED_HOSTS": "*",
"FORWARDED_ALLOW_IPS": "*",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
},
profile="self-hosted",
)
error_keys = {issue.key for issue in result.errors}
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
self.assertIn("FORWARDED_ALLOW_IPS", error_keys)
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "development",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "VALID_SECRET,invalid-name",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST": "relative/connector-ca.pem",
},
profile="development",
)
error_keys = {issue.key for issue in result.errors}
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", error_keys)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", error_keys)
def test_env_templates_surface_install_profiles(self) -> None:
self_hosted = env_template(profile="self-hosted")
production_like = env_template(profile="production-like", generate_secrets=True)
@@ -82,9 +125,23 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", production_like)
if __name__ == "__main__":

View File

@@ -6,12 +6,13 @@ import base64
import json
import os
import re
import runpy
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import textwrap
import tomllib
import unittest
from dataclasses import replace
@@ -19,7 +20,8 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import Column, Integer, MetaData, Table, inspect
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect
from sqlalchemy.orm import Session
# Keep the default app import side effect from bootstrapping a development DB.
_TEST_ROOT = Path(tempfile.mkdtemp(prefix="govoplan-module-tests-"))
@@ -28,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
os.environ.setdefault("CELERY_ENABLED", "false")
from fastapi import APIRouter
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
@@ -97,9 +99,9 @@ from govoplan_core.core.module_package_catalog import (
sign_module_package_catalog,
validate_module_package_catalog,
)
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base
@@ -110,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import scope_grants
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import RouteCollisionError
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
@@ -336,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(manifest.id, manifest.frontend.module_id)
if manifest.frontend.package_name is not None:
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes):
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
self.assertTrue(route.path.startswith("/"))
self.assertTrue(route.component.strip())
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
@@ -357,6 +360,88 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
registry.validate()
def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None:
for template, message in (
(
RoleTemplate(
slug="invalid-system-default",
name="Invalid",
description="Invalid system default.",
permissions=("system:*",),
level="system",
default_authenticated=True,
),
"must be tenant-level",
),
(
RoleTemplate(
slug="invalid-unmanaged-default",
name="Invalid",
description="Invalid unmanaged default.",
permissions=("tenant:*",),
managed=False,
default_authenticated=True,
),
"must be managed",
),
):
with self.subTest(template=template.slug):
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="test",
role_templates=(template,),
)
)
with self.assertRaisesRegex(RegistryError, message):
registry.validate()
def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="test",
role_templates=(
RoleTemplate(
slug="unsafe-default",
name="Unsafe default",
description="Must not grant broad authority automatically.",
permissions=("tenant:*",),
default_authenticated=True,
),
),
)
)
with self.assertRaisesRegex(RegistryError, "must use explicit permissions"):
registry.validate()
def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None:
registry = PlatformRegistry()
for module_id in ("first", "second"):
registry.register(
ModuleManifest(
id=module_id,
name=module_id.title(),
version="test",
role_templates=(
RoleTemplate(
slug="shared-reader",
name="Shared reader",
description="A colliding role template.",
permissions=(),
),
),
)
)
with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"):
registry.validate()
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
@@ -373,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "Frontend route"):
registry.validate()
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="test",
frontend=FrontendModule(
module_id="example",
package_name="@govoplan/example-webui",
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
public_routes=(
PublicFrontendRoute(
path="/example/public/:token",
component="ExamplePublicPage",
),
),
),
))
registry.register(ModuleManifest(
id="private",
name="Private",
version="test",
frontend=FrontendModule(
module_id="private",
package_name="@govoplan/private-webui",
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
),
))
registry.validate()
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_platform_router(), prefix="/api/v1")
with TestClient(app) as client:
response = client.get("/api/v1/platform/public-modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
public_module = response.json()["modules"][0]
self.assertNotIn("dependencies", public_module)
self.assertNotIn("nav", public_module["frontend"])
self.assertNotIn("routes", public_module["frontend"])
self.assertEqual(
["/example/public/:token"],
[item["path"] for item in public_module["frontend"]["public_routes"]],
)
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
registry = PlatformRegistry()
for module_id in ("first", "second"):
registry.register(ModuleManifest(
id=module_id,
name=module_id.title(),
version="test",
frontend=FrontendModule(
module_id=module_id,
public_routes=(
PublicFrontendRoute(
path="/shared/public/:token",
component=f"{module_id.title()}PublicPage",
),
),
),
))
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
registry.validate()
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
first = APIRouter()
second = APIRouter()
@@ -2039,6 +2192,25 @@ finally:
self.assertEqual(["check", "verify"], [record["task_id"] for record in records])
self.assertEqual(["warning", "warning"], [record["status"] for record in records])
def test_registered_module_migration_tasks_dispose_replaced_database(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-migration-dispose-", dir=_TEST_ROOT))
previous = configure_database(f"sqlite:///{root / 'previous.db'}")
previous_pool = previous.engine.pool
manifest = ModuleManifest(id="taskmod", name="Task Module", version="1.0.0")
try:
run_registered_module_migration_tasks(
database_url=f"sqlite:///{root / 'target.db'}",
enabled_modules=("taskmod",),
phases=(),
manifest_factories=(lambda: manifest,),
)
self.assertIsNot(previous.engine.pool, previous_pool)
self.assertEqual(f"sqlite:///{root / 'target.db'}", get_database().database_url)
finally:
reset_database(dispose=True)
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
available = {
"files": ModuleManifest(
@@ -2350,6 +2522,31 @@ finally:
self.assertEqual("example", record["retirements"][0]["module_id"])
self.assertIn("database_backup", record["snapshot"])
def test_drop_table_retirement_is_atomic_with_session_writes(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-retirement-transaction-", dir=_TEST_ROOT))
engine = create_engine(
f"sqlite:///{root / 'retirement.db'}",
connect_args={"timeout": 0.1},
)
metadata = MetaData()
audit_table = Table("retirement_audit", metadata, Column("id", Integer, primary_key=True))
owned_table = Table("retirement_owned", metadata, Column("id", Integer, primary_key=True))
metadata.create_all(bind=engine)
owned_model = type("OwnedModel", (), {"__table__": owned_table})
with Session(engine) as session:
session.execute(insert(audit_table).values(id=1))
plan = drop_table_retirement_provider(owned_model, label="Owned")(session, "owned")
self.assertIsNotNone(plan.destroy_data_executor)
plan.destroy_data_executor(session, "owned") # type: ignore[misc]
self.assertFalse(inspect(session.connection()).has_table("retirement_owned"))
session.rollback()
self.assertTrue(inspect(engine).has_table("retirement_owned"))
with engine.connect() as connection:
self.assertEqual([], connection.execute(audit_table.select()).all())
engine.dispose()
def test_module_installer_dry_run_writes_run_record_without_applying(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-dry-run-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -2562,9 +2759,33 @@ finally:
self.assertEqual("external", database_backup["metadata"]["snapshot"])
self.assertEqual("postgresql://govoplan:***@db.example.invalid/govoplan", database_backup["metadata"]["pgtools_url"])
self.assertEqual("postgresql+psycopg://govoplan:***@db.example.invalid/govoplan", database_backup["stdout"].strip())
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
secret_path = result.record_path.parent / database_backup["database_url_secret"]
self.assertEqual(database_url, secret_path.read_text(encoding="utf-8"))
self.assertEqual(0o700, stat.S_IMODE(result.record_path.parent.stat().st_mode))
self.assertEqual(0o600, stat.S_IMODE(secret_path.stat().st_mode))
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
def test_installer_secret_creation_refuses_an_existing_path(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-secret-existing-", dir=_TEST_ROOT))
secret_path = root / "database-url.secret"
secret_path.write_text("existing-value", encoding="utf-8")
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Could not create private installer secret file"):
module_installer_module._write_installer_secret(secret_path, "replacement-value")
self.assertEqual("existing-value", secret_path.read_text(encoding="utf-8"))
def test_installer_run_directory_remains_usable_with_a_restrictive_umask(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-run-umask-", dir=_TEST_ROOT))
run_dir = root / "run"
previous_umask = os.umask(0o777)
try:
module_installer_module._create_private_installer_run_dir(run_dir)
finally:
os.umask(previous_umask)
self.assertEqual(0o700, stat.S_IMODE(run_dir.stat().st_mode))
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -3035,27 +3256,21 @@ finally:
self.assertEqual("stable", validation["channel"])
def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
result = subprocess.run(
[
sys.executable,
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "generate-release-catalog.py"),
"--version",
"0.1.7",
"--catalog-output",
str(catalog_path),
],
cwd=str(Path(__file__).resolve().parents[1]),
env={**os.environ.copy(), "GOVOPLAN_CORE_ROOT": str(Path(__file__).resolve().parents[1])},
text=True,
capture_output=True,
check=False,
generator = runpy.run_path(
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
run_name="govoplan_release_catalog_contract_test",
)
generated_at = generator["datetime"].now(tz=generator["UTC"])
catalog = generator["_catalog_payload"](
version="0.1.9",
tag="v0.1.9",
channel="test",
sequence=1,
generated_at=generated_at,
expires_at=generated_at + generator["timedelta"](days=1),
repository_base="git+ssh://git@example.test/add-ideas",
public_base_url="https://example.test",
)
self.assertEqual(0, result.returncode, result.stderr)
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
modules = {item["module_id"]: item for item in catalog["modules"]}
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
@@ -3066,18 +3281,30 @@ finally:
"version_max_exclusive": "0.2.0",
}, modules["files"]["requires_interfaces"])
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
self.assertIn({
"name": "campaigns.access",
"optional": True,
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
}, modules["mail"]["requires_interfaces"])
self.assertIn({
"name": "files.campaign_attachments",
"optional": True,
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertIn({
"name": "mail.campaign_delivery",
"optional": True,
"version_min": "0.2.0",
"version_max_exclusive": "0.3.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.8", modules["files"]["version"])
self.assertIn("@v0.1.8", modules["files"]["python_ref"])
self.assertEqual("0.1.9", modules["files"]["version"])
self.assertIn("@v0.1.9", modules["files"]["python_ref"])
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.people import (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
PeopleSearchGroup,
PersonSearchCandidate,
people_search_providers,
person_selection_key,
search_visible_people,
)
from govoplan_core.core.registry import PlatformRegistry
class FakePeopleSearchProvider:
def __init__(self, *groups: PeopleSearchGroup) -> None:
self.groups = groups
self.calls: list[tuple[object, object, str, int]] = []
def search_people(self, session: object, principal: object, *, query: str, limit: int = 25):
self.calls.append((session, principal, query, limit))
return self.groups
class PeopleSearchContractTests(unittest.TestCase):
def test_collects_optional_providers_in_stable_group_order(self) -> None:
duplicate = PersonSearchCandidate(
selection_key="account:1",
kind="account",
reference_id="1",
display_name="Ada Lovelace",
)
access = FakePeopleSearchProvider(PeopleSearchGroup(key="accounts", label="Accounts", candidates=(duplicate, duplicate)))
addresses = FakePeopleSearchProvider(
PeopleSearchGroup(
key="contacts",
label="Contacts",
candidates=(
PersonSearchCandidate(
selection_key="contact:2:ada@example.test",
kind="contact",
reference_id="2",
display_name="Ada Lovelace",
email="ada@example.test",
),
),
)
)
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="access",
name="Access",
version="1.0.0",
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: access},
))
registry.register(ModuleManifest(
id="addresses",
name="Addresses",
version="1.0.0",
capability_factories={CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda _context: addresses},
))
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
session = object()
principal = object()
groups = search_visible_people(registry, session, principal, query=" ada ", limit=200)
self.assertEqual([group.key for group in groups], ["accounts", "contacts"])
self.assertEqual([len(group.candidates) for group in groups], [1, 1])
self.assertEqual(access.calls, [(session, principal, "ada", 100)])
self.assertEqual(addresses.calls, [(session, principal, "ada", 100)])
self.assertEqual(people_search_providers(None), ())
def test_omits_empty_groups_and_ignores_unrelated_capabilities(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="access",
name="Access",
version="1.0.0",
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: object()},
))
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertEqual(search_visible_people(registry, object(), object(), query="ada"), ())
def test_selection_key_is_stable_and_validated(self) -> None:
self.assertEqual(
person_selection_key("Account", "account-1", email=" Ada@Example.Test "),
"account:account-1:ada@example.test",
)
with self.assertRaises(ValueError):
person_selection_key("", "account-1")
if __name__ == "__main__":
unittest.main()

View File

@@ -3,7 +3,36 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
from govoplan_core.core.poll import (
CAPABILITY_POLL_SCHEDULING,
PollAnswerRef,
PollOptionUpdateCommand,
PollResponseRef,
PollResponseRetirementCommand,
PollResponseRetirementProvider,
poll_response_retirement_provider,
)
class _RetirementProvider:
def retire_responses(self, *args, **kwargs):
raise NotImplementedError
class _Registry:
def __init__(self, capability: object | None) -> None:
self.capability_value = capability
def has_capability(self, name: str) -> bool:
return (
name == CAPABILITY_POLL_SCHEDULING
and self.capability_value is not None
)
def capability(self, name: str) -> object:
if not self.has_capability(name):
raise KeyError(name)
return self.capability_value
class PollContractTests(unittest.TestCase):
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
self.assertEqual(command.label, "Tuesday 10:00")
self.assertEqual(command.value, {"slot_id": "slot-1"})
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
command = PollResponseRetirementCommand(
respondent_ids=("account-1", "alice@example.test"),
invitation_id="invitation-1",
reason="scheduling_participant_removed",
idempotency_key="scheduling:request-1:participant-1:removed",
metadata={"source_module": "scheduling"},
)
provider = _RetirementProvider()
self.assertEqual(command.respondent_ids[0], "account-1")
self.assertIsInstance(provider, PollResponseRetirementProvider)
self.assertIs(
poll_response_retirement_provider(_Registry(provider)),
provider,
)
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
self.assertIsNone(poll_response_retirement_provider(None))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import unittest
from govoplan_core.core.poll_participation import (
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
PollParticipationGatewayProvider,
PollParticipationPolicy,
participation_token_fingerprint,
poll_participation_gateway_provider,
)
class _CompleteGateway:
def create_governed_invitation(self, *args, **kwargs):
raise NotImplementedError
def resolve_participation(self, *args, **kwargs):
raise NotImplementedError
def submit_governed_response(self, *args, **kwargs):
raise NotImplementedError
def resolve_authenticated_participation(self, *args, **kwargs):
raise NotImplementedError
def submit_authenticated_response(self, *args, **kwargs):
raise NotImplementedError
def add_option(self, *args, **kwargs):
raise NotImplementedError
def remove_option(self, *args, **kwargs):
raise NotImplementedError
def revoke_invitation(self, *args, **kwargs):
raise NotImplementedError
def update_invitation_expiry(self, *args, **kwargs):
raise NotImplementedError
class _Registry:
def __init__(self, capability: object | None) -> None:
self.capability_value = capability
def has_capability(self, name: str) -> bool:
return (
name == CAPABILITY_POLL_PARTICIPATION_GATEWAY
and self.capability_value is not None
)
def capability(self, name: str) -> object:
if not self.has_capability(name):
raise KeyError(name)
return self.capability_value
class PollParticipationContractTests(unittest.TestCase):
def test_capability_name_and_policy_defaults_remain_stable(self) -> None:
policy = PollParticipationPolicy()
self.assertEqual("poll.participation_gateway", CAPABILITY_POLL_PARTICIPATION_GATEWAY)
self.assertEqual(1, policy.version)
self.assertTrue(policy.allow_maybe)
def test_token_fingerprint_is_deterministic_and_non_reversible(self) -> None:
fingerprint = participation_token_fingerprint("secret-token")
self.assertEqual(fingerprint, participation_token_fingerprint("secret-token"))
self.assertEqual(64, len(fingerprint))
self.assertNotIn("secret-token", fingerprint)
def test_registry_lookup_accepts_only_the_complete_structural_contract(self) -> None:
provider = _CompleteGateway()
self.assertIsInstance(provider, PollParticipationGatewayProvider)
self.assertIs(provider, poll_participation_gateway_provider(_Registry(provider)))
self.assertIsNone(poll_participation_gateway_provider(_Registry(object())))
self.assertIsNone(poll_participation_gateway_provider(None))
if __name__ == "__main__":
unittest.main()

130
tests/test_throttling.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import unittest
from redis.exceptions import RedisError
from govoplan_core.core.throttling import (
FixedWindowBucket,
FixedWindowThrottle,
InMemoryFixedWindowStore,
ResilientFixedWindowStore,
ThrottleDimension,
build_fixed_window_throttle,
)
class _Clock:
def __init__(self) -> None:
self.value = 100.0
def __call__(self) -> float:
return self.value
class _RecordingStore:
def __init__(self) -> None:
self.values: dict[str, int] = {}
def read(self, key: str) -> FixedWindowBucket:
return FixedWindowBucket(self.values.get(key, 0), 60)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
del window_seconds
self.values[key] = self.values.get(key, 0) + 1
return FixedWindowBucket(self.values[key], 60)
def delete(self, key: str) -> None:
self.values.pop(key, None)
class _FailingStore(_RecordingStore):
def read(self, key: str) -> FixedWindowBucket:
del key
raise RedisError("offline")
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
del key, window_seconds
raise RedisError("offline")
def delete(self, key: str) -> None:
del key
raise RedisError("offline")
class FixedWindowThrottleTests(unittest.TestCase):
def test_blocks_at_limit_and_expires_without_sleeping(self) -> None:
clock = _Clock()
store = InMemoryFixedWindowStore(clock=clock)
throttle = FixedWindowThrottle(store, window_seconds=60)
dimensions = (ThrottleDimension("password", "secret-subject", 3),)
self.assertTrue(throttle.check(dimensions).allowed)
self.assertTrue(throttle.record(dimensions).allowed)
self.assertTrue(throttle.record(dimensions).allowed)
blocked = throttle.record(dimensions)
self.assertFalse(blocked.allowed)
self.assertEqual(blocked.retry_after_seconds, 60)
self.assertFalse(throttle.check(dimensions).allowed)
clock.value += 61
self.assertTrue(throttle.check(dimensions).allowed)
def test_subject_is_hashed_and_success_can_reset_it(self) -> None:
store = _RecordingStore()
throttle = FixedWindowThrottle(store, window_seconds=60)
dimensions = (ThrottleDimension("poll-participation-password", "tenant:token-secret", 2),)
throttle.record(dimensions)
self.assertEqual(len(store.values), 1)
stored_key = next(iter(store.values))
self.assertNotIn("tenant:token-secret", stored_key)
self.assertIn(":poll-participation-password:", stored_key)
throttle.reset(dimensions)
self.assertEqual(store.values, {})
def test_resilient_store_uses_bounded_local_fallback(self) -> None:
clock = _Clock()
fallback = InMemoryFixedWindowStore(max_entries=2, clock=clock)
store = ResilientFixedWindowStore(
_FailingStore(),
fallback,
retry_seconds=30,
clock=clock,
)
first = store.increment("first", window_seconds=60)
second = store.increment("first", window_seconds=60)
store.increment("second", window_seconds=60)
store.increment("third", window_seconds=60)
self.assertEqual(first.count, 1)
self.assertEqual(second.count, 2)
self.assertEqual(fallback.read("first").count, 0)
self.assertEqual(fallback.read("third").count, 1)
def test_builder_operates_without_redis(self) -> None:
throttle = build_fixed_window_throttle(
redis_url=None,
window_seconds=30,
max_local_entries=10,
)
dimensions = (ThrottleDimension("test", "subject", 1),)
self.assertFalse(throttle.record(dimensions).allowed)
self.assertFalse(throttle.check(dimensions).allowed)
def test_rejects_invalid_dimensions(self) -> None:
throttle = FixedWindowThrottle(_RecordingStore(), window_seconds=60)
with self.assertRaisesRegex(ValueError, "At least one"):
throttle.check(())
with self.assertRaisesRegex(ValueError, "positive"):
throttle.check((ThrottleDimension("test", "subject", 0),))
with self.assertRaisesRegex(ValueError, "namespaces"):
throttle.check((ThrottleDimension("Invalid namespace", "subject", 1),))
if __name__ == "__main__":
unittest.main()

143
tests/test_wheel_runtime.py Normal file
View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import sysconfig
import tempfile
import unittest
class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
access_repository = repository_root.parent / "govoplan-access"
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels"
wheel_directory.mkdir()
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(repository_root),
cwd=temporary_root,
)
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(access_repository),
cwd=temporary_root,
)
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
self.assertEqual(1, len(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
venv_python = virtual_environment / "bin" / "python"
self._run(
str(venv_python),
"-m",
"pip",
"install",
"--no-deps",
str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root,
)
database_path = temporary_root / "wheel-runtime.db"
probe = self._run(
str(venv_python),
"-c",
self._probe_script(),
str(database_path),
cwd=temporary_root,
env={
**os.environ,
# Reuse only third-party test dependencies from the parent
# environment. Editable .pth files are not processed from
# PYTHONPATH, so Core itself can only come from the wheel.
"PYTHONPATH": sysconfig.get_path("purelib"),
"GOVOPLAN_CORE_SOURCE_ROOT": "",
"GOVOPLAN_ENABLED_MODULES": "",
},
)
result = json.loads(probe.stdout)
installed_package = Path(result["package_file"])
runtime_root = Path(result["runtime_root"])
self.assertTrue(installed_package.is_relative_to(virtual_environment))
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertIn("4f2a9c8e7b6d", result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])
@staticmethod
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
env=env,
check=False,
capture_output=True,
text=True,
)
if result.returncode:
raise AssertionError(
f"Command failed ({result.returncode}): {' '.join(command)}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result
@staticmethod
def _probe_script() -> str:
return """
import json
from pathlib import Path
import sys
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect
import govoplan_core
from govoplan_core.db.migrations import _repo_root, alembic_config
database_url = f"sqlite:///{Path(sys.argv[1])}"
config = alembic_config(database_url=database_url, enabled_modules=())
command.upgrade(config, "heads")
script = ScriptDirectory.from_config(config)
engine = create_engine(database_url)
try:
with engine.connect() as connection:
tables = sorted(inspect(connection).get_table_names())
finally:
engine.dispose()
print(json.dumps({
"package_file": govoplan_core.__file__,
"runtime_root": str(_repo_root()),
"heads": sorted(script.get_heads()),
"tables": tables,
}))
"""
if __name__ == "__main__":
unittest.main()

View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",

View File

@@ -1,18 +1,18 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
@@ -787,13 +787,13 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
"version": "0.1.10",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#735e874bd03c55c626347f5356301fe221145b98",
"dependencies": {
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -23,9 +23,16 @@
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
},
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -24,7 +24,6 @@
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
@@ -32,8 +31,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"

View File

@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const source = readFileSync("src/components/Dialog.tsx", "utf8");
const stackSource = readFileSync("src/components/dialogStack.ts", "utf8");
assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary");
assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback");
assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack");
assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners");
assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener");
assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog");
assert(stackSource.includes('panel.setAttribute("inert", "")'), "underlying dialog panels become inert");
assert(stackSource.includes('panel.setAttribute("aria-hidden", "true")'), "underlying dialogs leave the accessibility tree");
assert(source.includes('data-dialog-stack-state="topmost"'), "Dialog exposes stack state for custom keyboard interactions");
assert(source.includes("dialogIsTopmost(stackIdRef.current)"), "underlying backdrops cannot close their dialog");
assert(source.includes("shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)"), "Dialog preserves explicit backdrop-close conditions");
console.log("Dialog focus and stack lifecycle structure checks passed.");

View File

@@ -12,6 +12,7 @@ const packageByModule = {
files: "@govoplan/files-webui",
idm: "@govoplan/idm-webui",
mail: "@govoplan/mail-webui",
notifications: "@govoplan/notifications-webui",
organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui",
@@ -29,6 +30,7 @@ const cases = [
{ name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
{ name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "campaign-only", modules: ["campaigns"] },
@@ -37,7 +39,7 @@ const cases = [
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "calendar", "scheduling"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -1,14 +1,14 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
@@ -30,7 +30,9 @@ export default function App() {
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true);
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
@@ -38,9 +40,13 @@ export default function App() {
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
function updateSettings(next: ApiSettings) {
@@ -119,6 +125,14 @@ export default function App() {
};
}, [settings.apiBaseUrl]);
useEffect(() => {
let cancelled = false;
fetchPlatformPublicModules(settings).
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
setCheckingSession(true);
@@ -233,6 +247,18 @@ export default function App() {
return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
useEffect(() => {
let cancelled = false;
if (!platformPublicModules?.length) {
setRemotePublicWebModules([]);
return () => {cancelled = true;};
}
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
return () => {cancelled = true;};
}, [platformPublicModules, localPublicWebModules]);
useEffect(() => {
if (!auth) return;
@@ -284,7 +310,7 @@ export default function App() {
if (checkingSession) {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing">
@@ -304,10 +330,21 @@ export default function App() {
if (!auth) {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<Routes>
{publicRoutes.map((route) =>
<Route
key={`public:${route.path}`}
path={route.path}
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
/>
)}
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
</Routes>
</Suspense>
</AppShell>
</UnsavedChangesProvider>
</PlatformModulesProvider>
@@ -342,6 +379,13 @@ export default function App() {
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
{publicRoutes.map((route) =>
<Route
key={`public:${route.path}`}
path={route.path}
element={route.render({ settings, auth, onAuthChange: updateAuth })}
/>
)}
{moduleRoutes.map((route) =>
<Route
key={route.path}

View File

@@ -1,8 +1,9 @@
import type { ApiSettings } from "../types";
import type { PlatformModuleInfo } from "../types";
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
export type PlatformStatusResponse = {
maintenance_mode: {
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
}
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
}
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
}

View File

@@ -0,0 +1,35 @@
import type { ApiSettings } from "../types";
import { apiFetch, apiPath } from "./client";
import type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse,
ResourceAccessExplanationUser
} from "./resourceAccessContracts";
export type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse,
ResourceAccessExplanationUser
} from "./resourceAccessContracts";
/**
* Fetches the platform's optional resource-access explanation endpoint.
* Core owns the transport contract without requiring the Access module that
* contributes the endpoint at runtime.
*/
export function fetchResourceAccessExplanation<
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
>(
settings: ApiSettings,
options: ResourceAccessExplanationOptions
): Promise<ResourceAccessExplanationResponse<TUser, TProvenance>> {
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action,
tenant_id: options.tenantId
}));
}

View File

@@ -0,0 +1,34 @@
export type ResourceAccessExplanationUser = {
id: string;
account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details?: Record<string, unknown>;
};
export type ResourceAccessExplanationResponse<
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
> = {
user: TUser;
resource_type: string;
resource_id: string;
action: string;
provenance: TProvenance[];
};
export type ResourceAccessExplanationOptions = {
userId: string;
resourceType: string;
resourceId: string;
action: string;
tenantId?: string | null;
};

View File

@@ -1,5 +1,12 @@
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" };
import type { ButtonHTMLAttributes, ReactNode } from "react";
import DisabledActionTooltip from "./DisabledActionTooltip";
export default function Button({ variant = "secondary", className = "", ...props }: Props) {
return <button className={`btn btn-${variant} ${className}`} {...props} />;
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
disabledReason?: ReactNode;
};
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) {
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
}

View File

@@ -1,5 +1,7 @@
import { i18nMessage } from "../i18n/LanguageContext";import { Palette } from "lucide-react";
import { Palette } from "lucide-react";
import { useEffect, useRef, useState, type InputHTMLAttributes } from "react";
import useOutsideDismiss from "../hooks/useOutsideDismiss";
import { i18nMessage } from "../i18n/LanguageContext";
type ColorPickerFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
value: string;
@@ -20,27 +22,6 @@ function normalizeColor(value: string): string {
return trimmed;
}
function useOutsideClose(open: boolean, onClose: () => void) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!ref.current || ref.current.contains(event.target as Node)) return;
onClose();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, onClose]);
return ref;
}
export default function ColorPickerField({
value,
onChange,
@@ -51,7 +32,7 @@ export default function ColorPickerField({
...props
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
const rootRef = useOutsideClose(open, () => setOpen(false));
const rootRef = useOutsideDismiss(open, () => setOpen(false));
const inputRef = useRef<HTMLInputElement | null>(null);
const normalizedValue = normalizeColor(value || "");
const previewColor = HEX_PATTERN.test(normalizedValue) ? normalizedValue : "transparent";

View File

@@ -1,5 +1,6 @@
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
import useOutsideDismiss from "../hooks/useOutsideDismiss";
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
value: string;
@@ -48,32 +49,11 @@ function combineDateTime(date: string, time: string): string {
return `${date || dateString(new Date())}T${time || "00:00"}`;
}
function useOutsideClose(open: boolean, onClose: () => void) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!ref.current || ref.current.contains(event.target as Node)) return;
onClose();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, onClose]);
return ref;
}
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
const selectedDate = parseDate(value);
const [open, setOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
const rootRef = useOutsideClose(open, () => setOpen(false));
const rootRef = useOutsideDismiss(open, () => setOpen(false));
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {

View File

@@ -1,5 +1,12 @@
import { useEffect, useId, type ReactNode } from "react";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
import {
dialogIsTopmost,
nextDialogActivationOrder,
registerDialog,
type DialogStackId
} from "./dialogStack";
export type DialogProps = {
open: boolean;
@@ -46,6 +53,20 @@ export default function Dialog({
}: DialogProps) {
const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled;
const panelRef = useRef<HTMLElement | null>(null);
const stackIdRef = useRef<DialogStackId>(Symbol("govoplan-dialog"));
const activationOrderRef = useRef<number | null>(null);
const canCloseRef = useRef(canClose);
const onCloseRef = useRef(onClose);
const restoreFocusRef = useRef<HTMLElement | null>(
typeof document !== "undefined" && typeof HTMLElement !== "undefined" && document.activeElement instanceof HTMLElement
? document.activeElement
: null
);
canCloseRef.current = canClose;
onCloseRef.current = onClose;
if (!open) activationOrderRef.current = null;
else if (activationOrderRef.current === null) activationOrderRef.current = nextDialogActivationOrder();
const { translateText } = usePlatformLanguage();
const renderedTitle = translateReactNode(title, translateText);
const renderedChildren = translateReactNode(children, translateText);
@@ -53,15 +74,36 @@ export default function Dialog({
const translatedCloseLabel = translateText(closeLabel);
useEffect(() => {
if (!open || !canClose) return undefined;
if (open || typeof document === "undefined") return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose?.();
const rememberFocusedElement = () => {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) return;
if (panelRef.current?.contains(activeElement)) return;
restoreFocusRef.current = activeElement;
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [canClose, onClose, open]);
rememberFocusedElement();
document.addEventListener("focusin", rememberFocusedElement);
return () => document.removeEventListener("focusin", rememberFocusedElement);
}, [open]);
useEffect(() => {
if (!open || typeof document === "undefined") return undefined;
const panel = panelRef.current;
const activationOrder = activationOrderRef.current;
if (!panel || activationOrder === null) return undefined;
return registerDialog({
id: stackIdRef.current,
activationOrder,
panel,
restoreFocus: restoreFocusRef.current,
canClose: () => canCloseRef.current,
onClose: () => onCloseRef.current?.()
}, document.activeElement);
}, [open]);
if (!open) return null;
@@ -70,13 +112,19 @@ export default function Dialog({
className={joinClasses("dialog-backdrop", backdropClassName)}
role="presentation"
onMouseDown={(event) => {
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
if (
dialogIsTopmost(stackIdRef.current)
&& shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)
) onClose?.();
}}
>
<section
ref={panelRef}
tabIndex={-1}
className={joinClasses("dialog-panel", className)}
role={role}
aria-modal="true"
data-dialog-stack-state="topmost"
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
>

View File

@@ -10,15 +10,13 @@ export type ExplorerTreeNodeContext = {
disabled: boolean;
};
export type ExplorerTreeProps<T> = {
type ExplorerTreeCommonProps<T> = {
nodes: T[];
getNodeId: (node: T) => string;
getNodeLabel: (node: T) => string;
getNodeChildren: (node: T) => T[];
activeId?: string;
expandedIds: Set<string>;
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
disabled?: boolean;
depth?: number;
className?: string;
@@ -29,6 +27,7 @@ export type ExplorerTreeProps<T> = {
nodeButtonBaseClassName?: string;
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
renderNodeActions?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
@@ -41,122 +40,162 @@ export type ExplorerTreeProps<T> = {
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
};
export default function ExplorerTree<T>({
nodes,
getNodeId,
getNodeLabel,
getNodeChildren,
activeId = "",
expandedIds,
onOpen,
onToggle,
disabled = false,
depth = 1,
className = "",
childrenBaseClassName = "explorer-tree-children file-tree-children",
nodeContainerClassName = "",
nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap",
toggleBaseClassName = "explorer-tree-toggle file-tree-toggle",
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
renderToggleIcon,
renderNodeContent,
getNodeWrapClassName,
getNodeWrapStyle,
getNodeButtonClassName,
getNodeDraggable,
onContextMenu,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop
}: ExplorerTreeProps<T>) {
if (nodes.length === 0) return null;
type CollapsibleExplorerTreeProps<T> = {
collapsible?: true;
expandedIds: ReadonlySet<string>;
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
};
return (
<div className={[childrenBaseClassName, className].filter(Boolean).join(" ")}>
{nodes.map((node) => {
const nodeId = getNodeId(node);
const children = getNodeChildren(node);
const hasChildren = children.length > 0;
const expanded = expandedIds.has(nodeId);
const active = activeId === nodeId;
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
const draggable = getNodeDraggable?.(node, context) ?? false;
type StaticExplorerTreeProps = {
collapsible: false;
expandedIds?: never;
onToggle?: never;
};
return (
<div key={nodeId} className={nodeContainerClassName}>
<div
className={[
nodeWrapBaseClassName,
active ? "is-active" : "",
getNodeWrapClassName?.(node, context) ?? ""].
filter(Boolean).join(" ")}
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
draggable={draggable && !disabled}
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}>
export type ExplorerTreeProps<T> = ExplorerTreeCommonProps<T> & (
CollapsibleExplorerTreeProps<T> | StaticExplorerTreeProps
);
<button
type="button"
className={toggleBaseClassName}
onClick={(event) => {
event.stopPropagation();
if (hasChildren) onToggle(node, context);
}}
disabled={disabled || !hasChildren}
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506", value1: getNodeLabel(node) })}
aria-expanded={hasChildren ? expanded : undefined}>
type SafeNode<T> = {
id: string;
node: T;
};
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
</button>
<button
type="button"
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
onClick={() => onOpen(node, context)}
disabled={disabled}>
const EMPTY_EXPANDED_IDS: ReadonlySet<string> = new Set();
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
</button>
export default function ExplorerTree<T>(props: ExplorerTreeProps<T>) {
const {
nodes,
getNodeId,
getNodeLabel,
getNodeChildren,
activeId = "",
onOpen,
disabled = false,
depth = 1,
className = "",
childrenBaseClassName = "explorer-tree-children file-tree-children",
nodeContainerClassName = "",
nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap",
toggleBaseClassName = "explorer-tree-toggle file-tree-toggle",
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
renderToggleIcon,
renderNodeContent,
renderNodeActions,
getNodeWrapClassName,
getNodeWrapStyle,
getNodeButtonClassName,
getNodeDraggable,
onContextMenu,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop
} = props;
const collapsible = props.collapsible !== false;
const expandedIds = props.collapsible === false ? EMPTY_EXPANDED_IDS : props.expandedIds;
const onToggle = props.collapsible === false ? undefined : props.onToggle;
function pathSafeNodes(levelNodes: T[], ancestorIds: ReadonlySet<string>): SafeNode<T>[] {
const siblingIds = new Set<string>();
const safeNodes: SafeNode<T>[] = [];
for (const node of levelNodes) {
const id = getNodeId(node);
if (ancestorIds.has(id) || siblingIds.has(id)) continue;
siblingIds.add(id);
safeNodes.push({ id, node });
}
return safeNodes;
}
function renderLevel(levelNodes: T[], currentDepth: number, ancestorIds: ReadonlySet<string>, root = false): ReactNode {
const safeNodes = pathSafeNodes(levelNodes, ancestorIds);
if (safeNodes.length === 0) return null;
return (
<div className={[childrenBaseClassName, root ? className : ""].filter(Boolean).join(" ")}>
{safeNodes.map(({ id: nodeId, node }) => {
const nextAncestorIds = new Set(ancestorIds);
nextAncestorIds.add(nodeId);
const children = getNodeChildren(node);
const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0;
const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren;
const active = activeId === nodeId;
const context: ExplorerTreeNodeContext = { depth: currentDepth, active, expanded, hasChildren, disabled };
const draggable = getNodeDraggable?.(node, context) ?? false;
const nodeActions = renderNodeActions?.(node, context);
const hasNodeActions = nodeActions !== null && nodeActions !== undefined && nodeActions !== false;
return (
<div key={nodeId} className={nodeContainerClassName}>
<div
className={[
nodeWrapBaseClassName,
active ? "is-active" : "",
hasNodeActions ? "has-actions" : "",
getNodeWrapClassName?.(node, context) ?? ""
].filter(Boolean).join(" ")}
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(currentDepth * 14, 56)}px` }}
draggable={draggable && !disabled}
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
>
{collapsible ? (
<button
type="button"
className={toggleBaseClassName}
onClick={(event) => {
event.stopPropagation();
if (hasChildren) onToggle?.(node, context);
}}
disabled={disabled || !hasChildren}
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506",
value1: getNodeLabel(node)
})}
aria-expanded={hasChildren ? expanded : undefined}
>
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
</button>
) : (
<span className={`${toggleBaseClassName} explorer-tree-toggle-static`} aria-hidden="true">
{renderToggleIcon?.(node, context) ?? (hasChildren ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
</span>
)}
<button
type="button"
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
onClick={() => onOpen(node, context)}
disabled={disabled}
aria-current={active ? "true" : undefined}
>
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
</button>
{hasNodeActions && (
<div
className="explorer-tree-actions"
role="group"
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
value0: "i18n:govoplan-core.actions.c3cd636a",
value1: getNodeLabel(node)
})}
>
{nodeActions}
</div>
)}
</div>
{hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
</div>
{hasChildren && expanded &&
<ExplorerTree
nodes={children}
getNodeId={getNodeId}
getNodeLabel={getNodeLabel}
getNodeChildren={getNodeChildren}
activeId={activeId}
expandedIds={expandedIds}
onOpen={onOpen}
onToggle={onToggle}
disabled={disabled}
depth={depth + 1}
childrenBaseClassName={childrenBaseClassName}
nodeContainerClassName={nodeContainerClassName}
nodeWrapBaseClassName={nodeWrapBaseClassName}
toggleBaseClassName={toggleBaseClassName}
nodeButtonBaseClassName={nodeButtonBaseClassName}
renderToggleIcon={renderToggleIcon}
renderNodeContent={renderNodeContent}
getNodeWrapClassName={getNodeWrapClassName}
getNodeWrapStyle={getNodeWrapStyle}
getNodeButtonClassName={getNodeButtonClassName}
getNodeDraggable={getNodeDraggable}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop} />
}
</div>);
})}
</div>);
);
})}
</div>
);
}
return renderLevel(nodes, depth, new Set(), true);
}

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import Button, { type ButtonProps } from "./Button";
export type IconButtonProps = Omit<ButtonProps, "aria-label" | "children" | "title"> & {
label: string;
icon: ReactNode;
};
export default function IconButton({
label,
icon,
className = "",
type = "button",
...buttonProps
}: IconButtonProps) {
const { translateText } = usePlatformLanguage();
const translatedLabel = translateText(label);
return (
<Button
{...buttonProps}
type={type}
className={["icon-button", className].filter(Boolean).join(" ")}
aria-label={translatedLabel}
title={translatedLabel}
>
<span className="icon-button-icon" aria-hidden="true">{icon}</span>
</Button>
);
}

View File

@@ -23,6 +23,32 @@ export type MessageDisplayAttachment = {
type MessageDisplayBodyMode = "text" | "html";
const MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY = [
"default-src 'none'",
"base-uri 'none'",
"connect-src 'none'",
"font-src 'none'",
"form-action 'none'",
"frame-src 'none'",
"img-src data: cid:",
"manifest-src 'none'",
"media-src 'none'",
"object-src 'none'",
"prefetch-src 'none'",
"script-src 'none'",
"style-src 'unsafe-inline'",
"worker-src 'none'"
].join("; ");
const URL_ATTRIBUTE_PATTERN = /(\s)(src|href|xlink:href|poster|background|action|formaction|cite|longdesc|ping|data)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
const SOURCE_SET_ATTRIBUTE_PATTERN = /\s(?:srcset|imagesrcset)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
const EVENT_HANDLER_ATTRIBUTE_PATTERN = /\son[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
const CSS_IMPORT_PATTERN = /@import\s+(?:url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)|"[^"]*"|'[^']*')[^;]*;?/gi;
const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
const UNSAFE_DOCUMENT_ELEMENT_PATTERN = /<\s*(?:base|link|meta)\b[^>]*>/gi;
const SCRIPT_BLOCK_PATTERN = /<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi;
const SCRIPT_TAG_PATTERN = /<\s*\/?\s*script\b[^>]*>/gi;
type MessageDisplayPanelProps = {
title?: string | null;
fields?: MessageDisplayField[];
@@ -71,6 +97,7 @@ export default function MessageDisplayPanel({
const showBodySwitch = hasHtml && hasText;
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
const safeHtmlDocument = buildSafeMessageHtmlDocument(bodyHtml || htmlBodyFallback);
return (
<div className="message-display-panel">
@@ -104,7 +131,12 @@ export default function MessageDisplayPanel({
}
</div>
{activeBodyMode === "html" ?
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
<iframe
className="message-display-html-frame"
title="i18n:govoplan-core.rendered_html_message_body.8638b634"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={safeHtmlDocument} /> :
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
}
@@ -278,3 +310,67 @@ function formatBytes(value?: number | null): string {
function stripHtml(value: string): string {
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
/**
* Build the isolated document used for untrusted email HTML previews.
*
* Attribute neutralization keeps common clients from even attempting remote
* resource resolution. The document-local CSP is the authoritative backstop
* for malformed markup and parser edge cases: no network-capable resource is
* permitted, while inline presentation plus embedded CID/raster data images
* remain usable inside the already scriptless sandbox.
*/
export function buildSafeMessageHtmlDocument(value: string): string {
const neutralized = neutralizeRemoteMessageContent(value);
return [
"<!doctype html>",
"<html>",
"<head>",
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY}">`,
'<meta name="referrer" content="no-referrer">',
"</head>",
`<body>${neutralized}</body>`,
"</html>"
].join("");
}
function neutralizeRemoteMessageContent(value: string): string {
return value.
replace(SCRIPT_BLOCK_PATTERN, "").
replace(SCRIPT_TAG_PATTERN, "").
replace(UNSAFE_DOCUMENT_ELEMENT_PATTERN, "").
replace(EVENT_HANDLER_ATTRIBUTE_PATTERN, "").
replace(SOURCE_SET_ATTRIBUTE_PATTERN, "").
replace(URL_ATTRIBUTE_PATTERN, (_match, spacing: string, attributeName: string, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
const url = doubleQuoted ?? singleQuoted ?? unquoted ?? "";
if (!messagePreviewUrlAllowed(attributeName, url)) return "";
return `${spacing}${attributeName.toLowerCase()}="${escapeHtmlAttribute(url.trim())}"`;
}).
replace(CSS_IMPORT_PATTERN, "").
replace(CSS_URL_PATTERN, (_match, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
const url = (doubleQuoted ?? singleQuoted ?? unquoted ?? "").trim();
return isSafeEmbeddedMessageResource(url) ? `url("${escapeCssString(url)}")` : "none";
});
}
function messagePreviewUrlAllowed(attributeName: string, value: string): boolean {
const attribute = attributeName.toLowerCase();
if (["action", "formaction", "ping"].includes(attribute)) return false;
const url = value.trim();
if ((attribute === "href" || attribute === "xlink:href") && /^#[^\s]*$/.test(url)) return true;
return isSafeEmbeddedMessageResource(url);
}
function isSafeEmbeddedMessageResource(value: string): boolean {
if (/^cid:[^\s"'<>]+$/i.test(value)) return true;
return /^data:image\/(?:png|gif|jpe?g|webp);base64,[a-z0-9+/=\s]+$/i.test(value);
}
function escapeHtmlAttribute(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeCssString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\f]/g, "");
}

View File

@@ -1,9 +1,14 @@
import { usePlatformLanguage } from "../i18n/LanguageContext";
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
const { translateText } = usePlatformLanguage();
const renderedValue = typeof value === "string" ? translateText(value) : value;
return (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
{detail && <div className="metric-detail">{detail}</div>}
<div className="metric-label">{translateText(label)}</div>
<div className="metric-value">{renderedValue}</div>
{detail && <div className="metric-detail">{translateText(detail)}</div>}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts";
export type ResourceAccessExplanationProps = {
loading?: boolean;
explanation?: ResourceAccessExplanationResponse | null;
fallbackResourceLabel?: string;
};
export default function ResourceAccessExplanation({
loading = false,
explanation,
fallbackResourceLabel = ""
}: ResourceAccessExplanationProps) {
if (loading) {
return <p className="muted small-note">i18n:govoplan-core.loading_access_explanation.04a7c934</p>;
}
if (!explanation) return null;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label ||
fallbackResourceLabel ||
explanation.resource_id;
return (
<>
<div className="form-grid compact responsive-form-grid">
<div><span className="form-label">i18n:govoplan-core.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-core.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-core.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-core.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
{explanation.provenance.length === 0 ? (
<p className="muted small-note">i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e</p>
) : (
<div className="admin-assignment-grid">
{explanation.provenance.map((item, index) => (
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{resourceAccessProvenanceKindLabel(item.kind)}</strong>
<div className="muted small-note">
{item.source || "i18n:govoplan-core.no_source.6dcf9723"}
{item.id && <> · <code>{item.id}</code></>}
</div>
{item.label && <p>{item.label}</p>}
{Object.keys(item.details ?? {}).length > 0 && (
<p className="muted small-note">{formatResourceAccessProvenanceDetails(item.details ?? {})}</p>
)}
</div>
))}
</div>
)}
</>
);
}
export function resourceAccessProvenanceKindLabel(kind: string): string {
switch (kind) {
case "resource":
return "i18n:govoplan-core.resource.d1c626a9";
case "owner":
return "i18n:govoplan-core.owner.89ff3122";
case "share":
return "i18n:govoplan-core.share.09ca55ca";
case "policy":
return "i18n:govoplan-core.policy.0b779a05";
case "role":
return "i18n:govoplan-core.role.b5b4a5a2";
case "right":
return "i18n:govoplan-core.permission.2f81a22d";
default:
return kind;
}
}
export function formatResourceAccessProvenanceDetails(details: Record<string, unknown>): string {
return Object.entries(details)
.map(([key, value]) => `${key}: ${formatResourceAccessProvenanceValue(value)}`)
.join("; ");
}
function formatResourceAccessProvenanceValue(value: unknown): string {
if (value === null || value === undefined) return "i18n:govoplan-core.none.6eef6648";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value) ?? String(value);
}

View File

@@ -0,0 +1,67 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type SelectionListProps = Omit<HTMLAttributes<HTMLDivElement>, "children" | "role"> & {
children: ReactNode;
label?: string;
};
export type SelectionListItemProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"aria-selected" | "children" | "role"
> & {
children: ReactNode;
selected: boolean;
};
export default function SelectionList({
"aria-label": ariaLabel,
children,
className = "",
label,
...props
}: SelectionListProps) {
const { translateText } = usePlatformLanguage();
const rootClassName = ["selection-list", className].filter(Boolean).join(" ");
return (
<div
{...props}
className={rootClassName}
role="listbox"
aria-label={label ? translateText(label) : ariaLabel}
>
{children}
</div>
);
}
export function SelectionListItem({
children,
className = "",
disabled = false,
selected,
type = "button",
...props
}: SelectionListItemProps) {
const itemClassName = [
"selection-list-item",
selected ? "is-selected" : "",
disabled ? "is-disabled" : "",
className
].filter(Boolean).join(" ");
return (
<button
{...props}
type={type}
role="option"
aria-selected={selected}
aria-disabled={disabled || undefined}
className={itemClassName}
disabled={disabled}
>
{children}
</button>
);
}

View File

@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void;
/**
* Route an explicit Discard button through the same confirmation used for
* dirty navigation. The action runs after either saving or discarding.
*/
requestDiscard: (action: UnsavedNavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
setPendingAction(() => action);
}, []);
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
requestNavigation(action);
}, [requestNavigation]);
useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) {
const active = registrationRef.current;
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges,
registerUnsavedChanges,
requestNavigation
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
requestNavigation,
requestDiscard
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
return (
<UnsavedChangesContext.Provider value={value}>
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action()
requestNavigation: (action) => action(),
requestDiscard: (action) => action()
};
export function useUnsavedChanges() {

View File

@@ -1,29 +1,8 @@
import type { ReactNode } from "react";
import Button from "../Button";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import IconButton, { type IconButtonProps } from "../IconButton";
type Props = {
label: string;
icon: ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: "primary" | "secondary" | "ghost" | "danger";
};
export type AdminIconButtonProps = IconButtonProps;
export default function AdminIconButton({ label, icon, onClick, disabled = false, variant = "secondary" }: Props) {
const { translateText } = usePlatformLanguage();
const translatedLabel = translateText(label);
return (
<Button
type="button"
variant={variant}
className="admin-icon-button"
aria-label={translatedLabel}
title={translatedLabel}
disabled={disabled}
onClick={onClick}
>
{icon}
</Button>
);
/** @deprecated Prefer the neutral IconButton export for new consumers. */
export default function AdminIconButton({ className = "", ...props }: AdminIconButtonProps) {
return <IconButton {...props} className={["admin-icon-button", className].filter(Boolean).join(" ")} />;
}

View File

@@ -1,9 +1,10 @@
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import type { ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../../i18n/LanguageContext";
type Option = {
id: string;
label: string;
description?: string | null;
label: ReactNode;
description?: ReactNode;
disabled?: boolean;
};
@@ -37,8 +38,8 @@ export default function AdminSelectionList({
}} />
<span>
<strong>{translateText(option.label)}</strong>
{option.description && <small>{translateText(option.description)}</small>}
<strong>{translateReactNode(option.label, translateText)}</strong>
{option.description && <small>{translateReactNode(option.description, translateText)}</small>}
</span>
</label>
)}

View File

@@ -0,0 +1,106 @@
const FOCUSABLE_SELECTOR = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled]):not([type=\"hidden\"])",
"select:not([disabled])",
"textarea:not([disabled])",
"details > summary:first-of-type",
"iframe",
"object",
"embed",
"[contenteditable]:not([contenteditable=\"false\"])",
"[tabindex]:not([tabindex=\"-1\"])"
].join(",");
export type DialogKeyboardEvent = Pick<KeyboardEvent, "key" | "shiftKey" | "preventDefault">;
function elementIsAvailable(element: HTMLElement): boolean {
if (element.tabIndex < 0 || element.hasAttribute("disabled")) return false;
if (element.closest('[hidden], [inert], [aria-hidden="true"]')) return false;
if (typeof window === "undefined") return true;
try {
const style = window.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
} catch {
return true;
}
}
function focusWithoutScrolling(element: HTMLElement): void {
try {
element.focus({ preventScroll: true });
} catch {
element.focus();
}
}
export function dialogFocusableElements(panel: HTMLElement): HTMLElement[] {
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(elementIsAvailable);
}
export function focusDialogOnOpen(panel: HTMLElement, activeElement: Element | null): HTMLElement {
if (activeElement && panel.contains(activeElement)) return activeElement as HTMLElement;
const focusable = dialogFocusableElements(panel);
const requested = panel.querySelector<HTMLElement>("[autofocus]");
const target = requested && focusable.includes(requested) ? requested : (focusable[0] ?? panel);
focusWithoutScrolling(target);
return target;
}
export function trapDialogFocus(
panel: HTMLElement,
event: DialogKeyboardEvent,
activeElement: Element | null
): boolean {
if (event.key !== "Tab") return false;
const focusable = dialogFocusableElements(panel);
let target: HTMLElement | null = null;
if (focusable.length === 0) {
target = panel;
} else {
const activeIndex = activeElement ? focusable.indexOf(activeElement as HTMLElement) : -1;
if (activeIndex < 0) {
target = event.shiftKey ? focusable[focusable.length - 1] : focusable[0];
} else if (event.shiftKey && activeIndex === 0) {
target = focusable[focusable.length - 1];
} else if (!event.shiftKey && activeIndex === focusable.length - 1) {
target = focusable[0];
}
}
if (!target) return false;
event.preventDefault();
focusWithoutScrolling(target);
return true;
}
export function handleDialogKeyDown(
panel: HTMLElement,
event: DialogKeyboardEvent,
activeElement: Element | null,
canClose: boolean,
onClose?: () => void
): boolean {
if (event.key === "Tab") return trapDialogFocus(panel, event, activeElement);
if (event.key !== "Escape" || !canClose) return false;
onClose?.();
return true;
}
export function restoreDialogFocus(element: HTMLElement | null): boolean {
if (!element || element.isConnected === false) return false;
focusWithoutScrolling(element);
return true;
}
export function shouldCloseDialogOnBackdrop(
target: EventTarget | null,
currentTarget: EventTarget | null,
closeOnBackdrop: boolean,
canClose: boolean
): boolean {
return closeOnBackdrop && canClose && target === currentTarget;
}

View File

@@ -0,0 +1,158 @@
import {
focusDialogOnOpen,
handleDialogKeyDown,
restoreDialogFocus,
type DialogKeyboardEvent
} from "./dialogInteractions";
export type DialogStackId = symbol;
export type DialogStackRegistration = {
id: DialogStackId;
activationOrder: number;
panel: HTMLElement;
restoreFocus: HTMLElement | null;
canClose: () => boolean;
onClose?: () => void;
};
type DialogStackEntry = DialogStackRegistration & {
restoreTargets: HTMLElement[];
};
const openDialogs: DialogStackEntry[] = [];
let activationSequence = 0;
let listeningForKeyDown = false;
function topDialog(): DialogStackEntry | null {
return openDialogs[openDialogs.length - 1] ?? null;
}
function currentActiveElement(): Element | null {
return typeof document === "undefined" ? null : document.activeElement;
}
function appendUniqueTargets(targets: HTMLElement[], additions: Array<HTMLElement | null>): HTMLElement[] {
const next = [...targets];
additions.forEach((target) => {
if (target && !next.includes(target)) next.push(target);
});
return next;
}
function exposeAsTopmost(panel: HTMLElement): void {
panel.removeAttribute("inert");
panel.removeAttribute("aria-hidden");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("data-dialog-stack-state", "topmost");
}
function hideAsUnderlying(panel: HTMLElement): void {
panel.setAttribute("inert", "");
panel.setAttribute("aria-hidden", "true");
panel.removeAttribute("aria-modal");
panel.setAttribute("data-dialog-stack-state", "underlying");
}
function syncStackAccessibility(): void {
const topIndex = openDialogs.length - 1;
openDialogs.forEach((entry, index) => {
if (index === topIndex) exposeAsTopmost(entry.panel);
else hideAsUnderlying(entry.panel);
});
}
function handleWindowKeyDown(event: KeyboardEvent): void {
handleTopDialogKeyDown(event, currentActiveElement());
}
function syncWindowKeyDownListener(): void {
if (typeof window === "undefined") return;
if (openDialogs.length > 0 && !listeningForKeyDown) {
window.addEventListener("keydown", handleWindowKeyDown);
listeningForKeyDown = true;
} else if (openDialogs.length === 0 && listeningForKeyDown) {
window.removeEventListener("keydown", handleWindowKeyDown);
listeningForKeyDown = false;
}
}
function focusAfterTopmostCloses(entry: DialogStackEntry): void {
const nextTop = topDialog();
if (nextTop) {
const targetInsideNextDialog = entry.restoreTargets.find(
(target) => target.isConnected !== false && nextTop.panel.contains(target)
);
if (targetInsideNextDialog && restoreDialogFocus(targetInsideNextDialog)) return;
focusDialogOnOpen(nextTop.panel, currentActiveElement());
return;
}
entry.restoreTargets.some((target) => restoreDialogFocus(target));
}
function unregisterDialog(id: DialogStackId): void {
const index = openDialogs.findIndex((entry) => entry.id === id);
if (index < 0) return;
const wasTopmost = index === openDialogs.length - 1;
const [entry] = openDialogs.splice(index, 1);
exposeAsTopmost(entry.panel);
syncStackAccessibility();
syncWindowKeyDownListener();
if (wasTopmost) focusAfterTopmostCloses(entry);
}
export function nextDialogActivationOrder(): number {
activationSequence += 1;
return activationSequence;
}
export function registerDialog(
registration: DialogStackRegistration,
activeElement: Element | null
): () => void {
const existingIndex = openDialogs.findIndex((entry) => entry.id === registration.id);
if (existingIndex >= 0) openDialogs.splice(existingIndex, 1);
const entry: DialogStackEntry = {
...registration,
restoreTargets: appendUniqueTargets([], [registration.restoreFocus])
};
openDialogs.push(entry);
openDialogs.sort((left, right) => left.activationOrder - right.activationOrder);
const entryIndex = openDialogs.indexOf(entry);
const entryBelow = openDialogs[entryIndex - 1];
if (entryBelow) entry.restoreTargets = appendUniqueTargets(entry.restoreTargets, entryBelow.restoreTargets);
for (let index = entryIndex + 1; index < openDialogs.length; index += 1) {
openDialogs[index].restoreTargets = appendUniqueTargets(openDialogs[index].restoreTargets, entry.restoreTargets);
}
if (topDialog() === entry) focusDialogOnOpen(entry.panel, activeElement);
syncStackAccessibility();
syncWindowKeyDownListener();
return () => unregisterDialog(registration.id);
}
export function handleTopDialogKeyDown(
event: DialogKeyboardEvent,
activeElement: Element | null
): boolean {
const entry = topDialog();
if (!entry) return false;
return handleDialogKeyDown(entry.panel, event, activeElement, entry.canClose(), entry.onClose);
}
export function dialogIsTopmost(id: DialogStackId): boolean {
const entry = topDialog();
return !entry || entry.id === id;
}
export function resetDialogStackForTests(): void {
openDialogs.splice(0).forEach((entry) => exposeAsTopmost(entry.panel));
activationSequence = 0;
syncWindowKeyDownListener();
}

View File

@@ -0,0 +1,517 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FormEvent,
type FocusEvent,
type KeyboardEvent,
type ReactNode
} from "react";
import { Plus, Search, Trash2, UserPlus } from "lucide-react";
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
import { isValidEmailAddress } from "../../utils/emailAddresses";
import Button from "../Button";
import FieldLabel from "../help/FieldLabel";
import DataGrid, { DataGridEmptyAction, type DataGridColumn } from "../table/DataGrid";
import TableActionGroup from "../table/TableActionGroup";
import {
dedupePeoplePickerItems,
externalPeoplePickerItem,
peoplePickerDedupeKey,
selectionFromPeoplePickerCandidate,
type PeoplePickerItem,
type PeoplePickerSearch,
type PeoplePickerSearchCandidate,
type PeoplePickerSearchGroup
} from "./peoplePickerTypes";
export type PeoplePickerProps = {
id?: string;
label?: ReactNode;
help?: ReactNode;
selectedLabel?: ReactNode;
value: readonly PeoplePickerItem[];
onChange: (value: PeoplePickerItem[]) => void;
search: PeoplePickerSearch;
disabled?: boolean;
required?: boolean;
allowManualExternal?: boolean;
manualEmailRequired?: boolean;
minQueryLength?: number;
searchLimit?: number;
debounceMs?: number;
className?: string;
};
type IndexedCandidate = {
candidate: PeoplePickerSearchCandidate;
group: PeoplePickerSearchGroup;
index: number;
};
const I18N = {
people: "i18n:govoplan-core.people.b37554f6",
searchPeople: "i18n:govoplan-core.search_people_and_contacts.8e028a78",
searchPlaceholder: "i18n:govoplan-core.search_by_name_or_email_address.6283d84d",
searching: "i18n:govoplan-core.searching.1a6a5ba8",
noMatches: "i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688",
addExternal: "i18n:govoplan-core.add_external_person.b19c7abc",
externalPerson: "i18n:govoplan-core.external_person.35115d8e",
selectedPeople: "i18n:govoplan-core.selected_people.49a953a1",
noSelection: "i18n:govoplan-core.no_people_selected_yet.9383cc74",
source: "i18n:govoplan-core.source.6da13add",
addPerson: "i18n:govoplan-core.add_person.3b10561b",
removePerson: "i18n:govoplan-core.remove_person.2ea46758",
alreadySelected: "i18n:govoplan-core.this_person_is_already_selected.d337154c",
enterName: "i18n:govoplan-core.enter_a_name.86c810d9",
searchFailed: "i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7",
addSomeone: "i18n:govoplan-core.add_someone_to_the_selection.c617dd69"
} as const;
function translatedNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
return typeof node === "string" ? translateText(node) : node;
}
function flattenGroups(groups: readonly PeoplePickerSearchGroup[]): IndexedCandidate[] {
const flattened: IndexedCandidate[] = [];
const seen = new Set<string>();
for (const group of groups) {
for (const candidate of group.candidates) {
if (!candidate.selection_key || seen.has(candidate.selection_key)) continue;
seen.add(candidate.selection_key);
flattened.push({ candidate, group, index: flattened.length });
}
}
return flattened;
}
export default function PeoplePicker({
id,
label = I18N.people,
help,
selectedLabel = I18N.selectedPeople,
value,
onChange,
search,
disabled = false,
required = false,
allowManualExternal = true,
manualEmailRequired = true,
minQueryLength = 2,
searchLimit = 25,
debounceMs = 250,
className = ""
}: PeoplePickerProps) {
const { translateText } = usePlatformLanguage();
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
const pickerId = id || `people-picker-${generatedId}`;
const labelId = `${pickerId}-label`;
const inputId = `${pickerId}-search`;
const listboxId = `${pickerId}-results`;
const statusId = `${pickerId}-status`;
const searchInputRef = useRef<HTMLInputElement | null>(null);
const manualNameRef = useRef<HTMLInputElement | null>(null);
const manualEmailRef = useRef<HTMLInputElement | null>(null);
const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]);
const selectedKeys = useMemo(
() => new Set(selectedItems.map(peoplePickerDedupeKey)),
[selectedItems]
);
const [query, setQuery] = useState("");
const [groups, setGroups] = useState<readonly PeoplePickerSearchGroup[]>([]);
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [searchError, setSearchError] = useState("");
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [manualOpen, setManualOpen] = useState(false);
const [manualName, setManualName] = useState("");
const [manualEmail, setManualEmail] = useState("");
const [manualError, setManualError] = useState("");
const normalizedMinQueryLength = Math.max(0, Math.floor(minQueryLength));
const normalizedSearchLimit = Math.max(1, Math.min(Math.floor(searchLimit), 100));
const flattened = useMemo(() => flattenGroups(groups), [groups]);
function candidateUnavailable(candidate: PeoplePickerSearchCandidate): boolean {
return Boolean(candidate.disabled || selectedKeys.has(peoplePickerDedupeKey(candidate)));
}
useEffect(() => {
const normalizedQuery = query.trim();
if (disabled || normalizedQuery.length < normalizedMinQueryLength) {
setGroups([]);
setLoading(false);
setHasSearched(false);
setSearchError("");
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setSearchError("");
void search(normalizedQuery, { limit: normalizedSearchLimit, signal: controller.signal })
.then((nextGroups) => {
if (controller.signal.aborted) return;
setGroups(nextGroups);
setHasSearched(true);
})
.catch((error: unknown) => {
if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
setGroups([]);
setHasSearched(true);
setSearchError(I18N.searchFailed);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, Math.max(0, Math.floor(debounceMs)));
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [debounceMs, disabled, normalizedMinQueryLength, normalizedSearchLimit, query, search]);
useEffect(() => {
const firstAvailable = flattened.find((item) => !candidateUnavailable(item.candidate));
setActiveIndex(firstAvailable?.index ?? -1);
}, [flattened, selectedKeys]);
useEffect(() => {
if (manualOpen) manualNameRef.current?.focus();
}, [manualOpen]);
function focusSearch() {
if (disabled) return;
setOpen(true);
searchInputRef.current?.focus();
}
function addCandidate(candidate: PeoplePickerSearchCandidate) {
if (disabled || candidateUnavailable(candidate)) return;
onChange(dedupePeoplePickerItems([...selectedItems, selectionFromPeoplePickerCandidate(candidate)]));
setQuery("");
setGroups([]);
setHasSearched(false);
setOpen(false);
window.requestAnimationFrame(() => searchInputRef.current?.focus());
}
function removeItem(item: PeoplePickerItem) {
if (disabled) return;
const removedKey = peoplePickerDedupeKey(item);
onChange(selectedItems.filter((candidate) => peoplePickerDedupeKey(candidate) !== removedKey));
}
function moveActive(delta: 1 | -1) {
const available = flattened.filter((item) => !candidateUnavailable(item.candidate));
if (available.length === 0) {
setActiveIndex(-1);
return;
}
const currentPosition = available.findIndex((item) => item.index === activeIndex);
const nextPosition = currentPosition < 0
? (delta > 0 ? 0 : available.length - 1)
: (currentPosition + delta + available.length) % available.length;
setActiveIndex(available[nextPosition].index);
}
function onSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
moveActive(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
moveActive(-1);
return;
}
if (event.key === "Enter" && activeIndex >= 0 && open) {
event.preventDefault();
const active = flattened.find((item) => item.index === activeIndex);
if (active) addCandidate(active.candidate);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
setActiveIndex(-1);
}
}
function addManualPerson(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = manualName.trim();
const email = manualEmail.trim().toLowerCase();
if (!name) {
setManualError(I18N.enterName);
manualNameRef.current?.focus();
return;
}
if ((manualEmailRequired && !email) || (email && !isValidEmailAddress(email))) {
setManualError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
manualEmailRef.current?.focus();
return;
}
const item = externalPeoplePickerItem(name, email || null);
if (selectedKeys.has(peoplePickerDedupeKey(item))) {
setManualError(I18N.alreadySelected);
return;
}
onChange(dedupePeoplePickerItems([...selectedItems, item]));
setManualName("");
setManualEmail("");
setManualError("");
setManualOpen(false);
}
function closeResultsOnFocusLeave(event: FocusEvent<HTMLElement>) {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
setOpen(false);
}
const statusMessage = loading
? I18N.searching
: searchError
? searchError
: query.trim() && query.trim().length < normalizedMinQueryLength
? i18nMessage("i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad", {
value0: normalizedMinQueryLength
})
: hasSearched && flattened.length === 0
? I18N.noMatches
: "";
const resultsVisible = open && !disabled && query.trim().length >= normalizedMinQueryLength;
const columns: DataGridColumn<PeoplePickerItem>[] = [
{
id: "name",
header: "i18n:govoplan-core.name.709a2322",
minWidth: 180,
resizable: true,
render: (item) => (
<div className="people-picker-selected-person">
<strong>{item.display_name}</strong>
{item.description && <small>{item.description}</small>}
</div>
)
},
{
id: "email",
header: "i18n:govoplan-core.email_address.c94d3175",
minWidth: 220,
resizable: true,
value: (item) => item.email || "—"
},
{
id: "source",
header: I18N.source,
minWidth: 130,
resizable: true,
value: (item) => translateText(item.source_label || kindLabel(item.kind))
},
{
id: "actions",
header: "i18n:govoplan-core.actions.c3cd636a",
width: 104,
sticky: "end",
align: "right",
render: (item) => (
<TableActionGroup
minimumSlots={2}
actions={[
{
id: "add",
label: I18N.addPerson,
icon: <Plus size={16} aria-hidden="true" />,
disabled,
onClick: focusSearch
},
{
id: "remove",
label: I18N.removePerson,
icon: <Trash2 size={16} aria-hidden="true" />,
variant: "danger",
disabled,
onClick: () => removeItem(item)
}
]}
/>
)
}
];
return (
<section className={`people-picker ${className}`.trim()} aria-labelledby={labelId} onBlur={closeResultsOnFocusLeave}>
<div className="people-picker-search-field">
<label htmlFor={inputId} id={labelId}>
<FieldLabel help={help}>{translatedNode(label, translateText)}</FieldLabel>
</label>
<div className="people-picker-search-control">
<Search size={17} aria-hidden="true" />
<input
ref={searchInputRef}
id={inputId}
type="search"
role="combobox"
value={query}
disabled={disabled}
required={required && selectedItems.length === 0}
placeholder={translateText(I18N.searchPlaceholder)}
aria-label={translateText(I18N.searchPeople)}
aria-autocomplete="list"
aria-expanded={resultsVisible}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `${pickerId}-option-${activeIndex}` : undefined}
aria-describedby={statusId}
aria-busy={loading}
onFocus={() => setOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
setSearchError("");
}}
onKeyDown={onSearchKeyDown}
/>
</div>
{resultsVisible && (
<div id={listboxId} className="people-picker-results" role="listbox" aria-label={translateText(I18N.searchPeople)}>
{groups.map((group) => {
const candidates = flattened.filter((item) => item.group.key === group.key);
if (candidates.length === 0) return null;
const groupLabelId = `${pickerId}-group-${group.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
return (
<div key={group.key} className="people-picker-result-group" role="group" aria-labelledby={groupLabelId}>
<div id={groupLabelId} className="people-picker-result-group-label">{translateText(group.label)}</div>
{candidates.map(({ candidate, index }) => {
const selected = selectedKeys.has(peoplePickerDedupeKey(candidate));
const unavailable = candidateUnavailable(candidate);
const disabledReason = selected ? I18N.alreadySelected : candidate.disabled_reason;
return (
<button
id={`${pickerId}-option-${index}`}
key={candidate.selection_key}
type="button"
role="option"
className={`people-picker-result ${activeIndex === index ? "is-active" : ""}`.trim()}
aria-selected={selected}
aria-disabled={unavailable}
disabled={unavailable}
title={disabledReason ? translateText(disabledReason) : undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => !unavailable && setActiveIndex(index)}
onFocus={() => !unavailable && setActiveIndex(index)}
onClick={() => addCandidate(candidate)}
>
<span className="people-picker-result-main">
<strong>{candidate.display_name}</strong>
{candidate.email && <small>{candidate.email}</small>}
</span>
<span className="people-picker-result-context">
{candidate.description && <small>{candidate.description}</small>}
<small>{translateText(candidate.source_label || group.label)}</small>
</span>
</button>
);
})}
</div>
);
})}
</div>
)}
<div id={statusId} className={`people-picker-status ${searchError ? "danger-text" : ""}`.trim()} role={searchError ? "alert" : "status"} aria-live="polite">
{statusMessage && translateText(statusMessage)}
</div>
</div>
{allowManualExternal && (
<div className="people-picker-manual">
<Button
type="button"
variant="secondary"
disabled={disabled}
aria-expanded={manualOpen}
onClick={() => {
setManualOpen((current) => !current);
setManualError("");
}}
>
<UserPlus size={16} aria-hidden="true" />
{translateText(I18N.addExternal)}
</Button>
{manualOpen && (
<form className="people-picker-manual-form" onSubmit={addManualPerson}>
<label>
<FieldLabel>{translateText("i18n:govoplan-core.name.709a2322")}</FieldLabel>
<input
ref={manualNameRef}
value={manualName}
disabled={disabled}
required
autoComplete="name"
onChange={(event) => {
setManualName(event.target.value);
setManualError("");
}}
/>
</label>
<label>
<FieldLabel>{translateText("i18n:govoplan-core.email_address.c94d3175")}</FieldLabel>
<input
ref={manualEmailRef}
type="email"
inputMode="email"
value={manualEmail}
disabled={disabled}
required={manualEmailRequired}
autoComplete="email"
onChange={(event) => {
setManualEmail(event.target.value);
setManualError("");
}}
/>
</label>
<div className="people-picker-manual-actions">
<Button type="button" disabled={disabled} onClick={() => setManualOpen(false)}>
{translateText("i18n:govoplan-core.cancel.77dfd213")}
</Button>
<Button type="submit" variant="primary" disabled={disabled}>
{translateText(I18N.addPerson)}
</Button>
</div>
{manualError && <p className="form-help danger-text" role="alert">{translateText(manualError)}</p>}
</form>
)}
</div>
)}
<div className="people-picker-selection" aria-labelledby={`${pickerId}-selection-label`}>
<span id={`${pickerId}-selection-label`}><FieldLabel>{translatedNode(selectedLabel, translateText)}</FieldLabel></span>
<DataGrid
id={`${pickerId}-selection`}
rows={selectedItems}
columns={columns}
getRowKey={peoplePickerDedupeKey}
emptyText={I18N.noSelection}
emptyActionColumnId="actions"
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={focusSearch} label={I18N.addSomeone} />}
initialFit="container"
/>
</div>
</section>
);
}
function kindLabel(kind: string): string {
if (kind === "account") return "i18n:govoplan-core.accounts.36bae316";
if (kind === "contact") return "i18n:govoplan-core.contacts.b0dd615c";
if (kind === "external") return I18N.externalPerson;
return kind;
}

View File

@@ -0,0 +1,78 @@
export type PeoplePickerItemKind = "account" | "contact" | "external" | "function" | (string & {});
export type PeoplePickerItem = {
selection_key: string;
kind: PeoplePickerItemKind;
reference_id?: string | null;
display_name: string;
email?: string | null;
source_module?: string | null;
source_label?: string | null;
source_ref?: string | null;
source_revision?: string | null;
description?: string | null;
provenance?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type PeoplePickerSearchCandidate = PeoplePickerItem & {
disabled?: boolean;
disabled_reason?: string | null;
};
export type PeoplePickerSearchGroup = {
key: string;
label: string;
candidates: readonly PeoplePickerSearchCandidate[];
};
export type PeoplePickerSearchOptions = {
limit: number;
signal: AbortSignal;
};
export type PeoplePickerSearch = (
query: string,
options: PeoplePickerSearchOptions
) => Promise<readonly PeoplePickerSearchGroup[]>;
export function peoplePickerDedupeKey(item: Pick<PeoplePickerItem, "selection_key" | "email">): string {
const email = item.email?.trim().toLowerCase();
return email ? `email:${email}` : `selection:${item.selection_key.trim().toLowerCase()}`;
}
export function dedupePeoplePickerItems(items: readonly PeoplePickerItem[]): PeoplePickerItem[] {
const seen = new Set<string>();
const result: PeoplePickerItem[] = [];
for (const item of items) {
const key = peoplePickerDedupeKey(item);
if (!item.selection_key.trim() || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export function externalPeoplePickerItem(displayName: string, email?: string | null): PeoplePickerItem {
const normalizedName = displayName.trim();
const normalizedEmail = email?.trim().toLowerCase() || null;
const identityPart = normalizedEmail || normalizedName.toLowerCase();
return {
selection_key: `external:${identityPart}`,
kind: "external",
reference_id: null,
display_name: normalizedName,
email: normalizedEmail,
source_module: null,
source_label: "i18n:govoplan-core.external_person.35115d8e",
source_ref: null,
source_revision: null,
provenance: {},
metadata: { manual: true }
};
}
export function selectionFromPeoplePickerCandidate(candidate: PeoplePickerSearchCandidate): PeoplePickerItem {
const { disabled: _disabled, disabled_reason: _disabledReason, ...selection } = candidate;
return selection;
}

View File

@@ -2,7 +2,7 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
import StatusBadge from "../StatusBadge";
import Button from "../Button";
import TableActionGroup from "./TableActionGroup";
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
export type DataGridSortDirection = "asc" | "desc";
@@ -30,18 +30,36 @@ export type DataGridQueryState = {
filters: Record<string, string>;
};
export type DataGridPagination = {
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
mode?: "client" | "server";
type DataGridPaginationBase = {
page: number;
pageSize: number;
totalRows?: number;
pageSizeOptions?: number[];
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
disabled?: boolean;
};
export type DataGridClientPagination = DataGridPaginationBase & {
/**
* Client mode filters and sorts before slicing. `rows` must therefore contain
* the complete logical result set, never an already paginated backend page.
*/
mode?: "client";
totalRows?: never;
};
export type DataGridServerPagination = DataGridPaginationBase & {
/**
* Server mode treats `rows` as the current backend page. The query callback
* must apply every emitted sort/filter to the backend before pagination.
*/
mode: "server";
/** Total rows after the backend has applied the current filters. */
totalRows: number;
};
export type DataGridPagination = DataGridClientPagination | DataGridServerPagination;
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
export type DataGridColumn<T> = {
@@ -78,7 +96,7 @@ type DataGridState = {
bufferWidth?: number;
};
type DataGridProps<T> = {
type DataGridBaseProps<T> = {
id: string;
rows: T[];
columns: DataGridColumn<T>[];
@@ -98,14 +116,29 @@ type DataGridProps<T> = {
storageKey?: string;
initialFilters?: Record<string, string | string[]>;
initialSort?: {columnId: string;direction: DataGridSortDirection;};
pagination?: DataGridPagination;
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
onQueryChange?: (query: DataGridQueryState) => void;
/**
* Synchronize a query selected outside the grid, for example by a summary
* count shortcut. Server grids should feed `onQueryChange` back into this
* value so the header controls and backend query remain in agreement.
*/
query?: DataGridQueryState;
};
/**
* Query ownership is deliberately explicit: client grids receive all rows and
* DataGrid queries them locally; server grids receive one page and must handle
* the complete query through `onQueryChange`.
*/
export type DataGridProps<T> = DataGridBaseProps<T> & (
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
);
export type DataGridRowActionsProps = {
disabled?: boolean;
removeDisabled?: boolean;
/** Set to false only when row ordering is not part of this table's action set. */
reorderable?: boolean;
onAddBelow: () => void;
onRemove: () => void;
onMoveUp?: () => void;
@@ -177,6 +210,7 @@ export default function DataGrid<T>({
storageKey,
initialFilters = {},
initialSort,
query,
pagination,
onQueryChange
}: DataGridProps<T>) {
@@ -203,7 +237,15 @@ export default function DataGrid<T>({
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
const [state, setState] = useState<DataGridState>(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort));
const externalQueryKey = query ? JSON.stringify(query) : "";
const normalizedExternalQuery = useMemo(
() => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined,
[externalQueryKey]
);
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
normalizedExternalQuery
));
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
@@ -211,14 +253,21 @@ export default function DataGrid<T>({
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
const serverQueryMode = pagination?.mode === "server";
const onQueryChangeRef = useRef(onQueryChange);
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
const lastLayoutSignatureRef = useRef<string | null>(null);
const lastContainerWidthRef = useRef<number | undefined>(undefined);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
const serverQueryMode = pagination?.mode === "server";
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
useEffect(() => {
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
if (!serverQueryMode) lastServerQueryRef.current = null;
}, [pagination, serverQueryMode]);
useEffect(() => {
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
}, [columns, effectiveResizeBehavior]);
@@ -231,6 +280,10 @@ export default function DataGrid<T>({
setState((current) => mergeInitialSort(current, normalizedInitialSort));
}, [normalizedInitialSort]);
useEffect(() => {
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
}, [normalizedExternalQuery]);
useEffect(() => {
try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
@@ -458,10 +511,17 @@ export default function DataGrid<T>({
useEffect(() => {
if (!serverQueryMode) return;
onQueryChangeRef.current?.({
const query = {
sort: state.sort ?? null,
filters: { ...(state.filters ?? {}) }
});
};
const previousQuery = lastServerQueryRef.current;
lastServerQueryRef.current = query;
const serverPagination = serverPaginationRef.current;
if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) {
serverPagination.onPageChange(1);
}
onQueryChangeRef.current?.(query);
}, [serverQueryMode, state.sort, state.filters]);
const filterTypes = useMemo(() => {
@@ -793,13 +853,28 @@ export default function DataGrid<T>({
}
function DataGridPaginationBar({
export type DataGridPaginationBarProps = {
page: number;
pageSize: number;
totalRows: number;
pageCount?: number;
pageSizeOptions?: number[];
disabled?: boolean;
className?: string;
ariaLabel?: string;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
};
export function DataGridPaginationBar({
page,
pageSize,
totalRows,
pageCount,
pageSizeOptions,
pageCount = Math.max(1, Math.ceil(totalRows / pageSize)),
pageSizeOptions = [10, 25, 50, 100],
disabled = false,
className = "",
ariaLabel = "i18n:govoplan-core.table_pagination.3665bd76",
onPageChange,
onPageSizeChange
@@ -811,13 +886,13 @@ function DataGridPaginationBar({
}: {page: number;pageSize: number;totalRows: number;pageCount: number;pageSizeOptions: number[];disabled?: boolean;onPageChange: (page: number) => void;onPageSizeChange?: (pageSize: number) => void;}) {
}: DataGridPaginationBarProps) {
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(totalRows, page * pageSize);
const options = [...new Set([...pageSizeOptions, pageSize])].filter((value) => value > 0).sort((a, b) => a - b);
const { translateText } = usePlatformLanguage();
return (
<div className="data-grid-pagination" aria-label={translateText("i18n:govoplan-core.table_pagination.3665bd76")}>
<div className={`data-grid-pagination ${className}`.trim()} aria-label={translateText(ariaLabel)}>
<div className="data-grid-pagination-summary">{first}{last} {translateText("i18n:govoplan-core.of")} {totalRows}</div>
<label className="data-grid-page-size">
<span>{translateText("i18n:govoplan-core.rows_per_page.af2f9c1b")}</span>
@@ -843,6 +918,7 @@ function DataGridPaginationBar({
export function DataGridRowActions({
disabled = false,
removeDisabled = false,
reorderable = true,
onAddBelow,
onRemove,
onMoveUp,
@@ -852,86 +928,68 @@ export function DataGridRowActions({
moveUpLabel = "i18n:govoplan-core.move_row_up.b4715a19",
moveDownLabel = "i18n:govoplan-core.move_row_down.8e2b5957"
}: DataGridRowActionsProps) {
const { translateText } = usePlatformLanguage();
const translatedAddLabel = translateText(addLabel);
const translatedRemoveLabel = translateText(removeLabel);
const translatedMoveUpLabel = translateText(moveUpLabel);
const translatedMoveDownLabel = translateText(moveDownLabel);
return (
<div className="data-grid-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={disabled}
onClick={onAddBelow}>
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={disabled || !onMoveUp}
onClick={() => onMoveUp?.()}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={disabled || !onMoveDown}
onClick={() => onMoveDown?.()}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="danger"
className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel}
title={translatedRemoveLabel}
disabled={disabled || removeDisabled}
onClick={onRemove}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</div>);
<TableActionGroup
className="data-grid-row-actions"
actions={[
{
id: "add",
label: addLabel,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled,
onClick: onAddBelow
},
reorderable && {
id: "move-up",
label: moveUpLabel,
icon: <ArrowUp size={16} aria-hidden="true" />,
disabled: disabled || !onMoveUp,
onClick: () => onMoveUp?.()
},
reorderable && {
id: "move-down",
label: moveDownLabel,
icon: <ArrowDown size={16} aria-hidden="true" />,
disabled: disabled || !onMoveDown,
onClick: () => onMoveDown?.()
},
{
id: "remove",
label: removeLabel,
icon: <Trash2 size={16} aria-hidden="true" />,
variant: "danger",
disabled: disabled || removeDisabled,
onClick: onRemove
}
]}
/>);
}
export function DataGridEmptyAction({
disabled = false,
reorderable = true,
onAdd,
label = "i18n:govoplan-core.add_first_row.c82c15f2"
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
const { translateText } = usePlatformLanguage();
const translatedLabel = translateText(label);
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
return (
<div className="data-grid-row-actions data-grid-empty-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedLabel}
title={translatedLabel}
disabled={disabled}
onClick={onAdd}>
<Plus size={16} aria-hidden="true" />
</Button>
</div>);
<TableActionGroup
className="data-grid-row-actions data-grid-empty-row-actions"
minimumSlots={reorderable ? 4 : 2}
actions={[{
id: "add",
label,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled,
onClick: onAdd
}]}
/>);
}
@@ -1202,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
return { ...state, sort: initialSort };
}
function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState {
if (!query) return state;
const current = {
sort: state.sort ?? null,
filters: state.filters ?? {}
};
if (dataGridQueriesEqual(current, query)) return state;
return {
...state,
sort: query.sort ? { ...query.sort } : null,
filters: { ...query.filters }
};
}
function formatListFilter(values: string[]): string {
return `list:${JSON.stringify([...new Set(values)])}`;
}
@@ -1668,6 +1740,16 @@ function compareValues(a: unknown, b: unknown): number {
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
}
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function stringifyCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);

View File

@@ -0,0 +1,100 @@
import type { MouseEvent, ReactNode } from "react";
import Button from "../Button";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
export type TableActionDefinition = {
id: string;
label: string;
icon: ReactNode;
onClick: () => void;
/**
* @deprecated Use `disabled` for a row-level unavailable state and omit the
* action from the array only when it is not part of this table's action set.
* A false value is retained as a disabled, visible action for compatibility.
*/
applicable?: boolean;
disabled?: boolean;
disabledReason?: ReactNode;
variant?: "primary" | "secondary" | "ghost" | "danger";
};
export type TableActionGroupProps = {
actions: readonly (TableActionDefinition | false | null | undefined)[];
label?: string;
className?: string;
/** Reserve trailing action slots so controls keep the same column position. */
minimumSlots?: number;
};
function isDefinedAction(
action: TableActionDefinition | false | null | undefined
): action is TableActionDefinition {
return Boolean(action);
}
export function runTableAction(
event: Pick<MouseEvent<HTMLButtonElement>, "stopPropagation">,
onClick: () => void
) {
event.stopPropagation();
onClick();
}
/**
* The standard row-level action surface for tables and DataGrids.
*
* Callers define the action set for the whole table. An action that cannot be
* used for one row stays visible and disabled; an action that is structurally
* irrelevant to the table is omitted from the array. Labels remain available
* to assistive technology and as native tooltips while the visible controls
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
* rows, keeping their first action aligned with ordinary rows.
*/
export default function TableActionGroup({
actions,
label = "i18n:govoplan-core.actions.c3cd636a",
className = "",
minimumSlots = 0
}: TableActionGroupProps) {
const { translateText } = usePlatformLanguage();
const definedActions = actions.filter(isDefinedAction);
if (definedActions.length === 0) return null;
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
return (
<div
className={`table-action-group${className ? ` ${className}` : ""}`}
role="group"
aria-label={translateText(label)}
>
{definedActions.map((action) => {
const translatedLabel = translateText(action.label);
return (
<Button
key={action.id}
type="button"
variant={action.variant ?? "secondary"}
className="table-action-button"
aria-label={translatedLabel}
title={translatedLabel}
disabled={action.disabled || action.applicable === false}
disabledReason={action.disabledReason}
onClick={(event) => runTableAction(event, action.onClick)}
>
<span className="table-action-icon" aria-hidden="true">
{action.icon}
</span>
</Button>
);
})}
{Array.from({ length: placeholderCount }, (_unused, index) => (
<span
key={`reserved-action-slot-${index}`}
className="table-action-placeholder"
aria-hidden="true"
/>
))}
</div>
);
}

View File

@@ -92,7 +92,15 @@ export default function SettingsPage({
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
"mail_servers:read",
"mail_servers:write",
"mail_servers:manage_credentials",
"mail:profile:write_own",
"mail:secret:manage_own",
"admin:policies:read",
"admin:policies:write"
]);
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const contributedSections = useMemo(
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
@@ -319,8 +327,8 @@ export default function SettingsPage({
scopeId={auth.user.id}
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
canWriteProfiles={hasScope(auth, "mail_servers:write")}
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useRef } from "react";
export default function useOutsideDismiss<TElement extends HTMLElement = HTMLDivElement>(
active: boolean,
onDismiss: () => void,
) {
const rootRef = useRef<TElement | null>(null);
useEffect(() => {
if (!active) return;
function handlePointerDown(event: PointerEvent) {
if (!rootRef.current || rootRef.current.contains(event.target as Node)) return;
onDismiss();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onDismiss();
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [active, onDismiss]);
return rootRef;
}

View File

@@ -6,12 +6,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.about.6b21fb79": "About",
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
"i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.",
"i18n:govoplan-core.access_explanation.75ee7f62": "Access explanation",
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
"i18n:govoplan-core.account.f967543b": "ACCOUNT",
"i18n:govoplan-core.action.97c89a4d": "Action",
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.",
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
"i18n:govoplan-core.actions.c3cd636a": "Actions",
@@ -167,6 +169,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule",
"i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed",
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
"i18n:govoplan-core.evidence.8487d192": "Evidence",
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
"i18n:govoplan-core.expand.9869e506": "Expand",
"i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL",
@@ -253,6 +256,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
"i18n:govoplan-core.library_template.c62eb776": "Library template",
"i18n:govoplan-core.loading_access_explanation.04a7c934": "Loading access explanation...",
"i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...",
"i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...",
"i18n:govoplan-core.loading_data.089f19c5": "Loading data…",
@@ -314,6 +318,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Next passes will add functionality here.",
"i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
"i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.",
"i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.",
@@ -323,6 +328,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.no_value_available": "No {value0} available.",
"i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.",
"i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.",
"i18n:govoplan-core.no_source.6dcf9723": "No source",
"i18n:govoplan-core.no_subject.49b20da0": "(no subject)",
"i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
"i18n:govoplan-core.none.6eef6648": "None",
@@ -343,6 +349,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
"i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files",
"i18n:govoplan-core.owner.89ff3122": "Owner",
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
"i18n:govoplan-core.page.fb06270f": "Page",
@@ -351,6 +358,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.password.8be3c943": "Password",
"i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.",
"i18n:govoplan-core.pdf.d613d88c": "PDF",
"i18n:govoplan-core.permission.2f81a22d": "Permission",
"i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.",
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.",
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.",
@@ -366,6 +374,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Please wait while the local session is verified.",
"i18n:govoplan-core.png_image.6715d4b8": "PNG image",
"i18n:govoplan-core.preferences_saved.c8cd3501": "Preferences saved.",
"i18n:govoplan-core.policy.0b779a05": "Policy",
"i18n:govoplan-core.policies.8d611849": "Policies",
"i18n:govoplan-core.port.fe035157": "Port",
"i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation",
@@ -403,10 +412,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.request_failed.9fcda32c": "Request failed",
"i18n:govoplan-core.required.eed6bfb4": "Required",
"i18n:govoplan-core.resize.f52dc753": "Resize",
"i18n:govoplan-core.resource.d1c626a9": "Resource",
"i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.",
"i18n:govoplan-core.retention_policy.962fb418": "Retention policy",
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
"i18n:govoplan-core.review_send.1627617d": "Review & Send",
"i18n:govoplan-core.role.b5b4a5a2": "Role",
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
"i18n:govoplan-core.sa.50cf95ce": "Sa",
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
@@ -428,6 +439,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.server.cb0cb170": "Server",
"i18n:govoplan-core.session_expired.b828190e": "Session expired",
"i18n:govoplan-core.session_state.b7a3d0f4": "Session state",
"i18n:govoplan-core.share.09ca55ca": "Share",
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
"i18n:govoplan-core.settings.c7f73bb5": "Settings",
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
@@ -551,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…",
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
"i18n:govoplan-core.accounts.36bae316": "Accounts",
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
"i18n:govoplan-core.people.b37554f6": "People",
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
"i18n:govoplan-core.external_person.35115d8e": "External person",
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
"i18n:govoplan-core.source.6da13add": "Source",
"i18n:govoplan-core.add_person.3b10561b": "Add person",
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
@@ -563,12 +594,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.about.6b21fb79": "About",
"i18n:govoplan-core.accent_color.e49578ed": "Accent color",
"i18n:govoplan-core.access_is_restricted_sign_in_to_open_available_m.2a47a549": "Access is restricted. Sign in to open available modules and administration.",
"i18n:govoplan-core.access_explanation.75ee7f62": "Zugriffserklärung",
"i18n:govoplan-core.accessible_to_you.266eca4e": "Accessible to you",
"i18n:govoplan-core.account_context.5bb0a918": "Account context",
"i18n:govoplan-core.account_display_name.03ed8fc5": "Account display name",
"i18n:govoplan-core.account_id.c5e0db28": "Account ID",
"i18n:govoplan-core.account_settings.82cf8a5f": "Account settings",
"i18n:govoplan-core.account.f967543b": "ACCOUNT",
"i18n:govoplan-core.action.97c89a4d": "Aktion",
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_m.2e6863d1": "Action or review severity when this rule finds multiple possible matches.",
"i18n:govoplan-core.action_or_review_severity_when_this_rule_finds_n.bc500ee1": "Action or review severity when this rule finds no matching file.",
"i18n:govoplan-core.actions.c3cd636a": "Aktionen",
@@ -724,6 +757,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741": "entry did not export a PlatformWebModule",
"i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89": "entry integrity check failed",
"i18n:govoplan-core.equals.09b6a6dc": "Equals",
"i18n:govoplan-core.evidence.8487d192": "Nachweise",
"i18n:govoplan-core.excel_spreadsheet.7107fea2": "Excel spreadsheet",
"i18n:govoplan-core.expand.9869e506": "Expand",
"i18n:govoplan-core.explicit_api_url.b117b4b8": "Explicit API URL",
@@ -810,6 +844,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
"i18n:govoplan-core.library_template.c62eb776": "Library template",
"i18n:govoplan-core.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
"i18n:govoplan-core.loading_administration_data.643bd894": "Loading administration data...",
"i18n:govoplan-core.loading_campaigns.43ea3afa": "Loading campaigns...",
"i18n:govoplan-core.loading_data.089f19c5": "Loading data…",
@@ -871,6 +906,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Die nächsten Durchläufe ergänzen hier die Funktionalität.",
"i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
"i18n:govoplan-core.no_address_added_yet.809c4247": "No address added yet.",
"i18n:govoplan-core.no_entries_configured.48e7b97c": "No entries configured.",
@@ -880,6 +916,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.no_value_available": "Keine {value0} verfügbar.",
"i18n:govoplan-core.no_readable_body_content.37643a01": "No readable body content.",
"i18n:govoplan-core.no_rows_found.0c1a8d34": "No rows found.",
"i18n:govoplan-core.no_source.6dcf9723": "Keine Quelle",
"i18n:govoplan-core.no_subject.49b20da0": "(no subject)",
"i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8": "No values are configured for this column.",
"i18n:govoplan-core.none.6eef6648": "Keine",
@@ -900,6 +937,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
"i18n:govoplan-core.or_click_to_select_files.91b05dc1": "or click to select files",
"i18n:govoplan-core.owner.89ff3122": "Eigentümer",
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
"i18n:govoplan-core.page.fb06270f": "Page",
@@ -908,6 +946,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.password.8be3c943": "Passwort",
"i18n:govoplan-core.path_or_identifier_for_an_external_recipient_sou.f7da6e8f": "Path or identifier for an external recipient source such as a CSV file.",
"i18n:govoplan-core.pdf.d613d88c": "PDF",
"i18n:govoplan-core.permission.2f81a22d": "Berechtigung",
"i18n:govoplan-core.permit_per_recipient_sender_overrides_when_build.62e46d13": "Permit per-recipient sender overrides when building messages.",
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_bcc_re.46d737dc": "Permit recipient rows to define their own BCC recipients.",
"i18n:govoplan-core.permit_recipient_rows_to_define_their_own_cc_rec.0247b3cd": "Permit recipient rows to define their own CC recipients.",
@@ -923,6 +962,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.please_wait_while_the_local_session_is_verified.1dbd8fd3": "Bitte warten Sie, während die lokale Sitzung geprüft wird.",
"i18n:govoplan-core.png_image.6715d4b8": "PNG image",
"i18n:govoplan-core.preferences_saved.c8cd3501": "Einstellungen gespeichert.",
"i18n:govoplan-core.policy.0b779a05": "Richtlinie",
"i18n:govoplan-core.policies.8d611849": "Richtlinien",
"i18n:govoplan-core.port.fe035157": "Port",
"i18n:govoplan-core.powerpoint_presentation.b32b7115": "PowerPoint presentation",
@@ -960,10 +1000,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.request_failed.9fcda32c": "Request failed",
"i18n:govoplan-core.required.eed6bfb4": "Required",
"i18n:govoplan-core.resize.f52dc753": "Resize",
"i18n:govoplan-core.resource.d1c626a9": "Ressource",
"i18n:govoplan-core.retention_policy_saved.eb577758": "Retention policy saved.",
"i18n:govoplan-core.retention_policy.962fb418": "Retention policy",
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
"i18n:govoplan-core.review_send.1627617d": "Prüfen & Senden",
"i18n:govoplan-core.role.b5b4a5a2": "Rolle",
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
"i18n:govoplan-core.sa.50cf95ce": "Sa",
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
@@ -985,6 +1027,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.server.cb0cb170": "Server",
"i18n:govoplan-core.session_expired.b828190e": "Sitzung abgelaufen",
"i18n:govoplan-core.session_state.b7a3d0f4": "Session state",
"i18n:govoplan-core.share.09ca55ca": "Freigabe",
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
"i18n:govoplan-core.settings.c7f73bb5": "Einstellungen",
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
@@ -1108,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…",
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
"i18n:govoplan-core.people.b37554f6": "Personen",
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
"i18n:govoplan-core.source.6da13add": "Quelle",
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",

View File

@@ -24,6 +24,7 @@ export type {
MockMailboxMessageResponse
} from "./api/mailContracts";
export * from "./api/privacyRetention";
export * from "./api/resourceAccess";
export * from "./platform/modules";
export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents";
@@ -43,10 +44,12 @@ export { default as ActionBlockerHint } from "./components/ActionBlockerHint";
export type { ActionBlockerReason } from "./components/ActionBlockerHint";
export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel";
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
export type { AdminIconButtonProps } from "./components/admin/AdminIconButton";
export { default as AdminPageLayout } from "./components/admin/AdminPageLayout";
export { default as AdminSelectionList } from "./components/admin/AdminSelectionList";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
export { default as Button } from "./components/Button";
export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card";
export { default as ConfirmDialog } from "./components/ConfirmDialog";
export { default as ConnectionTree } from "./components/ConnectionTree";
@@ -70,6 +73,8 @@ export { default as GuidedReviewList } from "./components/GuidedReviewList";
export type { GuidedReviewItem } from "./components/GuidedReviewList";
export { default as HoverTooltip } from "./components/HoverTooltip";
export type { HoverTooltipProps, HoverTooltipTone } from "./components/HoverTooltip";
export { default as IconButton } from "./components/IconButton";
export type { IconButtonProps } from "./components/IconButton";
export { default as LoadingFrame } from "./components/LoadingFrame";
export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree";
@@ -79,6 +84,12 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
export { default as PasswordField } from "./components/PasswordField";
export { default as PeoplePicker } from "./components/people/PeoplePicker";
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
export type { PasswordFieldProps } from "./components/PasswordField";
export { PolicyRow, PolicySection, PolicyTable } from "./components/PolicyTable";
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "./components/PolicyPathHelp";
@@ -89,6 +100,8 @@ export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
export { default as SegmentedControl } from "./components/SegmentedControl";
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
export { default as SelectionList, SelectionListItem } from "./components/SelectionList";
export type { SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
export { default as StatusBadge } from "./components/StatusBadge";
export { default as Stepper } from "./components/Stepper";
export { default as ToggleSwitch } from "./components/ToggleSwitch";
@@ -100,8 +113,10 @@ export { default as MailServerSettingsPanel, MailServerActionResult, MailServerF
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
export { default as TableActionGroup } from "./components/table/TableActionGroup";
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
export { default as LoginModal } from "./features/auth/LoginModal";
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";

View File

@@ -9,20 +9,25 @@ import { usePlatformModules } from "../platform/ModuleContext";
import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { AuthInfo } from "../types";
import { hasAnyScope } from "../utils/permissions";
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
export default function HelpMenu() {
export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [contextOpen, setContextOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useGuardedNavigate();
const helpContext = helpContextForPathname(location.pathname);
const helpContext = helpContextForPathname(location.pathname, location.search);
const modules = usePlatformModules();
const { translateText } = usePlatformLanguage();
const docsAvailable = modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
const docsAvailable = configuredDocsAvailable &&
modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
useEffect(() => {
function openContextHelp(event: KeyboardEvent) {
@@ -53,6 +58,7 @@ export default function HelpMenu() {
function openDocs(type: "user" | "admin") {
setOpen(false);
setContextOpen(false);
if (!docsAvailable) {
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
return;
@@ -87,15 +93,17 @@ export default function HelpMenu() {
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button>
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button>
{adminDocsAvailable &&
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button>
}
<hr />
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a>
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
</div>
}
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
{contextOpen && <ContextHelpModal context={helpContext} onOpenDocs={() => openDocs("user")} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
</div>);
@@ -106,7 +114,7 @@ function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
}
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContext;onOpenDocs: () => void;onClose: () => void;}) {
const { translateText } = usePlatformLanguage();
return (
<Dialog
@@ -123,6 +131,7 @@ function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: (
<div className="help-panel-section">
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText("i18n:govoplan-core.open_user_documentation.084af515")}</Button>
</div>
</Dialog>);

View File

@@ -219,7 +219,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="titlebar-spacer" />
<LanguageMenu />
<HelpMenu />
<HelpMenu auth={auth} />
{auth && notificationsAvailable &&
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>

View File

@@ -1,4 +1,4 @@
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
for (const module of modules) {
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}

View File

@@ -1,10 +1,11 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
import {
hasUiCapability as hasUiCapabilityForModules,
moduleInstalled as moduleInstalledForModules,
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
routeContributionsForModules as routeContributionsForModuleList,
uiCapabilities as uiCapabilitiesForModules,
uiCapability as uiCapabilityForModules } from
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
uiCapabilities: {
...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info)
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
};
}
function filterPublicRoutes(
module: PlatformWebModule,
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
) {
if (!routes?.length) return [];
const allowedPaths = new Set(routes.map((route) => route.path));
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
}
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
return {
id: info.id,
name: info.name,
version: info.version,
dependencies: [],
optional_dependencies: [],
enabled: true,
runtime_ui_capabilities: [],
nav: [],
frontend: {
...info.frontend,
routes: [],
nav: [],
settings_routes: []
}
};
}
export function resolveInstalledPublicWebModules(
platformModules: PlatformPublicModuleInfo[] | null | undefined
): PlatformWebModule[] {
if (!platformModules?.length) return [];
const localById = new Map(localModules.map((module) => [module.id, module]));
return platformModules.flatMap((info) => {
const local = localById.get(info.id);
if (!local) return [];
const resolved = applyServerMetadata(local, publicModuleInfo(info));
return resolved.publicRoutes?.length ? [resolved] : [];
});
}
export async function loadRemotePublicWebModules(
platformModules: PlatformPublicModuleInfo[] | null | undefined,
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
): Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
return loaded.filter((module) => module.publicRoutes?.length);
}
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
if (!platformModules?.length) return localModules;
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
return routeContributionsForModuleList(modules);
}
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
return publicRouteContributionsForModuleList(modules);
}
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
flatMap((capability) => capability.widgets ?? []).

View File

@@ -1,6 +1,6 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-blocked, .status-error, .status-danger, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
.status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }

View File

@@ -117,10 +117,6 @@
font-size: 12px;
line-height: 1.35;
}
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 430px; }
.recipient-editor-table th:last-child,
.recipient-editor-table td:last-child { width: 92px; text-align: right; }
.recipient-field-input,
.recipient-attachments-input {
min-width: 150px;
@@ -957,16 +953,7 @@
align-self: start;
}
.admin-icon-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 36px;
justify-content: end;
gap: 5px;
width: 100%;
}
.admin-icon-button.btn {
.icon-button.btn {
display: inline-grid;
place-items: center;
width: 36px;
@@ -975,7 +962,7 @@
padding: 0;
}
.admin-icon-button.btn svg {
.icon-button.btn svg {
width: 17px;
height: 17px;
}
@@ -985,11 +972,19 @@
gap: 14px;
}
.admin-form-grid > .wide {
grid-column: 1 / -1;
}
.admin-form-grid.two-columns,
.admin-assignment-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-assignment-grid {
display: grid;
gap: 18px;
@@ -1032,8 +1027,9 @@
color: var(--muted);
}
@media (max-width: 850px) {
@media (max-width: 900px) {
.admin-form-grid.two-columns,
.admin-form-grid.three-columns,
.admin-assignment-grid,
.admin-permission-groups {
grid-template-columns: 1fr;
@@ -1520,6 +1516,166 @@
display: none;
}
/* Principal-aware account/contact picker with structured external entry. */
.people-picker {
display: grid;
gap: 16px;
min-width: 0;
}
.people-picker-search-field,
.people-picker-selection,
.people-picker-manual {
display: grid;
gap: 7px;
min-width: 0;
}
.people-picker-search-control {
position: relative;
display: flex;
align-items: center;
}
.people-picker-search-control > svg {
position: absolute;
left: 12px;
z-index: 1;
color: var(--muted);
pointer-events: none;
}
.people-picker-search-control input {
padding-left: 38px;
}
.people-picker-search-control:focus-within > svg {
color: var(--primary);
}
.people-picker-results {
display: grid;
max-height: 360px;
overflow: auto;
border: var(--border-line-dark);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow-popover);
padding: 5px;
}
.people-picker-result-group {
display: grid;
gap: 2px;
}
.people-picker-result-group + .people-picker-result-group {
margin-top: 5px;
border-top: var(--border-line);
padding-top: 5px;
}
.people-picker-result-group-label {
color: var(--text-label);
font-size: 11px;
font-weight: 800;
letter-spacing: .04em;
padding: 5px 9px 3px;
text-transform: uppercase;
}
.people-picker-result {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
align-items: center;
gap: 16px;
width: 100%;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.people-picker-result:hover:not(:disabled),
.people-picker-result.is-active:not(:disabled),
.people-picker-result:focus-visible:not(:disabled) {
background: var(--primary-soft);
box-shadow: var(--primary-inset-ring);
outline: none;
}
.people-picker-result:disabled {
background: var(--control-disabled-bg);
color: var(--control-disabled-text);
cursor: not-allowed;
}
.people-picker-result-main,
.people-picker-result-context,
.people-picker-selected-person {
display: grid;
gap: 2px;
min-width: 0;
}
.people-picker-result-main strong,
.people-picker-result-main small,
.people-picker-result-context small,
.people-picker-selected-person strong,
.people-picker-selected-person small {
overflow-wrap: anywhere;
}
.people-picker-result-main small,
.people-picker-result-context,
.people-picker-selected-person small {
color: var(--muted);
font-size: 12px;
}
.people-picker-result-context {
justify-items: end;
text-align: right;
}
.people-picker-status {
min-height: 18px;
color: var(--muted);
font-size: 12px;
}
.people-picker-manual {
justify-items: start;
}
.people-picker-manual-form {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
gap: 12px;
width: 100%;
border: var(--border-line);
border-radius: 8px;
background: var(--surface-subtle);
padding: 12px;
}
.people-picker-manual-form > label {
display: grid;
gap: 6px;
min-width: 0;
}
.people-picker-manual-actions {
display: flex;
align-items: center;
gap: 8px;
}
.people-picker-manual-form > .form-help {
grid-column: 1 / -1;
margin: 0;
}
.people-picker-selection > .field-label,
.people-picker-selection > span > .field-label {
width: fit-content;
}
@media (max-width: 760px) {
.people-picker-result {
grid-template-columns: 1fr;
gap: 4px;
}
.people-picker-result-context {
justify-items: start;
text-align: left;
}
.people-picker-manual-form {
grid-template-columns: 1fr;
}
}
/* Bootstrap-like switch controls without importing Bootstrap. */
.toggle-switch-row {
display: flex;
@@ -1803,6 +1959,27 @@
white-space: nowrap;
}
.split-field-action {
gap: 0;
}
.split-field-action > input,
.split-field-action > select,
.split-field-action > textarea {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-field-action > button,
.split-field-action > .button,
.split-field-action > .btn {
align-self: stretch;
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
box-shadow: none;
}
.field-input-missing {
border-color: var(--danger-border-deep) !important;
box-shadow: var(--danger-focus-ring) !important;
@@ -2017,6 +2194,214 @@
}
/* Shared explorer/list work surfaces. Modules own their column definitions and domain interactions. */
.file-manager-page.file-manager-fullscreen {
position: relative;
display: grid;
grid-template-rows: 1fr;
height: calc(100vh - 115px);
padding: 0;
overflow: hidden;
}
.file-manager-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 14px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.file-manager-shell {
display: grid;
min-height: 0;
height: 100%;
border: var(--border-line);
border-radius: 0;
overflow: hidden;
background: var(--panel);
}
.file-list-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--panel);
}
.file-list-sticky {
flex: 0 0 auto;
z-index: 2;
border-bottom: var(--border-line);
background: var(--panel-soft);
}
.file-breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-height: 32px;
padding: 10px 14px 6px;
}
.file-breadcrumb,
.file-breadcrumb-segment {
display: inline-flex;
align-items: center;
gap: 5px;
}
.file-breadcrumb {
border: 0;
background: transparent;
color: var(--text-strong);
cursor: pointer;
font-weight: 800;
padding: 4px 6px;
border-radius: 7px;
}
.file-breadcrumb:hover:not(:disabled),
.file-breadcrumb:focus-visible {
background: var(--panel);
outline: none;
}
.file-list-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 8px 14px;
border-top: var(--border-line);
color: var(--muted);
font-size: 12px;
}
.file-list-drop-target {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
transition: background-color .16s ease, box-shadow .16s ease;
}
.file-list-table-head,
.file-list-row {
display: grid;
gap: 12px;
align-items: center;
}
.file-list-table-head {
padding: 10px 14px;
border-top: var(--border-line);
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
}
.file-list-row {
min-height: 58px;
padding: 9px 14px;
border-bottom: var(--border-line);
cursor: default;
user-select: none;
}
.file-list-row:focus-visible {
outline: 2px solid var(--line-dark);
outline-offset: -2px;
}
.file-list-row:hover,
.file-list-row.is-selected {
background: var(--line);
}
.file-list-name-cell {
min-width: 0;
}
.file-list-name {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text);
cursor: default;
font: inherit;
text-align: left;
padding: 0;
}
.file-list-name > span {
display: grid;
min-width: 0;
gap: 2px;
}
.file-list-name strong,
.file-list-name small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-list-name small {
color: var(--muted);
font-size: 12px;
}
.file-row-icon {
color: var(--muted);
flex: 0 0 auto;
}
.file-row-tail {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
color: var(--muted);
font-size: 12px;
}
.file-list-empty {
padding: 36px 20px;
color: var(--muted);
text-align: center;
}
.file-manager-shell.is-loading .file-tree-panel,
.file-manager-shell.is-loading .file-list-panel {
filter: blur(1.5px);
pointer-events: none;
user-select: none;
}
.file-manager-loading-overlay {
position: absolute;
inset: 0;
z-index: 35;
display: grid;
place-items: center;
background: var(--panel-glass);
backdrop-filter: blur(1px);
}
/* Shared explorer tree and message display surfaces. */
.explorer-tree-children {
display: grid;
@@ -2031,6 +2416,10 @@
border-radius: 9px;
}
.explorer-tree-node-wrap.has-actions {
grid-template-columns: 26px minmax(0, 1fr) auto;
}
.explorer-tree-node,
.explorer-tree-toggle {
border: 0;
@@ -2053,6 +2442,11 @@
opacity: .55;
}
.explorer-tree-toggle.explorer-tree-toggle-static {
cursor: default;
pointer-events: none;
}
.explorer-tree-node {
display: flex;
align-items: center;
@@ -2072,6 +2466,44 @@
white-space: nowrap;
}
.explorer-tree-node-content {
display: grid;
gap: 2px;
min-width: 0;
}
.explorer-tree-node-content strong,
.explorer-tree-node-content small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorer-tree-node-content small {
color: var(--muted);
font-size: 12px;
}
.explorer-tree-actions {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding-right: 4px;
}
.explorer-tree-toolbar {
display: flex;
justify-content: flex-start;
margin-bottom: 12px;
}
.explorer-tree-scroll-region {
max-height: 640px;
overflow: auto;
}
.explorer-tree-node-wrap:hover,
.explorer-tree-node-wrap:focus-within,
.explorer-tree-node-wrap.is-active {
@@ -2300,6 +2732,55 @@
opacity: .55;
}
.selection-list {
display: grid;
min-width: 0;
}
:where(.selection-list-item) {
appearance: none;
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
padding: 8px 10px;
text-align: left;
transition: background .16s ease, border-color .16s ease, box-shadow .16s ease;
}
:where(.selection-list-item):hover:not(:disabled) {
background: var(--hover-tint-soft);
}
:where(.selection-list-item).is-selected {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
:where(.selection-list-item):focus-visible {
outline: var(--focus-outline);
outline-offset: -2px;
}
:where(.selection-list-item)[draggable="true"] {
cursor: grab;
}
:where(.selection-list-item)[draggable="true"]:active {
cursor: grabbing;
}
:where(.selection-list-item):disabled,
:where(.selection-list-item).is-disabled {
cursor: not-allowed;
opacity: .55;
}
.theme-preview-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
@@ -2789,3 +3270,179 @@
@keyframes file-drop-progress-spin {
to { transform: rotate(360deg); }
}
/* Shared settings, placeholder, and administration layout contracts. */
.placeholder-stack {
display: grid;
gap: 8px;
margin-top: 12px;
}
.placeholder-stack span {
display: block;
border: 1px dashed var(--line-dark);
border-radius: 6px;
background: var(--panel-soft);
padding: 10px 12px;
color: var(--muted);
font-weight: 700;
}
.compact-detail-list {
gap: 8px;
}
.settings-dashboard-grid {
align-items: start;
}
.settings-list {
display: grid;
gap: 12px;
}
.settings-target-row {
max-width: 520px;
}
.admin-secret {
display: block;
padding: 14px;
margin: 12px 0;
overflow-wrap: anywhere;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted);
font-size: 14px;
user-select: all;
}
.admin-json-preview {
max-height: 420px;
overflow: auto;
padding: 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted);
white-space: pre-wrap;
word-break: break-word;
}
.admin-audit-grid .data-grid-scroll-region {
max-height: min(68vh, 720px);
}
.admin-audit-grid .data-grid-header-cell {
top: 0;
}
.admin-details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.admin-details-grid > div {
min-width: 0;
padding: 10px 0;
border-bottom: var(--border-line);
}
.admin-details-grid dt {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.admin-details-grid dd {
margin: 4px 0 0;
overflow-wrap: anywhere;
}
.admin-governance-mode {
display: grid;
gap: 8px;
}
.admin-tenant-assignment-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
align-items: center;
gap: 10px;
}
.admin-settings-form {
display: grid;
gap: 18px;
}
.admin-settings-form .card {
margin: 0;
}
.admin-managed-notice {
margin: 0 0 16px;
padding: 12px 14px;
border: 1px solid var(--info-muted-border);
border-radius: var(--radius-sm);
background: var(--info-bg);
color: var(--info-text-deep);
line-height: 1.45;
}
.admin-permission-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.admin-permission-group {
min-width: 0;
margin: 0;
padding: 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.admin-permission-group legend {
padding: 0 6px;
color: var(--text-strong);
font-weight: 700;
}
.admin-protection-note {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--warning-border-soft);
border-radius: var(--radius-sm);
background: var(--warning-soft);
color: var(--warning-text-strong);
}
.admin-scope-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.admin-scope-list code {
max-width: 100%;
padding: 4px 7px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted);
overflow-wrap: anywhere;
}
.admin-overview-section-label {
margin: 4px 0 10px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
}
.admin-permission-details {
display: grid;
gap: 12px;
}
.admin-permission-details section {
display: grid;
gap: 6px;
min-width: 0;
}
.admin-permission-details ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
.module-license-status {
display: grid;
gap: 8px;
min-width: 0;
}
@media (max-width: 900px) {
.admin-details-grid,
.admin-tenant-assignment-row {
grid-template-columns: 1fr;
}
}

View File

@@ -1,10 +1,38 @@
.form-grid { display: grid; gap: 18px; }
.form-grid.compact { grid-template-columns: 1fr 1fr; }
.form-grid.compact,
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.form-grid > .wide { grid-column: 1 / -1; }
.responsive-form-grid { align-items: start; }
.form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.form-help { font-size: 12px; color: var(--muted); }
input, select, textarea { border: var(--border-line); border-radius: 5px; background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); }
input:not([type="checkbox"]):not([type="radio"]), select, textarea { border: var(--border-line); border-radius: 5px; background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); }
input[type="checkbox"]:not(.toggle-switch-input),
input[type="radio"] {
accent-color: var(--primary);
flex: 0 0 auto;
width: 1rem;
height: 1rem;
margin: 0;
}
input[type="checkbox"]:not(.toggle-switch-input):focus-visible,
input[type="radio"]:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
textarea { resize: vertical; }
@media (max-width: 1100px) {
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
}
@media (max-width: 980px) {
.form-grid.two,
.form-grid.three,
.form-grid.four { grid-template-columns: 1fr; }
}
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
input:disabled:not([type="checkbox"]):not([type="radio"]),
select:disabled,
@@ -33,7 +61,7 @@ textarea[readonly]::placeholder {
color: var(--control-disabled-placeholder);
opacity: 1;
}
.btn { border: var(--border-line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: var(--control-bg); color: var(--text); box-shadow: 0 1px 2px var(--hover-tint-strong); }
.btn { align-items: center; border: var(--border-line-dark); border-radius: 4px; cursor: pointer; display: inline-flex; font: inherit; font-weight: 700; gap: 7px; justify-content: center; padding: 9px 14px; background: var(--control-bg); color: var(--text); box-shadow: 0 1px 2px var(--hover-tint-strong); }
.btn-primary { background: var(--green); border-color: var(--success-border); color: var(--on-accent); }
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
.btn-danger { background: var(--red); color: var(--on-accent); border-color: var(--danger-border-strong); }

View File

@@ -29,6 +29,10 @@
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: var(--shadow-chrome); }
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
.block { display: block; }
.danger-text { color: var(--danger-text); }
.small-text { font-size: .78rem; }
.small-note { font-size: 12px; }
.titlebar-spacer { flex: 1; }
.titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
@@ -90,7 +94,8 @@
padding: 18px 34px 16px;
}
.workspace-heading .mono-small { margin-top: 8px; }
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
.panel { overflow: hidden; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}

View File

@@ -653,17 +653,17 @@
padding: 8px 10px;
}
/* Consistent row-level collection actions for editable DataGrid tables. */
.data-grid-row-actions {
display: grid;
grid-template-columns: repeat(4, 36px);
/* Consistent, context-aware row actions for tables and DataGrids. */
.table-action-group {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: end;
gap: 4px;
width: 100%;
}
.data-grid-row-action.btn {
.table-action-button.btn {
display: inline-grid;
place-items: center;
width: 36px;
@@ -672,10 +672,29 @@
padding: 0;
}
.data-grid-row-action.btn:disabled {
.table-action-button.btn:disabled {
opacity: .45;
}
.table-action-button.btn svg {
width: 17px;
height: 17px;
}
.table-action-icon {
display: inline-grid;
place-items: center;
}
.table-action-placeholder {
display: block;
flex: 0 0 36px;
width: 36px;
height: 36px;
visibility: hidden;
pointer-events: none;
}
.data-grid-empty-message {
color: var(--muted);
min-height: 64px;

View File

@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
};
export type PlatformPublicRouteContext = {
settings: ApiSettings;
auth: AuthInfo | null;
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
};
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
export type AdminSectionRenderContext = PlatformRouteContext & {
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
render: (context: PlatformRouteContext) => ReactNode;
};
export type PlatformPublicRouteContribution = {
path: string;
order?: number;
render: (context: PlatformPublicRouteContext) => ReactNode;
};
export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformTranslationDictionary = Record<string, string>;
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
remoteAssetContractVersion?: string | null;
navItems?: PlatformNavItem[];
routes?: PlatformRouteContribution[];
publicRoutes?: PlatformPublicRouteContribution[];
translations?: PlatformTranslations;
uiCapabilities?: PlatformUiCapabilities;
runtimeUiCapabilities?: PlatformUiCapabilities;
@@ -365,11 +378,12 @@ export type OrganizationFunctionActionContext = PlatformRouteContext & {
export type OrganizationFunctionActionContribution = {
id: string;
label?: string;
label: string;
icon: ReactNode;
order?: number;
anyOf?: string[];
allOf?: string[];
render: (context: OrganizationFunctionActionContext) => ReactNode;
onClick: (context: OrganizationFunctionActionContext) => void;
};
export type OrganizationFunctionActionsUiCapability = {
@@ -750,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
asset_manifest_integrity?: string | null;
asset_manifest_contract_version?: string | null;
routes: PlatformFrontendRouteInfo[];
public_routes: Array<{
path: string;
component: string;
order: number;
}>;
nav: Array<{
path: string;
label: string;
@@ -762,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
settings_routes: PlatformFrontendRouteInfo[];
};
export type PlatformPublicModuleInfo = {
id: string;
name: string;
version: string;
frontend: Pick<
PlatformFrontendModuleInfo,
| "module_id"
| "package_name"
| "asset_manifest"
| "asset_manifest_signature"
| "asset_manifest_public_key_id"
| "asset_manifest_integrity"
| "asset_manifest_contract_version"
| "public_routes"
>;
};
export type PlatformModuleInfo = {
id: string;
name: string;

View File

@@ -31,16 +31,24 @@ const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" },
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
};
export function helpContextForPathname(pathname: string): HelpContext {
export function helpContextForPathname(pathname: string, search = ""): HelpContext {
const route = pathname || "/";
const segments = route.split("/").filter(Boolean);
if (segments[0] === "settings") {
const section = new URLSearchParams(search).get("section") || "";
if (section === "mail-profiles") {
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` };
}
}
if (segments[0] === "campaigns" && segments[1]) {
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
if (segments[2] === "wizard") {

View File

@@ -3,7 +3,9 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
}
import { renderToStaticMarkup } from "react-dom/server";
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
import Button from "../src/components/Button";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid";
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
function noop() {}
@@ -30,3 +32,100 @@ const emptyAction = renderToStaticMarkup(
);
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
const contextActions = renderToStaticMarkup(
<TableActionGroup
actions={[
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
{ id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
]}
minimumSlots={4}
/>
);
assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
let propagationStopped = false;
let actionCalled = false;
runTableAction(
{ stopPropagation: () => { propagationStopped = true; } },
() => { actionCalled = true; }
);
assertEqual(propagationStopped, true, "table actions do not trigger clickable row handlers");
assertEqual(actionCalled, true, "table actions still invoke their handler");
const noReorderActions = renderToStaticMarkup(
<DataGridRowActions reorderable={false} onAddBelow={noop} onRemove={noop} />
);
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
const unavailableReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
);
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
type FilterRow = { id: string; name: string };
const filterRows: FilterRow[] = [
{ id: "first-non-match", name: "Ordinary row" },
{ id: "second-non-match", name: "Another row" },
{ id: "first-match", name: "Target alpha" },
{ id: "third-non-match", name: "Unrelated row" },
{ id: "second-match", name: "Target beta" },
{ id: "third-match", name: "Target gamma" }
];
const filterColumns: DataGridColumn<FilterRow>[] = [
{ id: "name", header: "Name", filterable: true, value: (row) => row.name }
];
const fullResultFilterMarkup = renderToStaticMarkup(
<DataGrid
id="full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
initialFilters={{ name: "target" }}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(fullResultFilterMarkup.includes("Target alpha"), true, "client filtering finds matches beyond the unfiltered first page");
assertEqual(fullResultFilterMarkup.includes("Target beta"), true, "client filtering happens before the first page is sliced");
assertEqual(fullResultFilterMarkup.includes("Target gamma"), false, "client pagination still limits the filtered page size");
assertEqual(fullResultFilterMarkup.includes("1\u20132"), true, "pagination counts describe the filtered result set");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?3/.test(fullResultFilterMarkup), true, "the filtered total includes matches from every source row");
const clearedFilterMarkup = renderToStaticMarkup(
<DataGrid
id="cleared-full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(clearedFilterMarkup.includes("Ordinary row"), true, "clearing filters restores the first unfiltered row");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?6/.test(clearedFilterMarkup), true, "clearing filters restores the full pagination total");
const externalShortcutMarkup = renderToStaticMarkup(
<DataGrid
id="external-query-shortcut-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
query={{ sort: null, filters: { name: "target" } }}
/>
);
assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query");
assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");

View File

@@ -0,0 +1,306 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
import { renderToStaticMarkup } from "react-dom/server";
import Dialog from "../src/components/Dialog";
import {
focusDialogOnOpen,
handleDialogKeyDown,
restoreDialogFocus,
shouldCloseDialogOnBackdrop,
trapDialogFocus
} from "../src/components/dialogInteractions";
import {
handleTopDialogKeyDown,
nextDialogActivationOrder,
registerDialog,
resetDialogStackForTests
} from "../src/components/dialogStack";
type FocusableFixture = HTMLElement & { focusCount: number; attributes: Map<string, string> };
function focusableFixture({ connected = true, hidden = false }: { connected?: boolean; hidden?: boolean } = {}): FocusableFixture {
const attributes = new Map<string, string>();
if (hidden) attributes.set("hidden", "");
const fixture = {
attributes,
focusCount: 0,
tabIndex: 0,
isConnected: connected,
getAttribute: (name: string) => attributes.get(name) ?? null,
hasAttribute: (name: string) => attributes.has(name),
setAttribute: (name: string, value: string) => attributes.set(name, value),
removeAttribute: (name: string) => attributes.delete(name),
closest: () => attributes.has("hidden") || attributes.has("inert") || attributes.get("aria-hidden") === "true" ? fixture : null,
focus() {
fixture.focusCount += 1;
}
};
return fixture as unknown as FocusableFixture;
}
function panelFixture(elements: FocusableFixture[], autofocus?: FocusableFixture) {
const panel = focusableFixture();
panel.tabIndex = -1;
Object.assign(panel, {
contains: (element: Element | null) => element === panel || elements.includes(element as FocusableFixture),
querySelectorAll: () => elements,
querySelector: (selector: string) => selector === "[autofocus]" ? (autofocus ?? null) : null
});
return panel;
}
function keyboardEvent(key: string, shiftKey = false) {
const fixture = {
key,
shiftKey,
prevented: 0,
preventDefault() {
fixture.prevented += 1;
}
};
return fixture;
}
const markup = renderToStaticMarkup(
<Dialog open title="Dialog title" onClose={() => undefined}>
<button type="button">First action</button>
</Dialog>
);
assert(markup.includes('role="dialog"'), "dialog semantics are preserved");
assert(markup.includes('aria-modal="true"'), "modal semantics are preserved");
assert(markup.includes('data-dialog-stack-state="topmost"'), "dialog exposes its initial stack state");
assert(markup.includes('tabindex="-1"'), "dialog panel can receive fallback focus");
const first = focusableFixture();
const requested = focusableFixture();
const entryPanel = panelFixture([first, requested], requested);
assertEqual(focusDialogOnOpen(entryPanel, null), requested, "autofocus target receives initial focus");
assertEqual(requested.focusCount, 1, "autofocus target is focused once");
const defaultEntryPanel = panelFixture([first]);
assertEqual(focusDialogOnOpen(defaultEntryPanel, null), first, "first available control receives initial focus");
assertEqual(first.focusCount, 1, "first available control is focused once");
const retained = focusableFixture();
const retainedPanel = panelFixture([retained]);
assertEqual(focusDialogOnOpen(retainedPanel, retained), retained, "existing focus inside the dialog is retained");
assertEqual(retained.focusCount, 0, "retained focus is not moved redundantly");
const emptyPanel = panelFixture([]);
assertEqual(focusDialogOnOpen(emptyPanel, null), emptyPanel, "panel receives focus when no control is available");
assertEqual(emptyPanel.focusCount, 1, "fallback panel is focused");
const boundaryFirst = focusableFixture();
const boundaryMiddle = focusableFixture();
const boundaryLast = focusableFixture();
const boundaryPanel = panelFixture([boundaryFirst, boundaryMiddle, boundaryLast]);
const forwardWrap = keyboardEvent("Tab");
assert(trapDialogFocus(boundaryPanel, forwardWrap, boundaryLast), "forward Tab wraps at the last control");
assertEqual(forwardWrap.prevented, 1, "forward wrap prevents focus from leaving the dialog");
assertEqual(boundaryFirst.focusCount, 1, "forward wrap focuses the first control");
const backwardWrap = keyboardEvent("Tab", true);
assert(trapDialogFocus(boundaryPanel, backwardWrap, boundaryFirst), "Shift+Tab wraps at the first control");
assertEqual(backwardWrap.prevented, 1, "backward wrap prevents focus from leaving the dialog");
assertEqual(boundaryLast.focusCount, 1, "backward wrap focuses the last control");
const interiorTab = keyboardEvent("Tab");
assert(!trapDialogFocus(boundaryPanel, interiorTab, boundaryMiddle), "interior Tab keeps native focus order");
assertEqual(interiorTab.prevented, 0, "interior Tab is not prevented");
const outsideTab = keyboardEvent("Tab");
assert(trapDialogFocus(boundaryPanel, outsideTab, focusableFixture()), "focus cannot enter the modal from outside its boundary");
assertEqual(boundaryFirst.focusCount, 2, "outside focus is redirected to the first control");
let closeCount = 0;
const escape = keyboardEvent("Escape");
assert(handleDialogKeyDown(boundaryPanel, escape, boundaryFirst, true, () => { closeCount += 1; }), "Escape closes a closable dialog");
assertEqual(closeCount, 1, "Escape invokes onClose once");
assertEqual(escape.prevented, 0, "Escape preserves the existing default-event behavior");
const blockedEscape = keyboardEvent("Escape");
assert(!handleDialogKeyDown(boundaryPanel, blockedEscape, boundaryFirst, false, () => { closeCount += 1; }), "Escape is ignored while closing is disabled");
assertEqual(closeCount, 1, "disabled Escape does not invoke onClose");
const opener = focusableFixture();
assert(restoreDialogFocus(opener), "connected opener focus is restored");
assertEqual(opener.focusCount, 1, "opener receives restored focus once");
assert(!restoreDialogFocus(focusableFixture({ connected: false })), "removed opener is not focused");
const backdrop = {} as EventTarget;
const child = {} as EventTarget;
assert(shouldCloseDialogOnBackdrop(backdrop, backdrop, true, true), "direct backdrop press closes a closable dialog");
assert(!shouldCloseDialogOnBackdrop(child, backdrop, true, true), "presses inside the panel do not close the dialog");
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, false, true), "backdrop closing can be disabled");
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, true, false), "backdrop press respects closeDisabled");
resetDialogStackForTests();
const outerOpener = focusableFixture();
const parentOpener = focusableFixture();
const parentLast = focusableFixture();
const parentPanel = panelFixture([parentOpener, parentLast]);
const childFirst = focusableFixture();
const childLast = focusableFixture();
const childPanel = panelFixture([childFirst, childLast]);
const parentId = Symbol("parent-dialog");
const childId = Symbol("child-dialog");
const parentOrder = nextDialogActivationOrder();
const childOrder = nextDialogActivationOrder();
let parentCloseCount = 0;
let childCloseCount = 0;
let childCanClose = false;
// React may commit a nested child's effect before its parent effect. Activation
// order is allocated during render so the child must remain topmost either way.
const unregisterChild = registerDialog({
id: childId,
activationOrder: childOrder,
panel: childPanel,
restoreFocus: parentOpener,
canClose: () => childCanClose,
onClose: () => { childCloseCount += 1; }
}, parentOpener);
const unregisterParent = registerDialog({
id: parentId,
activationOrder: parentOrder,
panel: parentPanel,
restoreFocus: outerOpener,
canClose: () => true,
onClose: () => { parentCloseCount += 1; }
}, childFirst);
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "underlying", "parent dialog is marked as underlying");
assert(parentPanel.hasAttribute("inert"), "parent dialog is inert while a child is open");
assertEqual(parentPanel.getAttribute("aria-hidden"), "true", "parent dialog is hidden from assistive technology");
assertEqual(parentPanel.getAttribute("aria-modal"), null, "underlying dialog no longer claims modal semantics");
assertEqual(childPanel.getAttribute("data-dialog-stack-state"), "topmost", "child dialog is marked as topmost");
assertEqual(childPanel.getAttribute("aria-modal"), "true", "child dialog owns modal semantics");
const stackedTab = keyboardEvent("Tab");
const parentFocusBeforeTab = parentOpener.focusCount;
assert(handleTopDialogKeyDown(stackedTab, childLast), "topmost dialog traps Tab");
assertEqual(stackedTab.prevented, 1, "stacked Tab wrapping is prevented once");
assertEqual(parentOpener.focusCount, parentFocusBeforeTab, "underlying dialog does not process Tab");
const disabledStackedEscape = keyboardEvent("Escape");
assert(!handleTopDialogKeyDown(disabledStackedEscape, childFirst), "disabled child ignores Escape without falling through");
assertEqual(childCloseCount, 0, "disabled child remains open");
assertEqual(parentCloseCount, 0, "disabled child Escape does not close the parent");
childCanClose = true;
const stackedEscape = keyboardEvent("Escape");
assert(handleTopDialogKeyDown(stackedEscape, childFirst), "topmost dialog handles Escape");
assertEqual(childCloseCount, 1, "one Escape closes the child once");
assertEqual(parentCloseCount, 0, "one Escape does not close the parent");
const parentFocusBeforeChildClose = parentOpener.focusCount;
unregisterChild();
assertEqual(parentOpener.focusCount, parentFocusBeforeChildClose + 1, "closing the child restores focus inside the parent");
assert(!parentPanel.hasAttribute("inert"), "parent dialog becomes interactive after the child closes");
assertEqual(parentPanel.getAttribute("aria-hidden"), null, "parent dialog returns to the accessibility tree");
assertEqual(parentPanel.getAttribute("aria-modal"), "true", "parent dialog regains modal semantics");
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "topmost", "parent dialog becomes topmost");
const parentEscape = keyboardEvent("Escape");
assert(handleTopDialogKeyDown(parentEscape, parentOpener), "parent handles Escape after child cleanup");
assertEqual(parentCloseCount, 1, "parent closes only on its own Escape handling");
unregisterParent();
assertEqual(outerOpener.focusCount, 1, "closing the final dialog restores the outer opener");
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), outerOpener), "keyboard handling stops when the stack is empty");
resetDialogStackForTests();
const forcedOuterOpener = focusableFixture();
const removedParentOpener = focusableFixture();
const removedParentPanel = panelFixture([removedParentOpener]);
const forcedChildControl = focusableFixture();
const forcedChildPanel = panelFixture([forcedChildControl]);
const unregisterForcedParent = registerDialog({
id: Symbol("forced-parent-dialog"),
activationOrder: nextDialogActivationOrder(),
panel: removedParentPanel,
restoreFocus: forcedOuterOpener,
canClose: () => true
}, forcedOuterOpener);
const unregisterForcedChild = registerDialog({
id: Symbol("forced-child-dialog"),
activationOrder: nextDialogActivationOrder(),
panel: forcedChildPanel,
restoreFocus: removedParentOpener,
canClose: () => true
}, removedParentOpener);
unregisterForcedParent();
Object.assign(removedParentOpener, { isConnected: false });
unregisterForcedChild();
assertEqual(forcedOuterOpener.focusCount, 1, "removing a dialog tree restores the nearest connected outer opener");
resetDialogStackForTests();
const strictModeOpener = focusableFixture();
const strictModeControl = focusableFixture();
const strictModePanel = panelFixture([strictModeControl]);
const strictModeRegistration = {
id: Symbol("strict-mode-dialog"),
activationOrder: nextDialogActivationOrder(),
panel: strictModePanel,
restoreFocus: strictModeOpener,
canClose: () => true
};
const unregisterStrictModeFirstPass = registerDialog(strictModeRegistration, strictModeOpener);
unregisterStrictModeFirstPass();
const unregisterStrictModeReplay = registerDialog(strictModeRegistration, strictModeOpener);
assertEqual(strictModeControl.focusCount, 2, "StrictMode effect replay returns focus to the dialog");
assertEqual(strictModeOpener.focusCount, 1, "StrictMode cleanup restores the opener before replay");
unregisterStrictModeReplay();
assertEqual(strictModeOpener.focusCount, 2, "StrictMode replay cleanup restores focus without leaving a stack entry");
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), strictModeOpener), "StrictMode replay leaves no stale topmost dialog");
resetDialogStackForTests();
let keydownListenerAdds = 0;
let keydownListenerRemovals = 0;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
addEventListener(type: string) {
if (type === "keydown") keydownListenerAdds += 1;
},
removeEventListener(type: string) {
if (type === "keydown") keydownListenerRemovals += 1;
},
getComputedStyle() {
return { display: "block", visibility: "visible" };
}
}
});
try {
const listenerOuter = focusableFixture();
const listenerParentPanel = panelFixture([focusableFixture()]);
const listenerChildPanel = panelFixture([focusableFixture()]);
const unregisterListenerParent = registerDialog({
id: Symbol("listener-parent"),
activationOrder: nextDialogActivationOrder(),
panel: listenerParentPanel,
restoreFocus: listenerOuter,
canClose: () => true
}, listenerOuter);
const unregisterListenerChild = registerDialog({
id: Symbol("listener-child"),
activationOrder: nextDialogActivationOrder(),
panel: listenerChildPanel,
restoreFocus: listenerParentPanel,
canClose: () => true
}, listenerParentPanel);
assertEqual(keydownListenerAdds, 1, "one window keyboard listener serves the whole dialog stack");
unregisterListenerChild();
assertEqual(keydownListenerRemovals, 0, "keyboard listener remains while a parent dialog is open");
unregisterListenerParent();
assertEqual(keydownListenerRemovals, 1, "keyboard listener is removed after the final dialog closes");
} finally {
resetDialogStackForTests();
Reflect.deleteProperty(globalThis, "window");
}

View File

@@ -0,0 +1,55 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function count(markup: string, pattern: RegExp): number {
return markup.match(pattern)?.length ?? 0;
}
import { renderToStaticMarkup } from "react-dom/server";
import ExplorerTree from "../src/components/ExplorerTree";
import IconButton from "../src/components/IconButton";
type TestNode = {
id: string;
label: string;
children: TestNode[];
};
function noop() {}
const alpha: TestNode = { id: "alpha", label: "Alpha", children: [] };
const beta: TestNode = { id: "beta", label: "Beta", children: [] };
alpha.children.push(beta);
beta.children.push(alpha);
const markup = renderToStaticMarkup(
<ExplorerTree
nodes={[alpha, alpha]}
getNodeId={(node) => node.id}
getNodeLabel={(node) => node.label}
getNodeChildren={(node) => node.children}
activeId="beta"
collapsible={false}
onOpen={noop}
renderNodeContent={(node) => <span>{`node-${node.id}`}</span>}
renderNodeActions={(node) => (
<IconButton label={`Add below ${node.label}`} icon={<span>+</span>} onClick={noop} />
)}
/>
);
assert(markup.includes("node-alpha"), "the root node is rendered");
assert(markup.includes("node-beta"), "non-collapsible trees render descendants without expansion state");
assert(count(markup, /node-alpha/g) === 1, "a cycle does not render an ancestor again");
assert(count(markup, /node-beta/g) === 1, "duplicate paths do not duplicate descendants");
assert(count(markup, /explorer-tree-toggle-static/g) === 2, "non-collapsible nodes render static tree affordances");
assert(!markup.includes('<button type="button" class="explorer-tree-toggle'), "non-collapsible nodes do not expose dead toggle buttons");
assert(markup.includes("explorer-tree-node-wrap file-tree-node-wrap has-actions"), "rows reserve a sibling action column only when used");
assert(markup.includes('class="explorer-tree-actions" role="group"'), "node actions expose group semantics");
assert(markup.includes('aria-label="Add below Alpha"'), "node action controls retain accessible labels");
assert(
/node-alpha<\/span><\/button><div class="explorer-tree-actions"/.test(markup),
"node actions are siblings of the node button instead of nested interactive content"
);
assert(markup.includes('aria-current="true"'), "the active node is exposed to assistive technology");

View File

@@ -0,0 +1,18 @@
import { helpContextForPathname } from "../src/utils/helpContext";
function assertEqual(actual: string, expected: string, message: string): void {
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
assertEqual(helpContextForPathname("/files").id, "files.list", "Files context");
assertEqual(helpContextForPathname("/mail").id, "mail.list", "Mail context");
assertEqual(
helpContextForPathname("/settings", "?section=mail-profiles").id,
"mail.profiles",
"Mail profile settings context"
);
assertEqual(
helpContextForPathname("/campaigns/campaign-1/attachments").id,
"campaign.attachments",
"Campaign attachment context"
);

View File

@@ -0,0 +1,40 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
import { renderToStaticMarkup } from "react-dom/server";
import AdminIconButton from "../src/components/admin/AdminIconButton";
import IconButton from "../src/components/IconButton";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
function noop() {}
const iconButtonMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<IconButton
label="i18n:govoplan-core.remove.e963907d"
icon={<svg><path /></svg>}
variant="danger"
className="context-action"
onClick={noop}
/>
</PlatformLanguageProvider>
);
assert(iconButtonMarkup.includes('type="button"'), "icon buttons default to a non-submitting button");
assert(iconButtonMarkup.includes('class="btn btn-danger icon-button context-action"'), "central variants and caller classes are preserved");
assert(iconButtonMarkup.includes('aria-label="Remove"'), "the accessible label is translated");
assert(iconButtonMarkup.includes('title="Remove"'), "the native title is translated");
assert(iconButtonMarkup.includes('class="icon-button-icon" aria-hidden="true"'), "the visible icon is decorative");
const reasonedMarkup = renderToStaticMarkup(
<IconButton label="Clear" icon={<span>X</span>} disabledReason="Permission denied" />
);
assert(reasonedMarkup.includes("disabled=\"\""), "a disabled reason disables the icon button");
assert(reasonedMarkup.includes("disabled-action-tooltip"), "a disabled reason remains discoverable");
assert(!reasonedMarkup.includes("disabledReason"), "central-only props are not passed to the DOM");
const legacyMarkup = renderToStaticMarkup(
<AdminIconButton label="Add" icon={<span>+</span>} disabled />
);
assert(legacyMarkup.includes("icon-button admin-icon-button"), "the legacy wrapper delegates to the neutral component and preserves its class hook");
assert(legacyMarkup.includes("disabled=\"\""), "the legacy disabled contract is preserved");

View File

@@ -15,7 +15,7 @@ function assertDeepEqual(actual: unknown, expected: unknown, message = "values s
import { renderToStaticMarkup } from "react-dom/server";
import CredentialPanel from "../src/components/CredentialPanel";
import PasswordField from "../src/components/PasswordField";
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
import MessageDisplayPanel, { buildSafeMessageHtmlDocument } from "../src/components/MessageDisplayPanel";
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
import EmailAddressInput from "../src/components/email/EmailAddressInput";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
@@ -245,3 +245,44 @@ assert(messageDisplay.includes("agenda.pdf"), "direct attachment is rendered");
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
const safeMessageDocument = buildSafeMessageHtmlDocument(`
<base href="https://tracking.example/">
<meta http-equiv="refresh" content="0;url=https://tracking.example/refresh">
<link rel="stylesheet" href="https://tracking.example/mail.css">
<style>
@import "https://tracking.example/import.css";
.remote { background-image: url(https://tracking.example/background.png); }
.embedded { background-image: url('cid:background@example.org'); }
</style>
<a href="https://tracking.example/click" ping="https://tracking.example/ping">Remote link</a>
<a href="#local-section">Local link</a>
<img src="https://tracking.example/pixel.gif" srcset="https://tracking.example/retina.gif 2x" onerror="fetch('https://tracking.example/error')">
<img src="cid:logo@example.org">
<img src="data:image/png;base64,iVBORw0KGgo=">
<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">
<script>fetch("https://tracking.example/script")</script>
`);
assert(safeMessageDocument.startsWith("<!doctype html><html><head>"), "message HTML is wrapped in an isolated document");
assert(safeMessageDocument.includes("default-src 'none'"), "message iframe CSP denies resources by default");
assert(safeMessageDocument.includes("img-src data: cid:"), "message iframe CSP permits only embedded image sources");
assert(safeMessageDocument.includes("connect-src 'none'"), "message iframe CSP blocks active network connections");
assert(safeMessageDocument.includes("form-action 'none'"), "message iframe CSP blocks form submissions");
assert(safeMessageDocument.includes('src="cid:logo@example.org"'), "CID image references remain available");
assert(safeMessageDocument.includes('src="data:image/png;base64,iVBORw0KGgo="'), "conservative raster data images remain available");
assert(safeMessageDocument.includes('href="#local-section"'), "same-document fragment links remain available");
assert(!safeMessageDocument.includes("tracking.example"), "remote message URLs are neutralized before preview");
assert(!safeMessageDocument.includes("data:image/svg+xml"), "active SVG data resources are rejected");
assert(!safeMessageDocument.includes("onerror="), "event-handler attributes are removed");
assert(!safeMessageDocument.includes("<script"), "script elements are removed");
const privateMessageDisplay = renderToStaticMarkup(
<MessageDisplayPanel
title="Untrusted message"
bodyHtml='<img src="https://tracking.example/pixel.gif"><img src="cid:logo@example.org">'
preferredBodyMode="html"
/>
);
assert(privateMessageDisplay.includes('sandbox=""'), "message HTML remains inside a scriptless iframe sandbox");
assert(privateMessageDisplay.includes('referrerPolicy="no-referrer"'), "message iframe suppresses referrer data");
assert(!privateMessageDisplay.includes("tracking.example"), "rendered iframe source contains no remote tracking URL");

Some files were not shown because too many files have changed in this diff Show More