Compare commits
70 Commits
c50ce58ad8
...
v0.1.14
| Author | SHA1 | Date | |
|---|---|---|---|
| e11ea81008 | |||
| bc8afeb139 | |||
| f876345656 | |||
| d487726f4d | |||
| e6fc07da37 | |||
| e6d589eb07 | |||
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 | |||
| 987ca894ed | |||
| 4caa326878 | |||
| 8c4c4456c6 | |||
| 6abe292ac8 | |||
| fea2807754 | |||
| 22646c614c | |||
| 17376332a2 | |||
| 0946bc84a9 | |||
| a18499cbb5 | |||
| 36d7b73bb5 | |||
| b89a2d15f1 | |||
| 7f923afdad | |||
| a7683c5d4a | |||
| 41ad057f7e | |||
| bf0729eb59 | |||
| c4b90181e0 | |||
| 55ed194a99 | |||
| b3b0cf0fca | |||
| fa9119bea7 | |||
| 70ca772138 | |||
| 2eae5c4df6 | |||
| 57fe6c6006 | |||
| 713afdb39b | |||
| 77f8d15d17 | |||
| fda99d40eb | |||
| 5ab1af803b | |||
| 0845e99cf6 | |||
| 28a0a596a6 | |||
| ae74189588 | |||
| 09b5009187 | |||
| 2ca61059dc | |||
| 865901f090 | |||
| 2ac1e64daa | |||
| 7526c5ebb2 | |||
| 8e1f64c790 | |||
| 66e4783d2e | |||
| 7af86b42eb | |||
| ad202f1267 | |||
| 6526f37aae | |||
| 9dabd9356d | |||
| 6502775bf7 | |||
| b2492b820f | |||
| 78d9ae48b2 | |||
| 4cb3e94de3 | |||
| 9131838b98 | |||
| 8e9eb6e1f5 | |||
| 249bf63eb8 | |||
| 248e3dc70e | |||
| 230ecf42b0 | |||
| 825791e9b0 | |||
| 7184b6cdd6 | |||
| ea8c600dce | |||
| 1839693575 | |||
| 183bf7aef0 | |||
| 37a5dfb182 | |||
| 844f934379 | |||
| a98475f7bc | |||
| 1153c9dd36 | |||
| 7eef52776c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
|
||||
```
|
||||
|
||||
The integration check runs migrations and startup smoke checks across the
|
||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
||||
on throwaway databases.
|
||||
standard module permutations. It first requires the retirement atomicity proof,
|
||||
using Files' real secret-owning provider and Audit's persistent recorder. That
|
||||
proof uses only random, test-owned schemas and cleans them afterward; it does
|
||||
not reset `public`. `--reset-schema` is destructive and belongs only on
|
||||
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
|
||||
release evidence.
|
||||
|
||||
### Broker And Workers
|
||||
|
||||
@@ -192,15 +196,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
|
||||
|
||||
|
||||
@@ -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
|
||||
Organizations–Identity–IDM–Access 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -788,7 +788,18 @@ tools/checks/postgres-integration-check.py \
|
||||
The script checks migrations and `/health` startup for core-only, files-only,
|
||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||
module sets. `--reset-schema` is destructive and must only be used against a
|
||||
throwaway database.
|
||||
throwaway database. Before those permutations, the required Core proof runs in
|
||||
random, test-owned schemas without modifying `public`. It exercises Files' real
|
||||
credential-owning retirement provider and proves that credential scrubbing,
|
||||
non-secret audit
|
||||
insertion, and table retirement commit together; database-injected audit and
|
||||
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
|
||||
captured backend process IDs also prove that each `DROP TABLE` uses the
|
||||
installer Session connection instead of waiting through a second connection.
|
||||
The meta check enables the release-gate flag so missing PostgreSQL configuration
|
||||
or full-stack test packages are a hard failure; ordinary Core-only test discovery
|
||||
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
|
||||
collecting release evidence.
|
||||
|
||||
## Migration Baselines
|
||||
|
||||
|
||||
@@ -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
21
docs/THROTTLING.md
Normal 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.
|
||||
@@ -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?
|
||||
|
||||
@@ -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,504 @@
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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-22T18:15:01Z",
|
||||
"release": "0.1.14",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.9"
|
||||
version = "0.1.14"
|
||||
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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) + "."
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
|
||||
272
src/govoplan_core/core/poll_participation.py
Normal file
272
src/govoplan_core/core/poll_participation.py
Normal 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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
349
src/govoplan_core/core/throttling.py
Normal file
349
src/govoplan_core/core/throttling.py
Normal 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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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)
|
||||
|
||||
408
src/govoplan_core/security/outbound_http.py
Normal file
408
src/govoplan_core/security/outbound_http.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
71
src/govoplan_core/server/request_limits.py
Normal file
71
src/govoplan_core/server/request_limits.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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={
|
||||
{},
|
||||
json.dumps(
|
||||
{
|
||||
"id": "file-1",
|
||||
"name": "summary.txt",
|
||||
"size": 14,
|
||||
"type": "file",
|
||||
"mtime": 1783425600,
|
||||
"permission": "r",
|
||||
},
|
||||
request=request,
|
||||
}
|
||||
).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(
|
||||
@@ -4197,13 +4211,17 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"enqueue_imap_task": False,
|
||||
},
|
||||
)
|
||||
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.status_code, 422, sent.text)
|
||||
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
|
||||
|
||||
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.queue_status, "draft")
|
||||
self.assertEqual(job.send_status, "not_queued")
|
||||
self.assertEqual(
|
||||
session.query(SendAttempt).filter(SendAttempt.job_id == job.id).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
||||
headers, _ = self._login()
|
||||
@@ -4267,29 +4285,26 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"enqueue_imap_task": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(sent.status_code, 200, sent.text)
|
||||
self.assertEqual(sent.json()["result"]["failed_count"], 1)
|
||||
self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"])
|
||||
self.assertEqual(sent.status_code, 422, sent.text)
|
||||
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
|
||||
self.assertEqual(list_records(kind="smtp"), [])
|
||||
|
||||
with SessionLocal() as session:
|
||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||
self.assertEqual(job.queue_status, "draft")
|
||||
self.assertEqual(job.send_status, "not_queued")
|
||||
|
||||
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,
|
||||
@@ -4337,7 +4352,8 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
self.assertEqual(sent.status_code, 200, sent.text)
|
||||
result = sent.json()["result"]
|
||||
self.assertEqual(result["sent_count"], 1, sent.text)
|
||||
self.assertIn("refused recipients", result["results"][0]["message"])
|
||||
self.assertEqual(result["results"][0]["status"], "smtp_accepted")
|
||||
self.assertNotIn("message", result["results"][0])
|
||||
finally:
|
||||
set_failures(smtp_reject_recipients_containing=None)
|
||||
|
||||
@@ -5780,6 +5796,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 +5822,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 +6020,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 +6141,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})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
85
tests/test_documentation_topic_contract.py
Normal file
85
tests/test_documentation_topic_contract.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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))
|
||||
|
||||
99
tests/test_people_search_contract.py
Normal file
99
tests/test_people_search_contract.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
84
tests/test_poll_participation_contract.py
Normal file
84
tests/test_poll_participation_contract.py
Normal 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()
|
||||
435
tests/test_postgres_retirement_atomicity.py
Normal file
435
tests/test_postgres_retirement_atomicity.py
Normal file
@@ -0,0 +1,435 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine, event, inspect, text
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.schema import CreateSchema, DropSchema
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
||||
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleContext
|
||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime, get_runtime_context
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
_CREDENTIAL_ID = "retirement-credential"
|
||||
_TENANT_ID = "retirement-tenant"
|
||||
_PRIVATE_FIXTURE_VALUES = (
|
||||
"opaque-password-ciphertext",
|
||||
"opaque-token-ciphertext",
|
||||
"vault:test-only:credential",
|
||||
"private-test-metadata",
|
||||
)
|
||||
_ROW_COUNT_QUERIES = {
|
||||
"audit_log": text("SELECT count(*) FROM audit_log"),
|
||||
"core_change_sequence": text("SELECT count(*) FROM core_change_sequence"),
|
||||
"retirement_scrub_probe": text("SELECT count(*) FROM retirement_scrub_probe"),
|
||||
}
|
||||
|
||||
|
||||
def _postgres_database_url() -> str:
|
||||
candidate = (
|
||||
os.environ.get("GOVOPLAN_POSTGRES_DATABASE_URL")
|
||||
or os.environ.get("DATABASE_URL")
|
||||
or ""
|
||||
).strip()
|
||||
return candidate if candidate.startswith("postgresql") else ""
|
||||
|
||||
|
||||
def _proof_is_required() -> bool:
|
||||
return os.environ.get("GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF", "").strip().casefold() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
class _AuditCapabilityRegistry:
|
||||
def __init__(self, recorder: object) -> None:
|
||||
self._recorder = recorder
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_AUDIT_RECORDER
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise LookupError(name)
|
||||
return self._recorder
|
||||
|
||||
|
||||
class PostgreSQLRetirementAtomicityTests(unittest.TestCase):
|
||||
"""Production-reference proof for destructive module retirement.
|
||||
|
||||
Every test owns a random PostgreSQL schema and removes it afterward. The
|
||||
fixture stores only synthetic credential material. Assertions and audit
|
||||
inspection expose booleans and allow-listed metadata, never stored values
|
||||
or the database URL.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
database_url = _postgres_database_url()
|
||||
if not database_url:
|
||||
if _proof_is_required():
|
||||
self.fail("the required PostgreSQL retirement proof has no PostgreSQL database")
|
||||
self.skipTest("a PostgreSQL integration database is not configured")
|
||||
|
||||
try:
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
from govoplan_audit.backend.recording import SqlAuditRecorder
|
||||
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
||||
from govoplan_files.backend.manifest import manifest as files_manifest
|
||||
except ModuleNotFoundError as exc:
|
||||
if _proof_is_required():
|
||||
self.fail(f"the required PostgreSQL retirement proof is missing package: {exc.name}")
|
||||
self.skipTest(f"the full-stack integration packages are not installed: {exc.name}")
|
||||
|
||||
self.audit_log_model = AuditLog
|
||||
self.credential_model = FileConnectorCredential
|
||||
self.profile_model = FileConnectorProfile
|
||||
self.files_manifest = files_manifest
|
||||
self.previous_runtime = get_runtime_context()
|
||||
self.admin_engine: Engine | None = None
|
||||
self.engine: Engine | None = None
|
||||
self.schema_name = f"govoplan_retirement_{uuid4().hex}"
|
||||
self.capture_retirement_ddl = False
|
||||
self.retirement_ddl_backend_pids: list[int] = []
|
||||
self.ddl_listener_registered = False
|
||||
|
||||
try:
|
||||
self.admin_engine = create_engine(database_url, pool_pre_ping=True)
|
||||
with self.admin_engine.begin() as connection:
|
||||
if connection.dialect.name != "postgresql":
|
||||
self.skipTest("the configured integration database is not PostgreSQL")
|
||||
connection.execute(CreateSchema(self.schema_name))
|
||||
|
||||
self.engine = create_engine(
|
||||
database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=0,
|
||||
connect_args={
|
||||
"options": (
|
||||
f"-csearch_path={self.schema_name},pg_catalog "
|
||||
"-clock_timeout=500ms -cstatement_timeout=10s "
|
||||
"-capplication_name=govoplan-retirement-atomicity"
|
||||
),
|
||||
},
|
||||
)
|
||||
event.listen(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
||||
self.ddl_listener_registered = True
|
||||
self._create_fixture_schema()
|
||||
self._seed_credential()
|
||||
|
||||
recorder = SqlAuditRecorder()
|
||||
configure_runtime(ModuleContext(registry=_AuditCapabilityRegistry(recorder), settings=object()))
|
||||
except Exception:
|
||||
self._cleanup()
|
||||
raise
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._cleanup()
|
||||
|
||||
def test_scrub_audit_and_table_retirement_commit_together(self) -> None:
|
||||
assert self.engine is not None
|
||||
with Session(self.engine) as session:
|
||||
installer_backend_pid = self._session_backend_pid(session)
|
||||
plan = self._retirement_plan(session)
|
||||
self._execute_retirement(session, plan)
|
||||
session.commit()
|
||||
|
||||
self.assertTrue(self.retirement_ddl_backend_pids, "the retirement provider emitted no DROP TABLE statement")
|
||||
self.assertEqual(
|
||||
{installer_backend_pid},
|
||||
set(self.retirement_ddl_backend_pids),
|
||||
"destructive DDL did not use the installer Session connection",
|
||||
)
|
||||
with self.engine.connect() as connection:
|
||||
self.assertFalse(inspect(connection).has_table(self.credential_model.__tablename__))
|
||||
self.assertFalse(inspect(connection).has_table(self.profile_model.__tablename__))
|
||||
|
||||
probe = connection.execute(text(
|
||||
"SELECT password_removed, token_removed, username_removed, "
|
||||
"secret_reference_removed, metadata_removed "
|
||||
"FROM retirement_scrub_probe"
|
||||
)).mappings().one()
|
||||
self.assertTrue(all(probe.values()), "the credential scrub probe did not observe every removal")
|
||||
|
||||
audit_rows = connection.execute(text(
|
||||
"SELECT action, object_type, object_id, details "
|
||||
"FROM audit_log ORDER BY created_at, id"
|
||||
)).mappings().all()
|
||||
self.assertEqual(1, len(audit_rows))
|
||||
audit_row = audit_rows[0]
|
||||
self.assertEqual("files.connector_credential_deleted", audit_row["action"])
|
||||
self.assertEqual("file_connector_credential", audit_row["object_type"])
|
||||
self.assertEqual(_CREDENTIAL_ID, audit_row["object_id"])
|
||||
self._assert_non_secret_audit_details(dict(audit_row["details"] or {}))
|
||||
self.assertEqual(1, self._row_count(connection, "core_change_sequence"))
|
||||
|
||||
def test_database_audit_failure_rolls_back_scrub_and_audit_state(self) -> None:
|
||||
assert self.engine is not None
|
||||
with self.engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE FUNCTION reject_retirement_audit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.action = 'files.connector_credential_deleted' THEN
|
||||
RAISE EXCEPTION 'injected retirement audit failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TRIGGER reject_retirement_audit_insert
|
||||
BEFORE INSERT ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_retirement_audit()
|
||||
"""
|
||||
)
|
||||
|
||||
with Session(self.engine) as session:
|
||||
self._session_backend_pid(session)
|
||||
plan = self._retirement_plan(session)
|
||||
with self.assertRaises(DBAPIError) as caught:
|
||||
self._execute_retirement(session, plan)
|
||||
self.assertEqual("P0001", getattr(caught.exception.orig, "sqlstate", None))
|
||||
session.rollback()
|
||||
|
||||
self.assertEqual([], self.retirement_ddl_backend_pids, "DDL ran after the injected audit failure")
|
||||
self._assert_failed_retirement_rolled_back()
|
||||
|
||||
def test_database_ddl_failure_rolls_back_scrub_audit_and_prior_drop(self) -> None:
|
||||
assert self.engine is not None
|
||||
with self.engine.begin() as connection:
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TABLE retirement_drop_guard (
|
||||
id bigint PRIMARY KEY,
|
||||
credential_id varchar(255) NOT NULL
|
||||
REFERENCES file_connector_credentials(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
text("INSERT INTO retirement_drop_guard (id, credential_id) VALUES (1, :credential_id)"),
|
||||
{"credential_id": _CREDENTIAL_ID},
|
||||
)
|
||||
|
||||
with Session(self.engine) as session:
|
||||
installer_backend_pid = self._session_backend_pid(session)
|
||||
plan = self._retirement_plan(session)
|
||||
with self.assertRaises(DBAPIError) as caught:
|
||||
self._execute_retirement(session, plan)
|
||||
self.assertEqual("2BP01", getattr(caught.exception.orig, "sqlstate", None))
|
||||
session.rollback()
|
||||
|
||||
self.assertTrue(self.retirement_ddl_backend_pids, "the injected DDL failure was not reached")
|
||||
self.assertEqual(
|
||||
{installer_backend_pid},
|
||||
set(self.retirement_ddl_backend_pids),
|
||||
"destructive DDL did not use the installer Session connection",
|
||||
)
|
||||
self._assert_failed_retirement_rolled_back()
|
||||
|
||||
def _create_fixture_schema(self) -> None:
|
||||
assert self.engine is not None
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
|
||||
with self.engine.begin() as connection:
|
||||
# Audit and Files reference Access actors. The proof does not need
|
||||
# actor rows, so small FK targets keep its schema bounded.
|
||||
connection.exec_driver_sql("CREATE TABLE access_users (id varchar(36) PRIMARY KEY)")
|
||||
connection.exec_driver_sql("CREATE TABLE access_api_keys (id varchar(36) PRIMARY KEY)")
|
||||
Base.metadata.create_all(
|
||||
bind=connection,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
self.audit_log_model.__table__,
|
||||
self.credential_model.__table__,
|
||||
self.profile_model.__table__,
|
||||
],
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TABLE retirement_scrub_probe (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
password_removed boolean NOT NULL,
|
||||
token_removed boolean NOT NULL,
|
||||
username_removed boolean NOT NULL,
|
||||
secret_reference_removed boolean NOT NULL,
|
||||
metadata_removed boolean NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE FUNCTION record_retirement_scrub() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
INSERT INTO retirement_scrub_probe (
|
||||
password_removed,
|
||||
token_removed,
|
||||
username_removed,
|
||||
secret_reference_removed,
|
||||
metadata_removed
|
||||
) VALUES (
|
||||
OLD.password_encrypted IS NOT NULL AND NEW.password_encrypted IS NULL,
|
||||
OLD.token_encrypted IS NOT NULL AND NEW.token_encrypted IS NULL,
|
||||
OLD.username IS NOT NULL AND NEW.username IS NULL,
|
||||
OLD.secret_ref IS NOT NULL AND NEW.secret_ref IS NULL,
|
||||
OLD.metadata IS NOT NULL AND NEW.metadata::jsonb = '{}'::jsonb
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
CREATE TRIGGER record_retirement_credential_scrub
|
||||
BEFORE UPDATE ON file_connector_credentials
|
||||
FOR EACH ROW EXECUTE FUNCTION record_retirement_scrub()
|
||||
"""
|
||||
)
|
||||
|
||||
def _seed_credential(self) -> None:
|
||||
assert self.engine is not None
|
||||
with Session(self.engine) as session:
|
||||
session.add(self.credential_model(
|
||||
id=_CREDENTIAL_ID,
|
||||
tenant_id=_TENANT_ID,
|
||||
scope_type="tenant",
|
||||
scope_id=_TENANT_ID,
|
||||
label="Retirement integration credential",
|
||||
provider="webdav",
|
||||
enabled=True,
|
||||
credential_mode="basic",
|
||||
username="integration-user",
|
||||
password_encrypted=_PRIVATE_FIXTURE_VALUES[0],
|
||||
token_encrypted=_PRIVATE_FIXTURE_VALUES[1],
|
||||
password_env="INTEGRATION_PASSWORD",
|
||||
token_env="INTEGRATION_TOKEN",
|
||||
secret_ref=_PRIVATE_FIXTURE_VALUES[2],
|
||||
policy={},
|
||||
metadata_={"private_hint": _PRIVATE_FIXTURE_VALUES[3]},
|
||||
))
|
||||
session.commit()
|
||||
|
||||
def _retirement_plan(self, session: Session) -> MigrationRetirementPlan:
|
||||
migration = self.files_manifest.migration_spec
|
||||
self.assertIsNotNone(migration)
|
||||
assert migration is not None
|
||||
self.assertIsNotNone(migration.retirement_provider)
|
||||
assert migration.retirement_provider is not None
|
||||
plan = migration.retirement_provider(session, "files")
|
||||
self.assertTrue(plan.destroy_data_supported)
|
||||
self.assertIsNotNone(plan.destroy_data_executor)
|
||||
return plan
|
||||
|
||||
def _execute_retirement(self, session: Session, plan: MigrationRetirementPlan) -> None:
|
||||
executor = plan.destroy_data_executor
|
||||
self.assertIsNotNone(executor)
|
||||
self.capture_retirement_ddl = True
|
||||
try:
|
||||
executor(session, "files")
|
||||
finally:
|
||||
self.capture_retirement_ddl = False
|
||||
|
||||
def _capture_ddl_connection(
|
||||
self,
|
||||
connection: Connection,
|
||||
_cursor: object,
|
||||
statement: str,
|
||||
_parameters: object,
|
||||
_context: object,
|
||||
_executemany: bool,
|
||||
) -> None:
|
||||
if not self.capture_retirement_ddl or not statement.lstrip().upper().startswith("DROP TABLE"):
|
||||
return
|
||||
self.retirement_ddl_backend_pids.append(self._connection_backend_pid(connection))
|
||||
|
||||
def _session_backend_pid(self, session: Session) -> int:
|
||||
connection = session.connection()
|
||||
sql_pid = int(connection.execute(text("SELECT pg_backend_pid()")).scalar_one())
|
||||
driver_pid = self._connection_backend_pid(connection)
|
||||
self.assertEqual(sql_pid, driver_pid)
|
||||
return sql_pid
|
||||
|
||||
@staticmethod
|
||||
def _connection_backend_pid(connection: Connection) -> int:
|
||||
driver_connection = connection.connection.driver_connection
|
||||
return int(driver_connection.info.backend_pid)
|
||||
|
||||
def _assert_failed_retirement_rolled_back(self) -> None:
|
||||
assert self.engine is not None
|
||||
with self.engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
self.assertTrue(inspector.has_table(self.credential_model.__tablename__))
|
||||
self.assertTrue(inspector.has_table(self.profile_model.__tablename__))
|
||||
state = connection.execute(
|
||||
text(
|
||||
"SELECT enabled, password_encrypted IS NOT NULL AS has_password, "
|
||||
"token_encrypted IS NOT NULL AS has_token, username IS NOT NULL AS has_username, "
|
||||
"secret_ref IS NOT NULL AS has_secret_reference, metadata::jsonb <> '{}'::jsonb AS has_metadata "
|
||||
"FROM file_connector_credentials WHERE id = :credential_id"
|
||||
),
|
||||
{"credential_id": _CREDENTIAL_ID},
|
||||
).mappings().one()
|
||||
self.assertTrue(all(state.values()), "credential state was not fully restored")
|
||||
self.assertEqual(0, self._row_count(connection, "retirement_scrub_probe"))
|
||||
self.assertEqual(0, self._row_count(connection, "audit_log"))
|
||||
self.assertEqual(0, self._row_count(connection, "core_change_sequence"))
|
||||
|
||||
def _assert_non_secret_audit_details(self, details: dict[str, object]) -> None:
|
||||
self.assertEqual("module_data_retired", details.get("deletion_reason"))
|
||||
self.assertEqual("unowned_external_reference_detached", details.get("storage_backend"))
|
||||
self.assertEqual("<redacted>", details.get("deleted_secret_kinds"))
|
||||
self.assertEqual(
|
||||
["password_env", "token_env", "unowned_external_secret_ref"],
|
||||
details.get("removed_reference_kinds"),
|
||||
)
|
||||
self.assertTrue(details.get("removed_metadata"))
|
||||
serialized = json.dumps(details, sort_keys=True)
|
||||
self.assertFalse(
|
||||
any(private_value in serialized for private_value in _PRIVATE_FIXTURE_VALUES),
|
||||
"audit diagnostics contain fixture-private material",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_count(connection: Connection, table_name: str) -> int:
|
||||
query = _ROW_COUNT_QUERIES.get(table_name)
|
||||
if query is None:
|
||||
raise ValueError("unsupported retirement proof table")
|
||||
return int(connection.execute(query).scalar_one())
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
if getattr(self, "previous_runtime", None) is None:
|
||||
clear_runtime()
|
||||
else:
|
||||
configure_runtime(self.previous_runtime)
|
||||
if self.engine is not None:
|
||||
if self.ddl_listener_registered:
|
||||
event.remove(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
||||
self.engine.dispose()
|
||||
self.engine = None
|
||||
if self.admin_engine is not None:
|
||||
try:
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(DropSchema(self.schema_name, cascade=True, if_exists=True))
|
||||
finally:
|
||||
self.admin_engine.dispose()
|
||||
self.admin_engine = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
130
tests/test_throttling.py
Normal file
130
tests/test_throttling.py
Normal 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
143
tests/test_wheel_runtime.py
Normal 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()
|
||||
48
webui/package-lock.json
generated
48
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.14",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
@@ -46,12 +46,12 @@
|
||||
},
|
||||
"../../govoplan-access/webui": {
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.11",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -65,9 +65,9 @@
|
||||
},
|
||||
"../../govoplan-addresses/webui": {
|
||||
"name": "@govoplan/addresses-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -86,7 +86,7 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -102,7 +102,7 @@
|
||||
"name": "@govoplan/audit-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -118,7 +118,7 @@
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"../../govoplan-campaign/webui": {
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -143,7 +143,7 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"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",
|
||||
@@ -173,9 +173,9 @@
|
||||
},
|
||||
"../../govoplan-docs/webui": {
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.10",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -192,9 +192,9 @@
|
||||
},
|
||||
"../../govoplan-files/webui": {
|
||||
"name": "@govoplan/files-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -213,7 +213,7 @@
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -230,12 +230,12 @@
|
||||
},
|
||||
"../../govoplan-mail/webui": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.10",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -251,7 +251,7 @@
|
||||
"name": "@govoplan/notifications-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -289,7 +289,7 @@
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.8",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -306,9 +306,9 @@
|
||||
},
|
||||
"../../govoplan-policy/webui": {
|
||||
"name": "@govoplan/policy-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -322,9 +322,9 @@
|
||||
},
|
||||
"../../govoplan-scheduling/webui": {
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.11",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.14",
|
||||
"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.11",
|
||||
"@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.11",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#4774a8025cea3fd0962a23741e6d0241d4062663",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.14",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.14",
|
||||
"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.11",
|
||||
"@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"
|
||||
|
||||
22
webui/scripts/test-dialog-focus-structure.mjs
Normal file
22
webui/scripts/test-dialog-focus-structure.mjs
Normal 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.");
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
35
webui/src/api/resourceAccess.ts
Normal file
35
webui/src/api/resourceAccess.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
34
webui/src/api/resourceAccessContracts.ts
Normal file
34
webui/src/api/resourceAccessContracts.ts
Normal 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;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,15 +40,37 @@ export type ExplorerTreeProps<T> = {
|
||||
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
export default function ExplorerTree<T>({
|
||||
type CollapsibleExplorerTreeProps<T> = {
|
||||
collapsible?: true;
|
||||
expandedIds: ReadonlySet<string>;
|
||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
type StaticExplorerTreeProps = {
|
||||
collapsible: false;
|
||||
expandedIds?: never;
|
||||
onToggle?: never;
|
||||
};
|
||||
|
||||
export type ExplorerTreeProps<T> = ExplorerTreeCommonProps<T> & (
|
||||
CollapsibleExplorerTreeProps<T> | StaticExplorerTreeProps
|
||||
);
|
||||
|
||||
type SafeNode<T> = {
|
||||
id: string;
|
||||
node: T;
|
||||
};
|
||||
|
||||
const EMPTY_EXPANDED_IDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export default function ExplorerTree<T>(props: ExplorerTreeProps<T>) {
|
||||
const {
|
||||
nodes,
|
||||
getNodeId,
|
||||
getNodeLabel,
|
||||
getNodeChildren,
|
||||
activeId = "",
|
||||
expandedIds,
|
||||
onOpen,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
depth = 1,
|
||||
className = "",
|
||||
@@ -60,6 +81,7 @@ export default function ExplorerTree<T>({
|
||||
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
||||
renderToggleIcon,
|
||||
renderNodeContent,
|
||||
renderNodeActions,
|
||||
getNodeWrapClassName,
|
||||
getNodeWrapStyle,
|
||||
getNodeButtonClassName,
|
||||
@@ -70,19 +92,40 @@ export default function ExplorerTree<T>({
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop
|
||||
}: ExplorerTreeProps<T>) {
|
||||
if (nodes.length === 0) return null;
|
||||
} = 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, className].filter(Boolean).join(" ")}>
|
||||
{nodes.map((node) => {
|
||||
const nodeId = getNodeId(node);
|
||||
<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 = children.length > 0;
|
||||
const expanded = expandedIds.has(nodeId);
|
||||
const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0;
|
||||
const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren;
|
||||
const active = activeId === nodeId;
|
||||
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
|
||||
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}>
|
||||
@@ -90,73 +133,69 @@ export default function ExplorerTree<T>({
|
||||
className={[
|
||||
nodeWrapBaseClassName,
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""].
|
||||
filter(Boolean).join(" ")}
|
||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
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}>
|
||||
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
||||
>
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBaseClassName}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(node, context);
|
||||
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}>
|
||||
|
||||
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}>
|
||||
|
||||
disabled={disabled}
|
||||
aria-current={active ? "true" : undefined}
|
||||
>
|
||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||
</button>
|
||||
</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>);
|
||||
|
||||
{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)
|
||||
})}
|
||||
</div>);
|
||||
|
||||
>
|
||||
{nodeActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderLevel(nodes, depth, new Set(), true);
|
||||
}
|
||||
|
||||
31
webui/src/components/IconButton.tsx
Normal file
31
webui/src/components/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeCssString(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\f]/g, "");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
84
webui/src/components/ResourceAccessExplanation.tsx
Normal file
84
webui/src/components/ResourceAccessExplanation.tsx
Normal 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);
|
||||
}
|
||||
67
webui/src/components/SelectionList.tsx
Normal file
67
webui/src/components/SelectionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(" ")} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
106
webui/src/components/dialogInteractions.ts
Normal file
106
webui/src/components/dialogInteractions.ts
Normal 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;
|
||||
}
|
||||
158
webui/src/components/dialogStack.ts
Normal file
158
webui/src/components/dialogStack.ts
Normal 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();
|
||||
}
|
||||
517
webui/src/components/people/PeoplePicker.tsx
Normal file
517
webui/src/components/people/PeoplePicker.tsx
Normal 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;
|
||||
}
|
||||
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
78
webui/src/components/people/peoplePickerTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
100
webui/src/components/table/TableActionGroup.tsx
Normal file
100
webui/src/components/table/TableActionGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"])} />
|
||||
|
||||
}
|
||||
|
||||
30
webui/src/hooks/useOutsideDismiss.ts
Normal file
30
webui/src/hooks/useOutsideDismiss.ts
Normal 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;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
{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>);
|
||||
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 ?? []).
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
|
||||
306
webui/tests/dialog-focus.test.tsx
Normal file
306
webui/tests/dialog-focus.test.tsx
Normal 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");
|
||||
}
|
||||
55
webui/tests/explorer-tree.test.tsx
Normal file
55
webui/tests/explorer-tree.test.tsx
Normal 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");
|
||||
18
webui/tests/help-context.test.ts
Normal file
18
webui/tests/help-context.test.ts
Normal 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"
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user