23 Commits
Author SHA1 Message Date
zemion 7eef52776c docs: link product vision to technical roadmap 2026-07-20 20:48:47 +02:00
zemion c50ce58ad8 refactor(webui): clarify shared controls and status feedback 2026-07-20 20:03:42 +02:00
zemion 344fc0077f test(integration): harden cross-module API coverage 2026-07-20 20:03:27 +02:00
zemion 28afc01371 fix(notifications): commit worker delivery transactions 2026-07-20 20:03:21 +02:00
zemion 1a29e75db4 feat(identity): define the search provider contract 2026-07-20 20:03:15 +02:00
zemion 57ec960f40 docs(release): record the v0.1.8 composition 2026-07-20 20:03:10 +02:00
zemion 6388afdad8 chore(release): move tooling tests to meta repository 2026-07-20 20:03:10 +02:00
zemion 1a0e90b22d fix(migrations): preserve runtime logging during upgrades 2026-07-20 20:03:02 +02:00
zemion 7ad6f6328a feat(poll): add filtered response lookup contract 2026-07-20 18:28:56 +02:00
zemion c79a7124b7 chore(core): bump contract version to 0.1.9 2026-07-20 18:27:13 +02:00
zemion abbef5a10b feat(policy): add scheduling participant privacy contract 2026-07-20 18:23:59 +02:00
zemion 2f559e3f0b feat(poll): extend response editing contract 2026-07-20 18:16:42 +02:00
zemion 9b5418db78 fix(webui): keep DataGrid actions out of form submission 2026-07-20 17:58:32 +02:00
zemion 1678602fd6 docs(ui): bind interfaces to central components 2026-07-20 17:56:18 +02:00
zemion 6e373dcdd6 feat(poll): define the scheduling capability contract 2026-07-20 17:33:47 +02:00
zemion d1c033edc7 feat(calendar): document outbox retention policy 2026-07-20 17:06:19 +02:00
zemion b5cfba666c fix(files): make dropped-file handling explicit 2026-07-20 16:57:29 +02:00
zemion 78b4afdec4 feat(calendar): define picker UI capability 2026-07-20 16:57:17 +02:00
zemion 15596f0742 feat(calendar): schedule durable outbox recovery 2026-07-20 16:57:01 +02:00
zemion a2320fcb5d docs(ui): record placement and focused-view rules 2026-07-20 16:49:57 +02:00
zemion e6f7c45f0a intermittent commit 2026-07-14 13:22:10 +02:00
zemion 8aa1943581 Register Scheduling WebUI package 2026-07-12 19:00:54 +02:00
zemion b9badc9153 Harden module installation and imports 2026-07-11 18:49:35 +02:00
112 changed files with 7953 additions and 2806 deletions
@@ -30,10 +30,14 @@ _TABLE_RENAMES = (
("governance_templates", "admin_governance_templates"), ("governance_templates", "admin_governance_templates"),
("governance_template_assignments", "admin_governance_template_assignments"), ("governance_template_assignments", "admin_governance_template_assignments"),
) )
_KNOWN_TABLE_NAMES = {name for pair in _TABLE_RENAMES for name in pair}
def _row_count(bind: sa.Connection, table_name: str) -> int: def _row_count(bind: sa.Connection, table_name: str) -> int:
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()) if table_name not in _KNOWN_TABLE_NAMES:
raise RuntimeError(f"Unexpected table name: {table_name}")
quoted = bind.dialect.identifier_preparer.quote(table_name)
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None: def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
@@ -19,11 +19,14 @@ LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes" CORE_SCOPE_TABLE = "core_scopes"
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug" LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
CORE_SLUG_INDEX = "ix_core_scopes_slug" CORE_SLUG_INDEX = "ix_core_scopes_slug"
_KNOWN_SCOPE_TABLES = {LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE}
def _row_count(bind: sa.Connection, table_name: str) -> int: def _row_count(bind: sa.Connection, table_name: str) -> int:
if table_name not in _KNOWN_SCOPE_TABLES:
raise RuntimeError(f"Unexpected scope table name: {table_name}")
quoted = bind.dialect.identifier_preparer.quote(table_name) quoted = bind.dialect.identifier_preparer.quote(table_name)
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _scope_tables(bind: sa.Connection) -> set[str]: def _scope_tables(bind: sa.Connection) -> set[str]:
@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
down_revision = "8c9d0e1f2a3b" down_revision = "8c9d0e1f2a3b"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
def _now() -> datetime: def _now() -> datetime:
@@ -28,9 +29,10 @@ def upgrade() -> None:
tables = set(inspector.get_table_names()) tables = set(inspector.get_table_names())
# Reconcile only the empty create_all shape for the newly introduced tables. # Reconcile only the empty create_all shape for the newly introduced tables.
for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"): for table_name in _RECONCILE_CREATE_ALL_TABLES:
if table_name in tables: if table_name in tables:
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one() quoted = bind.dialect.identifier_preparer.quote(table_name)
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one() # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
if count: if count:
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}") raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
op.drop_table(table_name) op.drop_table(table_name)
@@ -49,7 +49,7 @@ def upgrade() -> None:
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS)) placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)} params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
bind.execute( bind.execute(
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
params, params,
) )
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')")) bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
+4 -1
View File
@@ -20,7 +20,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
config.set_main_option("sqlalchemy.url", database_url) config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) # Migrations can run inside the long-lived application process when module
# state changes. Do not let Alembic's logging setup disable loggers that the
# server already created (for example slow-request diagnostics).
fileConfig(config.config_file_name, disable_existing_loggers=False)
def _target_metadata(): def _target_metadata():
+11 -3
View File
@@ -153,16 +153,24 @@ on throwaway databases.
| --- | --- | --- | | --- | --- | --- |
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. | | `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. | | `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. | | `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
Worker command: Worker command:
```bash ```bash
python -m celery -A govoplan_core.celery_app:celery worker \ python -m celery -A govoplan_core.celery_app:celery worker \
--queues send_email,append_sent,default \ --queues send_email,append_sent,notifications,calendar,default \
--loglevel INFO --loglevel INFO
``` ```
Run Celery beat as a separately supervised process. Its built-in one-minute
schedule recovers Calendar outbox rows left behind by broker failures, process
crashes, and expired worker leases:
```bash
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
```
### Storage ### Storage
| Setting | Default | Notes | | Setting | Default | Notes |
@@ -275,7 +283,7 @@ the checked in `.env.example`. It runs:
- explicit `ENABLED_MODULES` - explicit `ENABLED_MODULES`
- explicit migrations and `--with-dev-data` bootstrap - explicit migrations and `--with-dev-data` bootstrap
- API via the module-aware devserver - API via the module-aware devserver
- a Celery worker for `send_email,append_sent,default` - a Celery worker for `send_email,append_sent,notifications,calendar,default`
- WebUI through the Vite dev server - WebUI through the Vite dev server
- durable local files under `runtime/production-like/files` - durable local files under `runtime/production-like/files`
+20 -8
View File
@@ -1,13 +1,21 @@
# GovOPlaN Master Roadmap # GovOPlaN Master Roadmap
This roadmap is the durable product north star and sequencing guide for This roadmap is the technical and module-sequencing companion for GovOPlaN as
GovOPlaN as a modular platform for administrative operations. It keeps the a modular platform for administrative operations. It translates the
product moving without turning every possible public-sector need into an cross-product outcome horizons into dependency waves without turning every
immediate implementation track. possible public-sector need into an immediate implementation track.
Use this document for product direction, sequencing, and module routing. Issues Use this document for technical sequencing, module routing, and implementation
are the active backlog; this document is durable planning context and should be gates. Issues are the active backlog; this document is durable architecture
mirrored to the Gitea wiki. 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. Its product horizons are canonical; the waves below give
their technical dependency order. This Core roadmap remains the technical
sequencing and module-routing companion.
## Product Thesis ## Product Thesis
@@ -530,7 +538,11 @@ Before a module becomes release-included, it needs:
- smoke test or permutation test - smoke test or permutation test
- no required imports from optional modules - no required imports from optional modules
## Priority Order Summary ## Technical Dependency Order Summary
When the meta roadmap selects one of these product programs, use this order to
respect shared-spine dependencies. The list is not an active schedule or a
decision to start every program horizontally.
1. Stabilize the platform spine. 1. Stabilize the platform spine.
2. Deliver permit-to-payment MVP. 2. Deliver permit-to-payment MVP.
+26 -7
View File
@@ -126,6 +126,15 @@ Feature modules should prefer these capabilities over direct reads of
access/tenant ORM models when they need labels, group membership, default access/tenant ORM models when they need labels, group membership, default
access provisioning, counts, audit actor labels, or tenant metadata. access provisioning, counts, audit actor labels, or tenant metadata.
Other stable runtime capabilities currently include:
- `identity.directory` and `identity.search`
- `organizations.directory`
- `idm.directory`
- `calendar.outbox` and `calendar.scheduling`
- `poll.scheduling`
- `notifications.dispatch`
### Named Interface Contracts ### Named Interface Contracts
Capabilities are runtime objects. Named interface contracts are compatibility Capabilities are runtime objects. Named interface contracts are compatibility
@@ -147,15 +156,25 @@ intended for SemVer major-version lines. Missing optional interfaces are
allowed, but an installed provider with an incompatible version blocks allowed, but an installed provider with an incompatible version blocks
activation because the integration would otherwise bind to an unsafe API. activation because the integration would otherwise bind to an unsafe API.
Current named interfaces: Current named interfaces, generated from the source manifests by the workspace
contract checks, are:
- `files.campaign_attachments` - `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
- `calendar.outbox`, `calendar.scheduling`
- `campaigns.access`, `campaigns.delivery_tasks`,
`campaigns.mail_policy_context`, `campaigns.policy_context`,
`campaigns.retention`
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
- `files.access`, `files.campaign_attachments`
- `mail.campaign_delivery` - `mail.campaign_delivery`
- `campaigns.access` - `notifications.dispatch`
- `campaigns.delivery_tasks` - `poll.availability_matrix`, `poll.option_selection`,
- `campaigns.mail_policy_context` `poll.response_collection`, `poll.signed_participation`,
- `campaigns.policy_context` `poll.workflow_context`
- `campaigns.retention` - `rest.function_publication`
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
- `soap.operation_publication`
Core validates named interface contracts in three places: Core validates named interface contracts in three places:
+14
View File
@@ -97,6 +97,20 @@ New backend code should import policy-owned retention behavior from
`govoplan-policy` or request the capability, not add new implementation logic `govoplan-policy` or request the capability, not add new implementation logic
to core. to core.
The retention API DTOs live in `govoplan_core.privacy.schemas`.
`PrivacyRetentionPolicyItem`, `PrivacyRetentionPolicyPatchItem`,
`RETENTION_POLICY_FIELD_KEYS`, and `default_allow_lower_level_limits()` are
platform contracts because admin, access compatibility, and policy routes expose
the same stable retention payload shape. The policy engine's internal
`PrivacyRetentionPolicy` and `PrivacyRetentionPolicyPatch` models stay in
`govoplan-policy`, because they carry implementation validators and merge
behavior that are not generic API contracts.
Tenant administration DTOs remain owned by `govoplan-tenancy`; access keeps
matching compatibility DTOs only for its legacy admin surface. Admin overview
responses remain module-local because the same counters are exposed from
different menu contexts and are not yet a separately versioned platform API.
## Frontend Contract ## Frontend Contract
Policy UIs must: Policy UIs must:
+31 -25
View File
@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
Update those refs when cutting a release: Update those refs when cutting a release:
```text ```text
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6 govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6 govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6 govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6 govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6 govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6 govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6 govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6 govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6 govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6 govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6 govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
``` ```
## WebUI Packages ## WebUI Packages
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
``` ```
The module repositories include root-level npm package manifests so git Module repositories with a frontend include root-level npm package manifests
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`, so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
`@govoplan/files-webui`, `@govoplan/mail-webui`, resolved from repository roots even though their source lives below
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository `webui/src`.
roots even though their source lives below `webui/src`.
### Release Lockfile Strategy ### Release Lockfile Strategy
The supported release composition currently is the full GovOPlaN product: core The supported backend release composition is the set pinned in the meta
plus access, admin, tenancy, organizations, identity, policy, audit, repository's `requirements-release.txt`. The supported frontend composition
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed is the independently buildable module set pinned in
full-product release lockfile at `webui/package.release.json`; backend-only modules do not need a frontend
package entry. Keep one committed full-product release lockfile at
`webui/package-lock.release.json`, generated from `webui/package-lock.release.json`, generated from
`webui/package.release.json` in a clean release workspace. Development `webui/package.release.json` in a clean release workspace. Development
`package-lock.json` may continue to point at local `file:` dependencies. `package-lock.json` may continue to point at local `file:` dependencies.
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
```bash ```bash
cd /mnt/DATA/git/govoplan cd /mnt/DATA/git/govoplan
tools/release/push-release-tag.sh --version 0.1.6 tools/release/push-release-tag.sh --version 0.1.8
``` ```
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered `/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
@@ -126,7 +135,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
Current tag-only module repositories: Current tag-only module repositories:
- `govoplan-addresses`
- `govoplan-appointments` - `govoplan-appointments`
- `govoplan-cases` - `govoplan-cases`
- `govoplan-connectors` - `govoplan-connectors`
@@ -135,13 +143,11 @@ Current tag-only module repositories:
- `govoplan-fit-connect` - `govoplan-fit-connect`
- `govoplan-forms` - `govoplan-forms`
- `govoplan-identity-trust` - `govoplan-identity-trust`
- `govoplan-idm`
- `govoplan-ledger` - `govoplan-ledger`
- `govoplan-notifications`
- `govoplan-payments` - `govoplan-payments`
- `govoplan-portal` - `govoplan-portal`
- `govoplan-postbox`
- `govoplan-reporting` - `govoplan-reporting`
- `govoplan-scheduling`
- `govoplan-search` - `govoplan-search`
- `govoplan-tasks` - `govoplan-tasks`
- `govoplan-templates` - `govoplan-templates`
+77
View File
@@ -38,6 +38,14 @@ contestability, responsibility, and traceability at the point of action.
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit | | UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records | | UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments | | UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
| 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 |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
@@ -138,6 +146,67 @@ adaptive form, not force a linear wizard.
- Wizard shells remain available for assisted setup, first-run guidance, - Wizard shells remain available for assisted setup, first-run guidance,
imports, discovery-heavy flows, and operational preflight workflows. imports, discovery-heavy flows, and operational preflight workflows.
### DUE-008: Platform Theme Contract
Decision: the WebUI shell exposes a small, stable appearance contract based on
shared CSS tokens and persisted user preference selection.
- Core applies `system`, `light`, and `dark` preferences at the document root.
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
status colors, radii, shadows, and disabled-control colors.
- Modules must style new UI with these tokens and shared controls. Module-local
CSS may tune layout and spacing, but it must not introduce a separate
appearance system.
- Appearance controls live in user settings first. Tenant defaults and policy
enforcement can be added later without changing the token contract.
- Visual preview in settings is illustrative; it must reflect token families,
not become a second theme implementation.
### DUE-009: Central Component And Exception Contract
Decision: module interfaces are compositions of the components exported by
`@govoplan/core-webui`. When Core already owns the matching interaction, using
the central component is required rather than preferred.
A route or domain-specific page composed from central components is ordinary
module composition. A new reusable UI control, presentation primitive, or
module-local substitute is a custom component. Before one is implemented, the
product owner must explicitly authorize it and the owning decision or issue must
record:
- its single, narrowly defined purpose and intended consumers
- why central components or their composition cannot meet that purpose
- the permitted scope and the boundary it must not grow beyond
- accessibility, reachable states, theme behavior, and test expectations
- whether the component remains domain-owned or is a candidate for Core
Custom components must not duplicate, fork, or cosmetically replace a central
component. An existing local implementation does not grant an exception. If a
central contract later covers the need, migrate to it unless the product owner
explicitly retains the exception.
### DUE-010: Scheduling Request Reference Composition
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.
- 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
standard row-action placement and order.
- Calendar integration uses the central `ToggleSwitch`, with its dependent
controls shown when enabled.
- Each participant is edited as one structured row with name, email address,
and actions. The normal editor does not parse a free-form list of addresses;
that interaction requires a separate, explicitly designed bulk-import flow.
Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants.
## Implementation Sequence ## Implementation Sequence
| Phase | Scope | Output | | Phase | Scope | Output |
@@ -200,6 +269,14 @@ Every new or changed admin/configuration surface should answer:
- Does the action surface show consequence, reversibility, and audit evidence - Does the action surface show consequence, reversibility, and audit evidence
when rights, duties, records, money, communication, external systems, or when rights, duties, records, money, communication, external systems, or
workflow state are affected? workflow state are affected?
- Does the surface use every applicable central Core component? If it contains
a custom component, is the product-owner authorization, narrow purpose,
rationale, scope, and non-duplication evidence recorded?
- Is a collection-wide create action in the collection heading rather than
duplicated in a persistent side panel?
- Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively?
- If automation is involved, can the user see the trigger, system actor, - If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state? observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees? - Are technical details available without being the first thing the user sees?
+1 -1
View File
@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to - upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
`pyzipper==0.4.0` `pyzipper==0.4.0`
- upgraded the local audit environment to `pip==26.1.2` - upgraded the local audit environment to `pip==26.1.2`
- removed the obsolete local `govoplan-module-multimailer` editable install - removed the obsolete local mailer-module editable install
from the audit environment so the audit reflects the split module product from the audit environment so the audit reflects the split module product
Post-remediation result: Post-remediation result:
+84
View File
@@ -83,6 +83,90 @@
"release": "0.1.7", "release": "0.1.7",
"track": "release", "track": "release",
"squash_policy": "reviewed-manual" "squash_policy": "reviewed-manual"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "2c3d4e5f7081"
},
{
"owner": "govoplan-mail",
"revision": "3d4e5f708192"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-calendar",
"revision": "9e0f1a2b3c4d"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"9e0f1a2b3c4d"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"2c3d4e5f7081"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"3d4e5f708192"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
}
],
"recorded_at": "2026-07-20T02:45:41Z",
"release": "0.1.8",
"track": "release",
"squash_policy": "reviewed-manual"
} }
], ],
"version": 1 "version": 1
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.8" version = "0.1.9"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+62
View File
@@ -94,6 +94,28 @@ class UserInfo(BaseModel):
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences) ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
class AuthSessionUserInfo(BaseModel):
id: str
account_id: str
email: str
display_name: str | None = None
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
class AuthSessionResponse(BaseModel):
authenticated: bool = True
auth_method: Literal["session", "api_key"] = "session"
user: AuthSessionUserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
session_id: str | None = None
api_key_id: str | None = None
expires_at: datetime | None = None
class ProfileUpdateRequest(BaseModel): class ProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -143,6 +165,40 @@ class PrincipalContextInfo(BaseModel):
display_name: str | None = None display_name: str | None = None
class AuthShellResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
principal: PrincipalContextInfo | None = None
profile_loaded: bool = False
roles_loaded: bool = False
groups_loaded: bool = False
class AuthProfileResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
profile_loaded: bool = True
class AuthRolesResponse(BaseModel):
roles: list[RoleInfo] = Field(default_factory=list)
roles_loaded: bool = True
class AuthGroupsResponse(BaseModel):
groups: list[GroupInfo] = Field(default_factory=list)
groups_loaded: bool = True
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
@@ -159,6 +215,9 @@ class LoginResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list) available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en" default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
class MeResponse(BaseModel): class MeResponse(BaseModel):
@@ -174,3 +233,6 @@ class MeResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list) available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en" default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
+78 -7
View File
@@ -3,8 +3,11 @@ from __future__ import annotations
from celery import Celery from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime from govoplan_core.core.runtime import configure_runtime
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database from govoplan_core.db.session import configure_database
@@ -13,7 +16,7 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
configure_database(settings.database_url) configure_database(settings.database_url)
celery = Celery( celery = Celery(
"multimailer", "govoplan",
broker=settings.redis_url, broker=settings.redis_url,
backend=settings.redis_url, backend=settings.redis_url,
) )
@@ -21,21 +24,31 @@ celery = Celery(
celery.conf.update( celery.conf.update(
task_default_queue="default", task_default_queue="default",
task_routes={ task_routes={
"multimailer.send_email": {"queue": "send_email"}, "govoplan.campaigns.send_email": {"queue": "send_email"},
"multimailer.append_sent": {"queue": "append_sent"}, "govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
}, },
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
task_acks_late=True, task_acks_late=True,
task_reject_on_worker_lost=True, task_reject_on_worker_lost=True,
beat_schedule={
"calendar-outbox-every-minute": {
"task": "govoplan.calendar.dispatch_outbox",
"schedule": 60.0,
"args": (None, 100),
},
},
) )
@celery.task(name="multimailer.ping") @celery.task(name="govoplan.ping")
def ping(): def ping():
return "pong" return "pong"
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider: def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules) raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules) candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True) available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
@@ -44,13 +57,36 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
context = ModuleContext(registry=registry, settings=settings) context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context) configure_runtime(context)
registry.configure_capability_context(context) registry.configure_capability_context(context)
return registry
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS) capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
if not isinstance(capability, CampaignDeliveryTaskProvider): if not isinstance(capability, CampaignDeliveryTaskProvider):
raise RuntimeError("Campaign delivery task capability is invalid") raise RuntimeError("Campaign delivery task capability is invalid")
return capability return capability
@celery.task(name="multimailer.send_email", bind=True, max_retries=0) def _notification_dispatch() -> NotificationDispatchProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
if not isinstance(capability, NotificationDispatchProvider):
raise RuntimeError("Notification dispatch capability is invalid")
return capability
def _calendar_outbox() -> CalendarOutboxProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
if not isinstance(capability, CalendarOutboxProvider):
raise RuntimeError("Calendar outbox capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str): def send_email(self, job_id: str):
"""Send one explicitly queued campaign job. """Send one explicitly queued campaign job.
@@ -65,7 +101,7 @@ def send_email(self, job_id: str):
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True)) return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None) @celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
def append_sent(self, job_id: str): def append_sent(self, job_id: str):
"""Append the exact sent MIME to the configured IMAP Sent folder.""" """Append the exact sent MIME to the configured IMAP Sent folder."""
@@ -78,3 +114,38 @@ def append_sent(self, job_id: str):
if getattr(exc, "temporary", None) is True: if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300) raise self.retry(exc=exc, countdown=300)
raise raise
@celery.task(name="govoplan.notifications.deliver", bind=True, max_retries=0)
def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
session.commit()
return result
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int = 50):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
"""Drain durable Calendar operations; retry timing lives in the database."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _calendar_outbox()
if provider is None:
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
+325 -192
View File
@@ -5,7 +5,6 @@ import json
from pathlib import Path from pathlib import Path
import sys import sys
import time import time
from typing import Any
from govoplan_core.core.module_installer import ( from govoplan_core.core.module_installer import (
ModuleInstallerError, ModuleInstallerError,
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
update_module_installer_request, update_module_installer_request,
update_module_installer_daemon_status, update_module_installer_daemon_status,
) )
from govoplan_core.core.module_installer_notifications import (
build_runtime_notification_registry,
emit_module_installer_notification,
installer_notification_body,
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import ( from govoplan_core.core.module_management import (
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings from govoplan_core.settings import settings
def main() -> int: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.") parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.") parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.") parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
@@ -95,202 +101,295 @@ def main() -> int:
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.") parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.") parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.") parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
args = parser.parse_args() return parser
def main() -> int:
args = _build_parser().parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url) runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try: try:
if args.issue_license: return _dispatch_command(args=args, runtime_dir=runtime_dir)
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
if args.validate_license is not None:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
if args.sign_package_catalog:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
if args.validate_package_catalog is not None:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="cli",
options=_request_options_from_args(args),
)
_print_result(request, output_format=args.format)
return 0
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
if args.apply or args.dry_run:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
except ModuleInstallerError as exc: except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 1 return 1
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
for handler in (
_handle_license_command,
_handle_catalog_command,
_handle_runtime_command,
_handle_queue_command,
_handle_run_history_command,
):
result = handler(args=args, runtime_dir=runtime_dir)
if result is not None:
return result
return _handle_install_command(args=args, runtime_dir=runtime_dir)
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.issue_license:
return _issue_license_command(args)
if args.validate_license is not None:
return _validate_license_command(args)
return None
def _issue_license_command(args: argparse.Namespace) -> int:
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
def _validate_license_command(args: argparse.Namespace) -> int:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.sign_package_catalog:
return _sign_package_catalog_command(args)
if args.validate_package_catalog is not None:
return _validate_package_catalog_command(args)
return None
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
return None
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
_print_result(request, output_format=args.format)
return 0
return None
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
return _rollback_command(args=args, runtime_dir=runtime_dir)
return None
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
if args.apply or args.dry_run:
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
def _supervise_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _apply_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _preflight_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
def _default_webui_root() -> Path: def _default_webui_root() -> Path:
return Path(__file__).resolve().parents[3] / "webui" return Path(__file__).resolve().parents[3] / "webui"
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
request_id = str(request["request_id"]) request_id = str(request["request_id"])
options = request.get("options") if isinstance(request.get("options"), dict) else {} options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url)) configure_database(str(args.database_url))
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
try: try:
available = available_module_manifests(ignore_load_errors=True) available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session: with get_database().session() as session:
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds), health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
request_context=_request_context(request), request_context=_request_context(request),
) )
update_module_installer_request( updated_request = update_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=request_id, request_id=request_id,
patch={ patch={
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"result": result.as_dict(), "result": result.as_dict(),
}, },
) )
_emit_installer_daemon_notification(
updated_request,
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
)
except Exception as exc: except Exception as exc:
update_module_installer_request( updated_request = update_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=request_id, request_id=request_id,
patch={ patch={
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"error": str(exc), "error": str(exc),
}, },
) )
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
tenant_id = str(request.get("tenant_id") or "")
if not tenant_id:
return
registry = build_runtime_notification_registry(settings)
if registry is None:
return
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
try:
with get_database().SessionLocal() as session:
emitted = emit_module_installer_notification(
session=session,
registry=registry,
tenant_id=tenant_id,
request=request,
event_kind=event_kind,
subject=installer_notification_subject(event_kind, request),
body_text=installer_notification_body(event_kind, request),
recipient_id=str(request.get("requested_by") or "") or None,
priority=installer_notification_priority(status_value),
)
if emitted:
session.commit()
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]: def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
context: dict[str, object] = { context: dict[str, object] = {
"request_id": request.get("request_id"), "request_id": request.get("request_id"),
"requested_by": request.get("requested_by"), "requested_by": request.get("requested_by"),
"tenant_id": request.get("tenant_id"),
} }
if request.get("retry_of"): if request.get("retry_of"):
context["retry_of"] = request["retry_of"] context["retry_of"] = request["retry_of"]
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
class CalendarCapabilityError(ValueError):
"""Stable error raised by Calendar capability implementations."""
@dataclass(frozen=True, slots=True)
class CalendarEventRequest:
summary: str
start_at: datetime
calendar_id: str | None = None
description: str | None = None
location: str | None = None
status: str = "CONFIRMED"
transparency: str = "OPAQUE"
classification: str = "PUBLIC"
end_at: datetime | None = None
timezone: str | None = None
attendees: tuple[Mapping[str, object], ...] = ()
categories: tuple[str, ...] = ()
related_to: tuple[Mapping[str, object], ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CalendarEventRef:
id: str
calendar_id: str
uid: str
external_state: str = "local"
outbox_operation_id: str | None = None
@runtime_checkable
class CalendarSchedulingProvider(Protocol):
def list_freebusy(
self,
session: object,
*,
tenant_id: str,
start_at: datetime,
end_at: datetime,
calendar_ids: Sequence[str] | None = None,
) -> Sequence[Mapping[str, object]]:
...
def create_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarEventRequest,
) -> CalendarEventRef:
...
@runtime_checkable
class CalendarOutboxProvider(Protocol):
"""Stable worker boundary for Calendar-owned external side effects."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_SCHEDULING):
return None
capability = registry.capability(CAPABILITY_CALENDAR_SCHEDULING)
return capability if isinstance(capability, CalendarSchedulingProvider) else None
def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
return None
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
return capability if isinstance(capability, CalendarOutboxProvider) else None
+146 -44
View File
@@ -8,8 +8,6 @@ from pathlib import Path
import json import json
import os import os
from typing import Any, Literal, Protocol, runtime_checkable from typing import Any, Literal, Protocol, runtime_checkable
import urllib.error
import urllib.request
from govoplan_core.core.module_package_catalog import ( from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes, _canonical_catalog_bytes,
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
_load_private_key, _load_private_key,
_parse_trusted_keys, _parse_trusted_keys,
) )
from govoplan_core.security.http_fetch import fetch_http_text
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider" CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"] PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
@dataclass(frozen=True, slots=True)
class _ConfigurationCatalogValidationState:
packages: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ConfigurationModuleRequirement: class ConfigurationModuleRequirement:
module_id: str module_id: str
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
configured = source is not None and _catalog_source_exists(source) configured = source is not None and _catalog_source_exists(source)
if source is not None and not _catalog_source_exists(source): if source is not None and not _catalog_source_exists(source):
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}") return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None} read_state = _configuration_catalog_default_read_state()
try: try:
payload, read_state = _read_catalog_payload_with_metadata(source)
packages = _normalize_catalog_packages(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
not_before = _catalog_optional_text(payload, "not_before")
expires_at = _catalog_optional_text(payload, "expires_at")
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys() effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys) state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload) read_state = state.read_state
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc: except Exception as exc:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc)) return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
if signature_state.get("fatal"): policy_error = _configuration_catalog_policy_error(
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"])) source,
if effective_approved_channels and channel not in effective_approved_channels: configured=configured,
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.") state=state,
if effective_require_trusted and not signature_state["trusted"]: require_trusted=effective_require_trusted,
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key.")) approved_channels=effective_approved_channels,
if not freshness["valid"]: )
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"])) if policy_error is not None:
if not replay["valid"]: return policy_error
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
if not signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]:
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return _validation_result( return _validation_result(
source, source,
valid=True, valid=True,
configured=configured, configured=configured,
read_state=read_state, read_state=state.read_state,
packages=packages, packages=state.packages,
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
warnings=_configuration_catalog_warnings(state),
)
def _configuration_catalog_validation_state(
source: Path | str | None,
*,
trusted_keys: dict[str, str],
) -> _ConfigurationCatalogValidationState:
payload, read_state = _read_catalog_payload_with_metadata(source)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
return _ConfigurationCatalogValidationState(
packages=_normalize_catalog_packages(payload),
channel=channel, channel=channel,
sequence=sequence, sequence=sequence,
generated_at=generated_at, generated_at=_catalog_optional_text(payload, "generated_at"),
not_before=not_before, not_before=_catalog_optional_text(payload, "not_before"),
expires_at=expires_at, expires_at=_catalog_optional_text(payload, "expires_at"),
signature_state=signature_state, signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
warnings=warnings, freshness=_catalog_freshness_state(payload),
replay=_catalog_replay_state(channel=channel, sequence=sequence),
read_state=read_state,
) )
def _configuration_catalog_policy_error(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
require_trusted: bool,
approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
if state.signature_state.get("fatal"):
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"]),
)
if approved_channels and state.channel not in approved_channels:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=(
f"Configuration package catalog channel {state.channel!r} is not approved. "
f"Approved channels: {', '.join(approved_channels)}."
),
)
if require_trusted and not state.signature_state["trusted"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
)
if not state.freshness["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.freshness["error"]),
)
if not state.replay["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.replay["error"]),
)
return None
def _configuration_catalog_invalid_result(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
error: str,
) -> dict[str, object]:
return _validation_result(
source,
configured=configured,
read_state=state.read_state,
packages=(),
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
error=error,
)
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
if not state.signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _configuration_catalog_default_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path: def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
payload = _read_catalog_payload(path) payload = _read_catalog_payload(path)
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -686,17 +792,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
return {"packages": []}, metadata return {"packages": []}, metadata
if isinstance(source, str) and _is_http_url(source): if isinstance(source, str) and _is_http_url(source):
try: try:
with urllib.request.urlopen(source, timeout=15) as response: body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return json.loads(body), metadata return json.loads(body), metadata
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
metadata["cache_used"] = True metadata["cache_used"] = True
return json.loads(cache_path.read_text(encoding="utf-8")), metadata return json.loads(cache_path.read_text(encoding="utf-8")), metadata
+84 -24
View File
@@ -97,6 +97,16 @@ class ConfigurationChangeSafetyPlan:
} }
@dataclass(frozen=True, slots=True)
class _ConfigurationChangeSafetyState:
field: ConfigurationFieldSafety
missing_scopes: tuple[str, ...]
blockers: tuple[str, ...]
warnings: tuple[str, ...]
approval_required: bool
approval_satisfied: bool
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = ( _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
ConfigurationFieldSafety( ConfigurationFieldSafety(
key="module_management.desired_enabled", key="module_management.desired_enabled",
@@ -339,23 +349,56 @@ def plan_configuration_change(
) -> ConfigurationChangeSafetyPlan: ) -> ConfigurationChangeSafetyPlan:
field = classify_configuration_field(key) field = classify_configuration_field(key)
if field is None: if field is None:
return ConfigurationChangeSafetyPlan( return _unknown_configuration_plan(key)
key=key,
allowed=False,
field=None,
blockers=("unknown_configuration_field",),
policy_explanation="This setting is not in the configuration safety catalog.",
)
if not include_env_only and not field.ui_managed: if not include_env_only and not field.ui_managed:
return ConfigurationChangeSafetyPlan( return _deployment_managed_configuration_plan(key, field)
key=key, state = _configuration_change_safety_state(
allowed=False, field,
field=field, actor_scopes=actor_scopes,
risk=field.risk, value=value,
secret_handling=field.secret_handling, dry_run=dry_run,
blockers=("deployment_managed",), maintenance_mode=maintenance_mode,
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.", approval_count=approval_count,
) )
return _configuration_change_safety_plan(
field,
state=state,
dry_run=dry_run,
maintenance_mode=maintenance_mode,
)
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=key,
allowed=False,
field=None,
blockers=("unknown_configuration_field",),
policy_explanation="This setting is not in the configuration safety catalog.",
)
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=key,
allowed=False,
field=field,
risk=field.risk,
secret_handling=field.secret_handling,
blockers=("deployment_managed",),
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
)
def _configuration_change_safety_state(
field: ConfigurationFieldSafety,
*,
actor_scopes: tuple[str, ...] | list[str],
value: object,
dry_run: bool,
maintenance_mode: bool,
approval_count: int,
) -> _ConfigurationChangeSafetyState:
blockers: list[str] = [] blockers: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope)) missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
@@ -377,24 +420,41 @@ def plan_configuration_change(
blockers.append("env_only_secret") blockers.append("env_only_secret")
if field.rollback_history_required: if field.rollback_history_required:
warnings.append("rollback_history_required") warnings.append("rollback_history_required")
return ConfigurationChangeSafetyPlan( return _ConfigurationChangeSafetyState(
key=field.key,
allowed=not blockers,
field=field, field=field,
risk=field.risk,
missing_scopes=missing_scopes, missing_scopes=missing_scopes,
dry_run_required=field.dry_run_required, blockers=tuple(dict.fromkeys(blockers)),
dry_run_satisfied=not field.dry_run_required or dry_run, warnings=tuple(dict.fromkeys(warnings)),
approval_required=approval_required, approval_required=approval_required,
approval_satisfied=approval_satisfied, approval_satisfied=approval_satisfied,
)
def _configuration_change_safety_plan(
field: ConfigurationFieldSafety,
*,
state: _ConfigurationChangeSafetyState,
dry_run: bool,
maintenance_mode: bool,
) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=field.key,
allowed=not state.blockers,
field=field,
risk=field.risk,
missing_scopes=state.missing_scopes,
dry_run_required=field.dry_run_required,
dry_run_satisfied=not field.dry_run_required or dry_run,
approval_required=state.approval_required,
approval_satisfied=state.approval_satisfied,
maintenance_required=field.maintenance_required, maintenance_required=field.maintenance_required,
maintenance_satisfied=not field.maintenance_required or maintenance_mode, maintenance_satisfied=not field.maintenance_required or maintenance_mode,
rollback_history_required=field.rollback_history_required, rollback_history_required=field.rollback_history_required,
secret_handling=field.secret_handling, secret_handling=field.secret_handling,
audit_event=field.audit_event, audit_event=field.audit_event,
policy_explanation=_policy_explanation(field), policy_explanation=_policy_explanation(field),
blockers=tuple(dict.fromkeys(blockers)), blockers=state.blockers,
warnings=tuple(dict.fromkeys(warnings)), warnings=state.warnings,
) )
+13
View File
@@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable
IDENTITY_MODULE_ID = "identity" IDENTITY_MODULE_ID = "identity"
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory" CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
CAPABILITY_IDENTITY_SEARCH = "identity.search"
IdentityStatus = Literal["active", "inactive", "suspended"] IdentityStatus = Literal["active", "inactive", "suspended"]
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]: def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
... ...
@runtime_checkable
class IdentitySearchProvider(Protocol):
def search_identities(
self,
query: str | None = None,
*,
include_inactive: bool = False,
limit: int = 25,
) -> Sequence[IdentityRef]:
...
+126 -70
View File
@@ -5,7 +5,7 @@ import json
import os import os
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Literal from typing import Literal
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
@@ -72,6 +72,25 @@ class ConfigValidationResult:
return "\n".join(lines) return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class _RuntimeProfile:
name: str
production: bool
production_like: bool
local: bool
@dataclass(slots=True)
class _ConfigIssueCollector:
strict: bool
issues: list[ConfigIssue]
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if self.strict and level == "warning":
level = "error"
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"} _LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"} _PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"} _PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
@@ -114,95 +133,130 @@ def validate_runtime_configuration(
strict: bool = False, strict: bool = False,
) -> ConfigValidationResult: ) -> ConfigValidationResult:
env = dict(os.environ if environ is None else environ) env = dict(os.environ if environ is None else environ)
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")) runtime = _runtime_profile(env, profile=profile)
issues: list[ConfigIssue] = [] collector = _ConfigIssueCollector(strict=strict, issues=[])
_validate_app_env(env, runtime, collector)
_validate_database_settings(env, runtime, collector)
_validate_master_key(env, runtime, collector)
_validate_enabled_modules(env, runtime, collector)
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"} production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
local = clean_profile in _LOCAL_PROFILES and not production_like return _RuntimeProfile(
name=clean_profile,
production=production,
production_like=production_like,
local=clean_profile in _LOCAL_PROFILES and not production_like,
)
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if strict and level == "warning":
level = "error"
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
app_env = _clean(env.get("APP_ENV")) app_env = _clean(env.get("APP_ENV"))
if not app_env and production_like: if not app_env and runtime.production_like:
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.") collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
elif app_env.lower() in {"dev", "test", "local"} and production_like: elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.") collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
database_url = _clean(env.get("DATABASE_URL")) database_url = _clean(env.get("DATABASE_URL"))
if not database_url: if not database_url:
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.") collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
else: return
backend = _database_backend(database_url) backend = _database_backend(database_url)
if backend is None: if backend is None:
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.") collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
elif backend == "sqlite" and production_like: return
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.") if backend == "sqlite" and runtime.production_like:
elif backend != "postgresql" and production: collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.") elif backend != "postgresql" and runtime.production:
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")): collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.") if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
master_key = _clean(env.get("MASTER_KEY_B64")) master_key = _clean(env.get("MASTER_KEY_B64"))
if not master_key and not local: if not master_key and not runtime.local:
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.") collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
elif master_key: return
error = _master_key_error(master_key) if not master_key:
if error: return
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.") error = _master_key_error(master_key)
elif "change-me" in master_key.lower() or "generate" in master_key.lower(): if error:
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.") collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
enabled_modules = _csv(env.get("ENABLED_MODULES")) enabled_modules = _csv(env.get("ENABLED_MODULES"))
if not enabled_modules and production_like: if not enabled_modules and runtime.production_like:
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.") collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
elif "access" not in enabled_modules and production_like: elif "access" not in enabled_modules and runtime.production_like:
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.") collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
elif enabled_modules and "admin" not in enabled_modules and production_like: elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.") collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
if celery_enabled and not _clean(env.get("REDIS_URL")):
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")): def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.") if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
cors_origins = _csv(env.get("CORS_ORIGINS")) cors_origins = _csv(env.get("CORS_ORIGINS"))
if production_like and not cors_origins: if runtime.production_like and not cors_origins:
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.") collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
elif "*" in cors_origins and production_like: elif "*" in cors_origins and runtime.production_like:
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.") collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS: elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.") collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local" storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
if storage_backend == "local": if storage_backend == "local":
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like: _validate_local_file_storage(env, runtime, collector)
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif production:
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
elif storage_backend == "s3": elif storage_backend == "s3":
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"): _validate_s3_file_storage(env, collector)
if not _clean(env.get(key)):
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
else: else:
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.") collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif runtime.production:
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
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")) catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
if production and catalog_source: if not runtime.production or not catalog_source:
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")): return
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.") if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")): collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.") if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
def _self_hosted_env_template(master_key: str) -> str: def _self_hosted_env_template(master_key: str) -> str:
@@ -220,7 +274,8 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0 REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
CORS_ORIGINS=https://govoplan.example.org CORS_ORIGINS=https://govoplan.example.org
AUTH_COOKIE_SECURE=true AUTH_COOKIE_SECURE=true
@@ -263,7 +318,8 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0 REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops 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 CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
@@ -299,8 +355,8 @@ def _master_key_error(value: str) -> str | None:
try: try:
Fernet(candidate) Fernet(candidate)
return None return None
except Exception: except (TypeError, ValueError):
pass raw = None
try: try:
raw = base64.b64decode(candidate) raw = base64.b64decode(candidate)
except Exception: except Exception:
@@ -309,6 +365,6 @@ def _master_key_error(value: str) -> str | None:
return "MASTER_KEY_B64 must decode to exactly 32 bytes." return "MASTER_KEY_B64 must decode to exactly 32 bytes."
try: try:
Fernet(base64.urlsafe_b64encode(raw)) Fernet(base64.urlsafe_b64encode(raw))
except Exception: except (TypeError, ValueError):
return "MASTER_KEY_B64 is not usable as a Fernet key." return "MASTER_KEY_B64 is not usable as a Fernet key."
return None return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
def emit_module_installer_notification(
*,
session: object,
registry: object | None,
tenant_id: str | None,
request: Mapping[str, object],
event_kind: str,
subject: str,
body_text: str,
recipient_id: str | None = None,
priority: int = 5,
) -> bool:
if not tenant_id:
return False
provider = notification_dispatch_provider(registry)
if provider is None:
return False
request_id = str(request.get("request_id") or "")
if not request_id:
return False
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=tenant_id,
source_module="core",
source_resource_type="module_install_request",
source_resource_id=request_id,
event_kind=event_kind,
channel="inbox",
recipient_type="user" if recipient_id else None,
recipient_id=recipient_id,
subject=subject,
body_text=body_text,
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
priority=priority,
payload={
"request_id": request_id,
"status": request.get("status"),
"run_id": _result_run_id(request),
},
metadata={
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
"retry_of": request.get("retry_of"),
},
),
enqueue_delivery=False,
)
return True
def build_runtime_notification_registry(settings: object) -> object | None:
try:
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
if "notifications" not in enabled_modules:
return None
registry = build_platform_registry(enabled_modules)
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
return registry
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return None
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
request_id = str(request.get("request_id") or "unknown")
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
prefixes = {
"queued": "Module installer request queued",
"running": "Module installer request started",
"completed": "Module installer request completed",
"failed": "Module installer request failed",
"cancelled": "Module installer request cancelled",
}
prefix = prefixes.get(status, "Module installer request updated")
return ": ".join((prefix, request_id))
def _installer_status_sentence(request_id: str, status: str) -> str:
return " ".join(("Installer request", request_id, "is", status))
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
request_id = str(request.get("request_id") or "unknown")
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
error = str(request.get("error") or result.get("error") or "").strip()
sentence = _installer_status_sentence(request_id, status)
if error:
return ". Error: ".join((sentence, error))
run_id = _result_run_id(request)
if run_id:
return ". Run: ".join((sentence, run_id))
return sentence + "."
def installer_notification_priority(status: str) -> int:
if status == "failed":
return 20
if status in {"completed", "cancelled"}:
return 8
if status == "running":
return 4
return 5
def _result_run_id(request: Mapping[str, object]) -> str | None:
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
return str(run_id) if run_id else None
+3 -7
View File
@@ -7,12 +7,11 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.security.http_fetch import fetch_http_text
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]: def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
@@ -257,17 +256,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not url.startswith(("https://", "http://")):
raise ValueError("Trusted license key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise
+72 -24
View File
@@ -73,6 +73,23 @@ class ModuleInstallPlanItem:
return payload return payload
@dataclass(frozen=True, slots=True)
class _NormalizedModuleInstallPlanItem:
module_id: str
action: str
source: str
catalog: Mapping[str, object] | None
status: str
python_package: str | None
python_ref: str | None
webui_package: str | None
webui_ref: str | None
artifact_integrity: Mapping[str, object] | None
data_safety_acknowledged: bool
destroy_data: bool
notes: str | None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ModuleInstallPlan: class ModuleInstallPlan:
items: tuple[ModuleInstallPlanItem, ...] = () items: tuple[ModuleInstallPlanItem, ...] = ()
@@ -291,6 +308,28 @@ def desired_modules_after_package_plan(
def normalize_module_install_plan_item( def normalize_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem, item: Mapping[str, object] | ModuleInstallPlanItem,
) -> ModuleInstallPlanItem: ) -> ModuleInstallPlanItem:
normalized = _normalized_module_install_plan_item(item)
_validate_module_install_plan_item(normalized)
return ModuleInstallPlanItem(
module_id=normalized.module_id,
action=normalized.action,
source=normalized.source,
catalog=normalized.catalog,
python_package=normalized.python_package,
python_ref=normalized.python_ref,
webui_package=normalized.webui_package,
webui_ref=normalized.webui_ref,
artifact_integrity=normalized.artifact_integrity,
data_safety_acknowledged=normalized.data_safety_acknowledged,
destroy_data=normalized.destroy_data,
status=normalized.status,
notes=normalized.notes,
)
def _normalized_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem,
) -> _NormalizedModuleInstallPlanItem:
if isinstance(item, ModuleInstallPlanItem): if isinstance(item, ModuleInstallPlanItem):
raw = item.as_dict() raw = item.as_dict()
elif isinstance(item, Mapping): elif isinstance(item, Mapping):
@@ -311,33 +350,12 @@ def normalize_module_install_plan_item(
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged")) data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
destroy_data = _clean_bool(raw.get("destroy_data")) destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes")) notes = _clean_optional_string(raw.get("notes"))
return _NormalizedModuleInstallPlanItem(
if action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
if status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
if action in {"install", "update"} and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
if action != "uninstall" and destroy_data:
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
if action == "uninstall" and webui_ref and not webui_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
if python_ref:
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
if webui_ref:
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
return ModuleInstallPlanItem(
module_id=module_id, module_id=module_id,
action=action, action=action,
source=source, source=source,
catalog=catalog, catalog=catalog,
status=status,
python_package=python_package, python_package=python_package,
python_ref=python_ref, python_ref=python_ref,
webui_package=webui_package, webui_package=webui_package,
@@ -345,11 +363,41 @@ def normalize_module_install_plan_item(
artifact_integrity=artifact_integrity, artifact_integrity=artifact_integrity,
data_safety_acknowledged=data_safety_acknowledged, data_safety_acknowledged=data_safety_acknowledged,
destroy_data=destroy_data, destroy_data=destroy_data,
status=status,
notes=notes, notes=notes,
) )
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
if item.status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
if item.source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
_validate_module_install_plan_item_requirements(item)
_validate_module_install_plan_item_refs(item)
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action in {"install", "update"} and not item.python_ref:
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
if item.action == "uninstall" and not item.python_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
if item.action != "uninstall" and item.destroy_data:
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
if item.python_ref:
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
if item.webui_ref:
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
def plan_desired_enabled_modules( def plan_desired_enabled_modules(
requested_enabled: Iterable[str], requested_enabled: Iterable[str],
available: Mapping[str, ModuleManifest], available: Mapping[str, ModuleManifest],
+149 -174
View File
@@ -3,20 +3,20 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import json import json
import os import os
import re import re
from typing import Any from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") _INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive") CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
@@ -28,6 +28,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
) )
@dataclass(frozen=True, slots=True)
class _CatalogValidationState:
modules: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
def module_package_catalog( def module_package_catalog(
path: Path | str | None = None, path: Path | str | None = None,
*, *,
@@ -61,178 +75,143 @@ def validate_module_package_catalog(
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys() effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
if catalog_source is not None and not _catalog_source_exists(catalog_source): if catalog_source is not None and not _catalog_source_exists(catalog_source):
return { return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
"valid": False,
"configured": True,
"path": str(catalog_source),
"source": str(catalog_source),
"source_type": _catalog_source_type(catalog_source),
"cache_used": False,
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"not_before": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": f"Module package catalog does not exist: {catalog_source}",
}
warnings: list[str] = []
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
try: try:
payload, read_state = _read_catalog_payload_with_metadata(catalog_source) state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
not_before = _catalog_optional_text(payload, "not_before")
expires_at = _catalog_optional_text(payload, "expires_at")
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc: except Exception as exc:
return { return _catalog_error_result(catalog_source, error=str(exc))
"valid": False, policy_error = _catalog_policy_error(
"configured": catalog_source is not None, catalog_source,
"path": str(catalog_source) if catalog_source is not None else None, state,
"source": str(catalog_source) if catalog_source is not None else None, require_trusted=effective_require_trusted,
"source_type": _catalog_source_type(catalog_source), approved_channels=effective_approved_channels,
"cache_used": bool(read_state.get("cache_used")), )
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None, if policy_error is not None:
"modules": [], return policy_error
"channel": None, return _valid_catalog_result(catalog_source, state)
"sequence": None,
"generated_at": None,
"not_before": None, def _catalog_validation_state(
"expires_at": None, source: Path | str | None,
"signed": False, *,
"trusted": False, trusted_keys: dict[str, str],
"key_id": None, ) -> _CatalogValidationState:
"warnings": [], payload, read_state = _read_catalog_payload_with_metadata(source)
"error": str(exc), modules = _normalize_catalog_modules(payload)
} channel = _catalog_channel(payload)
if signature_state.get("fatal"): sequence = _catalog_sequence(payload)
return { return _CatalogValidationState(
"valid": False, modules=modules,
"configured": catalog_source is not None, channel=channel,
"path": str(catalog_source) if catalog_source is not None else None, sequence=sequence,
"source": str(catalog_source) if catalog_source is not None else None, generated_at=_catalog_optional_text(payload, "generated_at"),
"source_type": _catalog_source_type(catalog_source), not_before=_catalog_optional_text(payload, "not_before"),
"cache_used": bool(read_state.get("cache_used")), expires_at=_catalog_optional_text(payload, "expires_at"),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None, signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
"modules": [], freshness=_catalog_freshness_state(payload),
"channel": channel, replay=_catalog_replay_state(channel=channel, sequence=sequence),
"sequence": sequence, read_state=read_state,
"generated_at": generated_at, )
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"], def _catalog_policy_error(
"trusted": signature_state["trusted"], source: Path | str | None,
"key_id": signature_state["key_id"], state: _CatalogValidationState,
"warnings": [], *,
"error": str(signature_state["error"] or "Module package catalog signature is invalid."), require_trusted: bool,
} approved_channels: tuple[str, ...],
if effective_approved_channels and channel not in effective_approved_channels: ) -> dict[str, object] | None:
return { if state.signature_state.get("fatal"):
"valid": False, return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
"configured": catalog_source is not None, if approved_channels and state.channel not in approved_channels:
"path": str(catalog_source) if catalog_source is not None else None, return _invalid_catalog_state_result(
"source": str(catalog_source) if catalog_source is not None else None, source,
"source_type": _catalog_source_type(catalog_source), state,
"cache_used": bool(read_state.get("cache_used")), error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
}
if effective_require_trusted and not signature_state["trusted"]:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": False,
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
}
if not freshness["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel,
sequence=sequence,
generated_at=generated_at,
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state,
read_state=read_state,
error=str(freshness["error"]),
) )
if not replay["valid"]: if require_trusted and not state.signature_state["trusted"]:
return _invalid_catalog_result( return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
catalog_source, if not state.freshness["valid"]:
modules=(), return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
channel=channel, if not state.replay["valid"]:
sequence=sequence, return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
generated_at=generated_at, return None
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state, def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
read_state=read_state, return _invalid_catalog_result(
error=str(replay["error"]), source,
) modules=(),
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item) channel=None,
warnings.extend(str(item) for item in replay.get("warnings", ()) if item) sequence=None,
warnings.extend(_catalog_interface_warnings(modules)) generated_at=None,
if not signature_state["signed"]: not_before=None,
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.") expires_at=None,
elif not signature_state["trusted"]: signature_state=_unsigned_catalog_signature_state(),
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key.")) read_state=_default_catalog_read_state(),
error=error,
)
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
return _invalid_catalog_result(
source,
modules=(),
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
read_state=state.read_state,
error=error,
)
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
return { return {
"valid": True, "valid": True,
"configured": catalog_source is not None and _catalog_source_exists(catalog_source), "configured": source is not None and _catalog_source_exists(source),
"path": str(catalog_source) if catalog_source is not None else None, "path": str(source) if source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None, "source": str(source) if source is not None else None,
"source_type": _catalog_source_type(catalog_source), "source_type": _catalog_source_type(source),
"cache_used": bool(read_state.get("cache_used")), "cache_used": bool(state.read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None, "cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
"modules": list(modules), "modules": list(state.modules),
"channel": channel, "channel": state.channel,
"sequence": sequence, "sequence": state.sequence,
"generated_at": generated_at, "generated_at": state.generated_at,
"not_before": not_before, "not_before": state.not_before,
"expires_at": expires_at, "expires_at": state.expires_at,
"signed": signature_state["signed"], "signed": state.signature_state["signed"],
"trusted": signature_state["trusted"], "trusted": state.signature_state["trusted"],
"key_id": signature_state["key_id"], "key_id": state.signature_state["key_id"],
"warnings": warnings, "warnings": _catalog_validation_warnings(state),
"error": None, "error": None,
} }
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
warnings: list[str] = []
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
warnings.extend(_catalog_interface_warnings(state.modules))
if not state.signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _default_catalog_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
def _unsigned_catalog_signature_state() -> dict[str, object]:
return {"signed": False, "trusted": False, "key_id": None}
def sign_module_package_catalog( def sign_module_package_catalog(
*, *,
path: Path, path: Path,
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]: def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
cache_path = _configured_catalog_cache_path() cache_path = _configured_catalog_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body, False return body, False
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8"), True return cache_path.read_text(encoding="utf-8"), True
raise raise
@@ -935,7 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
def _is_http_url(value: str) -> bool: def _is_http_url(value: str) -> bool:
return value.startswith(("https://", "http://")) return is_http_url(value)
def _invalid_catalog_result( def _invalid_catalog_result(
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
@dataclass(frozen=True, slots=True)
class NotificationDispatchRequest:
tenant_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
event_kind: str
channel: str = "inbox"
recipient: str | None = None
recipient_type: str | None = None
recipient_id: str | None = None
recipient_label: str | None = None
subject: str | None = None
body_text: str | None = None
body_html: str | None = None
action_url: str | None = None
priority: int = 0
not_before_at: datetime | None = None
payload: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationDispatchProvider(Protocol):
def enqueue_notification(
self,
session: object,
request: NotificationDispatchRequest,
*,
enqueue_delivery: bool = True,
) -> Mapping[str, object]:
...
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
...
def deliver_pending(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
return None
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
return capability if isinstance(capability, NotificationDispatchProvider) else None
+64
View File
@@ -5,8 +5,10 @@ from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_chec
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"] PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention" CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign") POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -126,6 +128,55 @@ class PolicyDecision:
} }
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.
``requested_visibility`` is the Scheduling-owned configuration result. A
policy provider may preserve or reduce it, but must not broaden it. Core
deliberately defines no fallback when the optional capability is absent;
that secure default remains the responsibility of Scheduling.
"""
tenant_id: str
scheduling_request_id: str
participant_id: str
requested_visibility: SchedulingParticipantVisibility
actor_user_id: str | None = None
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyDecision:
"""Policy-resolved visibility of other participants' names and statuses."""
effective_visibility: SchedulingParticipantVisibility
reason: str | None = None
source_path: tuple[PolicySourceStep, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"effective_visibility": self.effective_visibility,
"reason": self.reason,
"source_path": [step.to_dict() for step in self.source_path],
"details": dict(self.details),
}
@runtime_checkable
class SchedulingParticipantPrivacyPolicy(Protocol):
"""Optional policy hook for Scheduling participant-roster disclosure."""
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
...
@runtime_checkable @runtime_checkable
class PrivacyRetentionService(Protocol): class PrivacyRetentionService(Protocol):
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any: def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
@@ -160,3 +211,16 @@ class PrivacyRetentionService(Protocol):
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any: def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
... ...
def scheduling_participant_privacy_policy(
registry: object | None,
) -> SchedulingParticipantPrivacyPolicy | None:
"""Return the optional provider without selecting a visibility fallback."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
return None
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
+267
View File
@@ -0,0 +1,267 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_POLL_SCHEDULING = "poll.scheduling"
class PollCapabilityError(ValueError):
"""Stable error raised by Poll capability implementations."""
@dataclass(frozen=True, slots=True)
class PollOptionRequest:
label: str
key: str | None = None
description: str | None = None
value: Mapping[str, object] | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollCreateCommand:
title: str
kind: str
status: str
visibility: str
result_visibility: str
description: str | None = None
context_module: str | None = None
context_resource_type: str | None = None
context_resource_id: str | None = None
workflow_state: str | None = None
workflow_steps: tuple[Mapping[str, object], ...] = ()
allow_anonymous: bool = False
allow_response_update: bool = True
min_choices: int = 1
max_choices: int | None = None
opens_at: datetime | None = None
closes_at: datetime | None = None
options: tuple[PollOptionRequest, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollUpdateCommand:
"""Scheduling-owned Poll fields synchronized as one policy snapshot."""
title: str
description: str | None
visibility: str
result_visibility: str
allow_anonymous: bool
allow_response_update: bool
closes_at: datetime | None
@dataclass(frozen=True, slots=True)
class PollOptionUpdateCommand:
"""Complete mutable option snapshot supplied by an owning module.
Providers must treat an exact repeat as a no-op. When the snapshot
changes, answers for this option are invalidated while answers for other
options are retained.
"""
label: str
description: str | None = None
value: Mapping[str, object] | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollInvitationCommand:
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 PollAnswerRequest:
option_id: str | None = None
option_key: str | None = None
value: object = None
rank: int | None = None
@dataclass(frozen=True, slots=True)
class PollSubmitResponseCommand:
respondent_id: str
respondent_label: str | None = None
answers: tuple[PollAnswerRequest, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionRef:
id: str
position: int
@dataclass(frozen=True, slots=True)
class PollRef:
id: str
status: str
options: tuple[PollOptionRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PollInvitationRef:
id: str
token: str
@dataclass(frozen=True, slots=True)
class PollAnswerRef:
option_id: str | None = None
option_key: str | None = None
value: object = None
rank: int | None = None
@dataclass(frozen=True, slots=True)
class PollResponseRef:
invitation_id: str | None
submitted_at: datetime
# Optional for backwards compatibility with existing providers. A
# consumer can use it to reconcile an authenticated response without
# trusting client-supplied invitation metadata.
respondent_id: str | None = None
# Optional for backwards compatibility. Providers that support response
# editing expose the authoritative, still-valid answers here.
answers: tuple[PollAnswerRef, ...] = ()
@runtime_checkable
class PollSchedulingProvider(Protocol):
def create_poll(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
command: PollCreateCommand,
) -> PollRef:
...
def create_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollInvitationCommand,
) -> PollInvitationRef:
...
def update_poll(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollUpdateCommand,
) -> PollRef:
...
def update_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str,
command: PollOptionUpdateCommand,
) -> PollOptionRef:
"""Update one option and invalidate only answers for that option."""
...
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
def decide_poll(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str | None,
) -> PollRef:
...
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
def set_workflow_context(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
workflow_state: str,
workflow_steps: Sequence[Mapping[str, object]],
context_module: str,
context_resource_type: str,
context_resource_id: str,
) -> PollRef:
...
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
...
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
...
def get_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
respondent_ids: Sequence[str],
invitation_id: str | None = None,
) -> PollResponseRef | None:
"""Return one response matching server-trusted participant identities."""
...
@runtime_checkable
class PollResponseSubmissionProvider(Protocol):
"""Optional extension for modules that collect responses through Poll."""
def submit_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollSubmitResponseCommand,
) -> PollResponseRef:
...
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
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, PollSchedulingProvider) else None
def poll_response_submission_provider(
registry: object | None,
) -> PollResponseSubmissionProvider | None:
provider = poll_scheduling_provider(registry)
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
+112 -54
View File
@@ -186,46 +186,20 @@ class PlatformRegistry:
def validate(self) -> RegistrySnapshot: def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted()) ordered = tuple(self._topologically_sorted())
available_capabilities = set(self._capability_factories) available_capabilities = set(self._capability_factories)
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered: for manifest in ordered:
_validate_manifest_shape(manifest) _validate_manifest_shape(manifest)
for dependency in manifest.dependencies: _validate_manifest_relationships(
if dependency not in self._manifests: manifest,
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}") known_modules=self._manifests,
for capability in manifest.required_capabilities: available_capabilities=available_capabilities,
if capability not in available_capabilities: )
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}") permissions = _collect_manifest_permissions(ordered)
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
for permission in manifest.permissions:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission
_validate_interface_closure(ordered) _validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
known_scopes = set(seen_permissions)
for manifest in ordered:
for template in manifest.role_templates:
for scope in template.permissions:
if scope in {"*", "tenant:*", "system:*"}:
continue
if _WILDCARD_RE.match(scope):
continue
if scope not in known_scopes:
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
return RegistrySnapshot( return RegistrySnapshot(
manifests=ordered, manifests=ordered,
permissions=tuple(seen_permissions.values()), permissions=tuple(permissions.values()),
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates), role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
nav_items=self.nav_items(), nav_items=self.nav_items(),
) )
@@ -299,7 +273,80 @@ def _attribute_delete_veto_issue(
return replace(issue, module_id=issue.module_id or registration.module_id, details=details) return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
def _validate_manifest_relationships(
manifest: ModuleManifest,
*,
known_modules: Mapping[str, ModuleManifest],
available_capabilities: set[str],
) -> None:
for dependency in manifest.dependencies:
if dependency not in known_modules:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
for capability in manifest.required_capabilities:
if capability not in available_capabilities:
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
permissions: dict[str, PermissionDefinition] = {}
for manifest in manifests:
for permission in manifest.permissions:
_validate_manifest_permission(manifest, permission, permissions)
permissions[permission.scope] = permission
return permissions
def _validate_manifest_permission(
manifest: ModuleManifest,
permission: PermissionDefinition,
seen_permissions: Mapping[str, PermissionDefinition],
) -> None:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
def _validate_role_template_scopes(
manifests: tuple[ModuleManifest, ...],
*,
known_scopes: set[str],
) -> None:
for manifest in manifests:
for template in manifest.role_templates:
for scope in template.permissions:
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
if scope in {"*", "tenant:*", "system:*"}:
return True
if _WILDCARD_RE.match(scope):
return True
return scope in known_scopes
def _validate_manifest_shape(manifest: ModuleManifest) -> None: def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_identity(manifest)
_validate_manifest_contract_lists(manifest)
_validate_manifest_overlaps(manifest)
_validate_manifest_migration_spec(manifest)
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
if not _MODULE_ID_RE.match(manifest.id): if not _MODULE_ID_RE.match(manifest.id):
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}") raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
if not manifest.name.strip(): if not manifest.name.strip():
@@ -313,12 +360,17 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}" f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
) )
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies) _validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies) _validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities) _validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities) _validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces) _validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces) _validate_interface_requirements(manifest.id, manifest.requires_interfaces)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies) overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap: if overlap:
joined = ", ".join(sorted(overlap)) joined = ", ".join(sorted(overlap))
@@ -328,36 +380,42 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
joined = ", ".join(sorted(capability_overlap)) joined = ", ".join(sorted(capability_overlap))
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}") raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
if manifest.migration_spec is not None: if manifest.migration_spec is not None:
if manifest.migration_spec.module_id != manifest.id: if manifest.migration_spec.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}") raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location: if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location") raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
if manifest.frontend is not None:
frontend = manifest.frontend
if frontend.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
f"{frontend.asset_manifest_contract_version!r}; supported version is "
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
)
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):
if not route.path.startswith("/"):
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
if not route.component.strip():
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
for item in manifest.nav_items: def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
if manifest.frontend is None:
return
frontend = manifest.frontend
if frontend.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
f"{frontend.asset_manifest_contract_version!r}; supported version is "
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
)
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):
_validate_frontend_route(manifest.id, route.path, route.component)
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item) _validate_nav_item(manifest.id, item)
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
if not path.startswith("/"):
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
if not component.strip():
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None: def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list) providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests: for manifest in manifests:
+40
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
_context: ModuleContext | None = None _context: ModuleContext | None = None
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
def get_registry() -> object | None: def get_registry() -> object | None:
return _context.registry if _context is not None else None return _context.registry if _context is not None else None
class ModuleSettingsProxy:
def __init__(self, runtime: ModuleRuntimeState) -> None:
self._runtime = runtime
def __getattr__(self, name: str) -> Any:
return getattr(self._runtime.get_settings(), name)
class ModuleRuntimeState:
def __init__(self, module_name: str) -> None:
self.module_name = module_name
self.settings = ModuleSettingsProxy(self)
self._registry: object | None = None
self._settings: object | None = None
def configure_runtime(self, *, registry: object | None = None, settings: object | None = None) -> None:
if registry is not None:
self._registry = registry
if settings is not None:
self._settings = settings
def clear_runtime(self) -> None:
self._registry = None
self._settings = None
def get_registry(self) -> object | None:
return self._registry
def get_settings(self) -> object:
if self._settings is not None:
return self._settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError(f"GovOPlaN {self.module_name} runtime settings are not configured") from exc
return legacy_settings
@@ -0,0 +1,68 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Literal
from sqlalchemy import inspect
ChangeOperation = Literal["created", "updated", "deleted"]
def object_state(obj: object) -> Any:
return inspect(obj)
def has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
def previous_value(obj: object, attr_name: str) -> str | None:
state = object_state(obj)
if attr_name not in state.attrs:
return None
history = state.attrs[attr_name].history
if not history.has_changes() or not history.deleted:
return None
value = history.deleted[0]
return str(value) if value is not None else None
def ensure_object_id(obj: object, new_id: Callable[[], str]) -> str:
resource_id = getattr(obj, "id", None)
if resource_id:
return str(resource_id)
resource_id = new_id()
setattr(obj, "id", resource_id)
return resource_id
def operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not has_attr_changes(state, changed_attrs):
return None
return "updated"
def operation_for_soft_deletable(
obj: object,
*,
changed_attrs: tuple[str, ...],
deleted_attr: str = "deleted_at",
) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if not has_attr_changes(state, changed_attrs):
return None
if deleted_attr in state.attrs:
history = state.attrs[deleted_attr].history
if history.has_changes():
if any(value is not None for value in history.added):
return "deleted"
if any(value is not None for value in history.deleted):
return "created"
return "updated"
+267 -135
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
import re
from typing import Any from typing import Any
from alembic import command from alembic import command
@@ -24,6 +26,8 @@ from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings from govoplan_core.settings import settings
logger = logging.getLogger(__name__)
# Historic development databases could be created partly through Alembic and # Historic development databases could be created partly through Alembic and
# partly through Base.metadata.create_all(). In that state Alembic still says # partly through Base.metadata.create_all(). In that state Alembic still says
# "2c..." while the 3d/4e file-storage tables already exist, so a normal # "2c..." while the 3d/4e file-storage tables already exist, so a normal
@@ -44,6 +48,7 @@ MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES
MIGRATION_TRACK_RELEASE = "release" MIGRATION_TRACK_RELEASE = "release"
MIGRATION_TRACK_DEV = "dev" MIGRATION_TRACK_DEV = "dev"
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV) MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_NAMESPACE_TABLE_RENAMES = ( _NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"), ("tenants", "tenancy_tenants"),
@@ -164,6 +169,15 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
} }
@dataclass(frozen=True, slots=True)
class _LegacyCreateAllSchemaState:
current: str | None
has_no_revision: bool
has_file_storage: bool
has_file_folders: bool
has_create_all_hierarchical_schema: bool
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class MigrationResult: class MigrationResult:
previous_revision: str | None previous_revision: str | None
@@ -231,10 +245,7 @@ def run_registered_module_migration_tasks(
dry_run: bool = False, dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (), manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]: ) -> tuple[dict[str, object], ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip())) active_phases = _normalized_migration_task_phases(phases)
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
url = database_url or settings.database_url url = database_url or settings.database_url
registry = _registered_module_registry( registry = _registered_module_registry(
database_url=url, database_url=url,
@@ -242,91 +253,192 @@ def run_registered_module_migration_tasks(
manifest_factories=manifest_factories, manifest_factories=manifest_factories,
) )
manifests = {manifest.id: manifest for manifest in registry.manifests()} manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([ ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
records: list[dict[str, object]] = [] records: list[dict[str, object]] = []
database = get_database() database = get_database()
with database.SessionLocal() as session: with database.SessionLocal() as session:
for phase in active_phases: for phase in active_phases:
for module_id in ordered_ids: for manifest, task in _iter_phase_migration_tasks(phase, ordered_ids=ordered_ids, manifests=manifests):
manifest = manifests.get(module_id) _run_registered_module_migration_task(
if manifest is None or manifest.migration_spec is None: session,
continue manifest=manifest,
for task in manifest.migration_spec.migration_tasks: task=task,
if task.phase != phase: database_url=url,
continue dry_run=dry_run,
record: dict[str, object] = { records=records,
"module_id": manifest.id, )
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
if not task.idempotent:
record.update({"status": "blocked", "message": "Task is not idempotent."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
records=tuple(records),
)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
continue
if task.executor is None:
record.update({"status": "blocked", "message": "Task has no executor."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
records=tuple(records),
)
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
result = task.executor(context)
normalized = _normalize_migration_task_result(result)
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
return tuple(records) return tuple(records)
def _normalized_migration_task_phases(phases: tuple[str, ...] | list[str]) -> tuple[str, ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
return active_phases
def _ordered_migration_task_module_ids(
migration_module_order: tuple[str, ...] | list[str] | None,
manifests: Mapping[str, object],
) -> tuple[str, ...]:
return tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
def _iter_phase_migration_tasks(
phase: str,
*,
ordered_ids: tuple[str, ...],
manifests: Mapping[str, object],
) -> Iterable[tuple[object, object]]:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
migration_spec = getattr(manifest, "migration_spec", None)
if manifest is None or migration_spec is None:
continue
for task in migration_spec.migration_tasks:
if task.phase == phase:
yield manifest, task
def _run_registered_module_migration_task(
session: object,
*,
manifest: object,
task: object,
database_url: str,
dry_run: bool,
records: list[dict[str, object]],
) -> None:
record = _migration_task_record(manifest, task, dry_run=dry_run)
_validate_migration_task_can_run(manifest, task, record, records, dry_run=dry_run)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
return
normalized = _execute_module_migration_task(
session,
manifest=manifest,
task=task,
record=record,
records=records,
database_url=database_url,
dry_run=dry_run,
)
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
def _migration_task_record(manifest: object, task: object, *, dry_run: bool) -> dict[str, object]:
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
return record
def _validate_migration_task_can_run(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
dry_run: bool,
) -> None:
if not task.idempotent:
_block_migration_task(
manifest,
task,
record,
records,
message="Task is not idempotent.",
error=f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
)
if not dry_run and task.executor is None:
_block_migration_task(
manifest,
task,
record,
records,
message="Task has no executor.",
error=f"Module migration task {manifest.id}/{task.task_id} has no executor.",
)
def _block_migration_task(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
message: str,
error: str,
) -> None:
record.update({"status": "blocked", "message": message})
records.append(record)
raise ModuleMigrationTaskExecutionError(
error,
records=tuple(records),
)
def _execute_module_migration_task(
session: object,
*,
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
database_url: str,
dry_run: bool,
) -> ModuleMigrationTaskResult:
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=database_url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
return _normalize_migration_task_result(task.executor(context))
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult: def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
if result is None: if result is None:
return ModuleMigrationTaskResult() return ModuleMigrationTaskResult()
@@ -521,19 +633,28 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int: def _row_count(connection, table_name: str) -> int:
quoted = connection.dialect.identifier_preparer.quote(table_name) quoted = _quoted_table_name(connection, table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
return int(connection.execute(statement).scalar_one())
def _drop_table(connection, table_name: str) -> None: def _drop_table(connection, table_name: str) -> None:
quoted = connection.dialect.identifier_preparer.quote(table_name) quoted = _quoted_table_name(connection, table_name)
connection.execute(text(f"DROP TABLE {quoted}")) statement = text(f"DROP TABLE {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _rename_table(connection, old_name: str, new_name: str) -> None: def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = connection.dialect.identifier_preparer.quote(old_name) quoted_old = _quoted_table_name(connection, old_name)
quoted_new = connection.dialect.identifier_preparer.quote(new_name) quoted_new = _quoted_table_name(connection, new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}")) statement = text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _quoted_table_name(connection, table_name: str) -> str:
if not _SQL_IDENTIFIER_RE.fullmatch(table_name):
raise ValueError(f"Unsafe table identifier: {table_name!r}")
return connection.dialect.identifier_preparer.quote(table_name)
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool: def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
@@ -707,7 +828,8 @@ def reconcile_covered_alembic_dependency_heads(
for revision_id in current: for revision_id in current:
try: try:
revision = script.get_revision(revision_id) revision = script.get_revision(revision_id)
except Exception: except Exception as exc:
logger.debug("Skipping Alembic revision %s while pruning dependency heads: %s", revision_id, exc, exc_info=True)
continue continue
ancestors = { ancestors = {
item.revision item.revision
@@ -741,58 +863,68 @@ def reconcile_legacy_create_all_schema(
""" """
url = database_url or settings.database_url url = database_url or settings.database_url
engine = create_engine(url) state = _legacy_create_all_schema_state(url)
try: target = _legacy_create_all_reconciliation_target(state)
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
current = heads[0] if len(heads) == 1 else None
has_no_revision = len(heads) == 0
schema = inspect(connection)
tables = set(schema.get_table_names())
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
finally:
engine.dispose()
target: str | None = None
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_AUTH_RBAC and has_file_storage:
target = REVISION_FILE_STORAGE
elif current == REVISION_FILE_STORAGE and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
# Development DBs may be stamped at 4e after earlier create_all
# reconciliation even though later tables/columns were already created
# by a newer model set. Skip only when that complete known schema is
# present, then let newer migrations such as mail credential usernames
# run normally.
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_create_all_hierarchical_schema:
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_file_storage and has_file_folders:
# This is the other create_all-only development shape. The strict
# column checks above ensure that we only stamp a complete known schema.
target = REVISION_FILE_FOLDERS
if target is None: if target is None:
return None return None
if target == REVISION_HIERARCHICAL_SETTINGS:
_backfill_user_lock_state_for_create_all_schema(url)
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target) command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
return target return target
def _legacy_create_all_schema_state(database_url: str) -> _LegacyCreateAllSchemaState:
engine = create_engine(database_url)
try:
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
schema = inspect(connection)
tables = set(schema.get_table_names())
return _legacy_create_all_schema_state_from_inspection(heads, schema, tables)
finally:
engine.dispose()
def _legacy_create_all_schema_state_from_inspection(
heads: tuple[str, ...],
schema: object,
tables: set[str],
) -> _LegacyCreateAllSchemaState:
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
return _LegacyCreateAllSchemaState(
current=heads[0] if len(heads) == 1 else None,
has_no_revision=len(heads) == 0,
has_file_storage=has_file_storage,
has_file_folders=has_file_folders,
has_create_all_hierarchical_schema=_has_create_all_schema_through_hierarchical_settings(schema, tables),
)
def _legacy_create_all_reconciliation_target(state: _LegacyCreateAllSchemaState) -> str | None:
if state.current == REVISION_AUTH_RBAC and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_AUTH_RBAC and state.has_file_storage:
return REVISION_FILE_STORAGE
if state.current == REVISION_FILE_STORAGE and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_FILE_FOLDERS and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
return None
def migrate_database( def migrate_database(
*, *,
database_url: str | None = None, database_url: str | None = None,
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import time
import weakref
from collections.abc import Iterator
from sqlalchemy import event
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.engine.interfaces import ExecutionContext
@dataclass(slots=True)
class QueryMetrics:
query_count: int = 0
total_ms: float = 0.0
slowest_ms: float = 0.0
error_count: int = 0
def record(self, elapsed_ms: float, *, failed: bool = False) -> None:
self.query_count += 1
self.total_ms += elapsed_ms
self.slowest_ms = max(self.slowest_ms, elapsed_ms)
if failed:
self.error_count += 1
_current_metrics: ContextVar[QueryMetrics | None] = ContextVar("govoplan_query_metrics", default=None)
_instrumented_engines: weakref.WeakSet[Engine] = weakref.WeakSet()
_START_STACK_KEY = "govoplan_query_metric_starts"
@contextmanager
def collect_query_metrics() -> Iterator[QueryMetrics]:
metrics = QueryMetrics()
token = _current_metrics.set(metrics)
try:
yield metrics
finally:
_current_metrics.reset(token)
def current_query_metrics() -> QueryMetrics | None:
return _current_metrics.get()
def instrument_engine(engine: Engine) -> Engine:
if engine in _instrumented_engines:
return engine
event.listen(engine, "before_cursor_execute", _before_cursor_execute)
event.listen(engine, "after_cursor_execute", _after_cursor_execute)
event.listen(engine, "handle_error", _handle_error)
_instrumented_engines.add(engine)
return engine
def _before_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
if current_query_metrics() is None:
return
starts = conn.info.setdefault(_START_STACK_KEY, [])
starts.append(time.perf_counter())
def _after_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
_record_elapsed(conn, failed=False)
def _handle_error(exception_context: object) -> None:
conn = getattr(exception_context, "connection", None)
if isinstance(conn, Connection):
_record_elapsed(conn, failed=True)
def _record_elapsed(conn: Connection, *, failed: bool) -> None:
metrics = current_query_metrics()
if metrics is None:
return
starts = conn.info.get(_START_STACK_KEY)
if not starts:
return
started_at = starts.pop()
elapsed_ms = (time.perf_counter() - started_at) * 1000
metrics.record(elapsed_ms, failed=failed)
+4 -2
View File
@@ -7,6 +7,8 @@ from sqlalchemy import create_engine
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
def default_connect_args(database_url: str) -> dict[str, Any]: def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {} return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -22,13 +24,13 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url)) merged_connect_args = dict(default_connect_args(database_url))
if connect_args: if connect_args:
merged_connect_args.update(connect_args) merged_connect_args.update(connect_args)
return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs) return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
class DatabaseHandle: class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None: def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url self.database_url = database_url
self.engine = engine or create_database_engine(database_url) self.engine = instrument_engine(engine) if engine is not None else create_database_engine(database_url)
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False) self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
def session(self) -> Session: def session(self) -> Session:
+26
View File
@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
password: str | None = None password: str | None = None
def normalize_split_transport_credentials(value: object) -> object:
"""Move legacy transport username/password fields into credentials."""
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
def default_smtp_port(security: TransportSecurity | str | None) -> int: def default_smtp_port(security: TransportSecurity | str | None) -> int:
if security == TransportSecurity.TLS or security == "tls": if security == TransportSecurity.TLS or security == "tls":
return 465 return 465
+9 -34
View File
@@ -1,5 +1,3 @@
from __future__ import annotations
"""Compatibility facade for privacy retention policy. """Compatibility facade for privacy retention policy.
Policy-owned behavior is provided by ``govoplan-policy`` through the Policy-owned behavior is provided by ``govoplan-policy`` through the
@@ -7,27 +5,22 @@ Policy-owned behavior is provided by ``govoplan-policy`` through the
without importing policy implementation code from core. without importing policy implementation code from core.
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Mapping from typing import Any, Mapping
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.schemas import (
RETENTION_DAY_KEYS as RETENTION_DAY_KEYS,
RETENTION_POLICY_FIELD_KEYS,
default_allow_lower_level_limits as default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy" PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
_DEFAULT_POLICY = { _DEFAULT_POLICY = {
"store_raw_campaign_json": True, "store_raw_campaign_json": True,
"raw_campaign_json_retention_days": None, "raw_campaign_json_retention_days": None,
@@ -122,28 +115,10 @@ def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]:
for key in RETENTION_POLICY_FIELD_KEYS: for key in RETENTION_POLICY_FIELD_KEYS:
if key in raw: if key in raw:
payload[key] = raw[key] payload[key] = raw[key]
payload["allow_lower_level_limits"] = _normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True) payload["allow_lower_level_limits"] = normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
return payload return payload
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, Mapping):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def privacy_policy_from_settings(item: object) -> Any: def privacy_policy_from_settings(item: object) -> Any:
service = _runtime_service() service = _runtime_service()
if service is not None: if service is not None:
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
__all__ = [
"RETENTION_DAY_KEYS",
"RETENTION_POLICY_FIELD_KEYS",
"PrivacyRetentionPolicyItem",
"PrivacyRetentionPolicyPatchItem",
"default_allow_lower_level_limits",
"normalize_allow_lower_level_limits",
]
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Mapping
@dataclass(frozen=True, slots=True)
class HttpFetchResponse:
status: int
headers: dict[str, str]
body: bytes
def text(self, encoding: str = "utf-8") -> str:
return self.body.decode(encoding)
def is_http_url(value: str) -> bool:
try:
validate_http_url(value)
except ValueError:
return False
return True
def validate_http_url(value: str, *, label: str = "URL") -> str:
parsed = urllib.parse.urlparse(str(value).strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{label} must be an absolute HTTP(S) URL.")
if parsed.username or parsed.password:
raise ValueError(f"{label} must not include embedded credentials.")
return urllib.parse.urlunparse(parsed)
def fetch_http(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
) -> HttpFetchResponse:
request = urllib.request.Request(
validate_http_url(url, label=label),
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
return HttpFetchResponse(
status=int(getattr(response, "status", 0)),
headers=dict(response.headers.items()),
body=response.read(),
)
def fetch_http_text(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
encoding: str = "utf-8",
) -> str:
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers).text(encoding)
@@ -95,6 +95,11 @@ MODULE_SYSTEM_SCOPES = frozenset(
for legacy, module in LEGACY_TO_MODULE_SCOPES.items() for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
if legacy.startswith("system:") if legacy.startswith("system:")
) )
LEGACY_SYSTEM_SCOPES = frozenset(
legacy
for legacy in LEGACY_TO_MODULE_SCOPES
if legacy.startswith("system:")
)
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool: def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes) granted = list(scopes)
if required in MODULE_SYSTEM_SCOPES: if required in MODULE_SYSTEM_SCOPES or required in LEGACY_SYSTEM_SCOPES:
return "*" in granted or "system:*" in granted or any( return "*" in granted or "system:*" in granted or any(
scope != "tenant:*" and scopes_grant([scope], alias) scope != "tenant:*" and scopes_grant([scope], alias)
for scope in granted for scope in granted
+2 -22
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Iterable
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PermissionDefinition: class PermissionDefinition:
@@ -96,28 +98,6 @@ KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS) TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS) SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; the Alembic migration expands old
# role records once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"recipients:read", "recipients:write", "recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
}),
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
}
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = { DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True}, "owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [ "tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [
@@ -0,0 +1,55 @@
from __future__ import annotations
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; migrations expand old role records
# once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create",
"campaign:update",
"campaign:copy",
"campaign:archive",
"campaign:delete",
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read",
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:groups:read",
"admin:groups:write",
"admin:groups:manage_members",
"admin:roles:read",
"admin:roles:write",
"admin:roles:assign",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}
+2 -2
View File
@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
try: try:
Fernet(candidate) Fernet(candidate)
return candidate return candidate
except Exception: except (TypeError, ValueError):
pass raw = None
try: try:
raw = base64.b64decode(candidate) raw = base64.b64decode(candidate)
except Exception as exc: except Exception as exc:
+62 -44
View File
@@ -13,52 +13,13 @@ from govoplan_core.server.route_validation import validate_no_route_collisions
def create_app(config: GovoplanServerConfig | str | None = None): def create_app(config: GovoplanServerConfig | str | None = None):
if isinstance(config, str) or config is None: server_config = _server_config(config)
server_config = load_server_config(config) _configure_app_database(server_config)
else: registry, available_modules = _server_module_registry(server_config)
server_config = config api_router = _server_api_router(server_config, registry)
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
lifecycle = ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
lifecycle.configure_runtime() lifecycle.configure_runtime()
api_router.include_router(create_platform_router(settings=server_config.settings))
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
app = create_govoplan_app( app = create_govoplan_app(
title=server_config.title, title=server_config.title,
version=server_config.version, version=server_config.version,
@@ -74,4 +35,61 @@ def create_app(config: GovoplanServerConfig | str | None = None):
return app return app
def _server_config(config: GovoplanServerConfig | str | None) -> GovoplanServerConfig:
if isinstance(config, str) or config is None:
return load_server_config(config)
return config
def _configure_app_database(server_config: GovoplanServerConfig) -> None:
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
def _server_module_registry(server_config: GovoplanServerConfig):
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
return registry, available_modules
def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRouter:
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
return api_router
def _server_lifecycle(
server_config: GovoplanServerConfig,
*,
registry,
available_modules,
) -> ModuleLifecycleManager:
return ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
app = create_app() app = create_app()
+45 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
from collections.abc import Callable, Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib import import_module from importlib import import_module
@@ -15,6 +16,10 @@ from govoplan_core.server.fastapi import LifespanFactory
ManifestFactory = Callable[[], ModuleManifest] ManifestFactory = Callable[[], ModuleManifest]
RouterEnabled = Callable[[object | None, PlatformRegistry], bool] RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None] AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
DEFAULT_TRUSTED_IMPORT_PREFIXES = ("govoplan_core", "govoplan_", "app")
TRUSTED_IMPORT_PREFIXES_ENV = "GOVOPLAN_TRUSTED_IMPORT_PREFIXES"
_MODULE_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
_ATTRIBUTE_PATH_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -49,16 +54,52 @@ class GovoplanServerConfig:
def import_object(path: str) -> Any: def import_object(path: str) -> Any:
module_name, separator, attribute = path.partition(":") module_name, attribute = validate_object_path(path)
if not separator: _validate_trusted_import_prefix(module_name, trusted_import_prefixes())
raise ValueError(f"Object path must use module:attribute syntax: {path!r}") module = import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
module = import_module(module_name)
value: Any = module value: Any = module
for part in attribute.split("."): for part in attribute.split("."):
value = getattr(value, part) value = getattr(value, part)
return value return value
def validate_object_path(path: str) -> tuple[str, str]:
module_name, separator, attribute = path.strip().partition(":")
if not separator:
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
if not _MODULE_PATH_RE.fullmatch(module_name):
raise ValueError(f"Object path module is not a valid Python module path: {path!r}")
if not _ATTRIBUTE_PATH_RE.fullmatch(attribute) or any(part.startswith("_") for part in attribute.split(".")):
raise ValueError(f"Object path attribute is not a public attribute path: {path!r}")
return module_name, attribute
def trusted_import_prefixes() -> tuple[str, ...]:
configured = os.getenv(TRUSTED_IMPORT_PREFIXES_ENV, "").strip()
if not configured:
return DEFAULT_TRUSTED_IMPORT_PREFIXES
prefixes = tuple(item.strip() for item in configured.split(",") if item.strip())
return prefixes or DEFAULT_TRUSTED_IMPORT_PREFIXES
def _validate_trusted_import_prefix(module_name: str, prefixes: Iterable[str]) -> None:
if any(_module_matches_prefix(module_name, prefix) for prefix in prefixes):
return
raise ValueError(
f"Object path module {module_name!r} is outside trusted import prefixes. "
f"Set {TRUSTED_IMPORT_PREFIXES_ENV} to allow custom deployment config modules."
)
def _module_matches_prefix(module_name: str, prefix: str) -> bool:
cleaned = prefix.strip()
if not cleaned:
return False
if cleaned.endswith((".", "_")):
return module_name.startswith(cleaned)
return module_name == cleaned or module_name.startswith(f"{cleaned}.")
def load_server_config(path: str | None = None) -> GovoplanServerConfig: def load_server_config(path: str | None = None) -> GovoplanServerConfig:
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config" config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
try: try:
+50
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging
import os
import time
from collections.abc import AsyncIterator, Callable, Iterable from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from typing import Any from typing import Any
@@ -9,9 +12,19 @@ from fastapi.middleware.cors import CORSMiddleware
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
from govoplan_core.core.registry import PlatformRegistry 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.conditional_requests import conditional_json_get_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
def _slow_request_threshold_ms() -> float:
raw = os.getenv("GOVOPLAN_SLOW_REQUEST_MS", "500").strip()
try:
return float(raw)
except ValueError:
return 500.0
def create_govoplan_app( def create_govoplan_app(
@@ -26,6 +39,7 @@ def create_govoplan_app(
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title=title, version=version, lifespan=lifespan) app = FastAPI(title=title, version=version, lifespan=lifespan)
app.state.govoplan_registry = registry app.state.govoplan_registry = registry
slow_request_threshold_ms = _slow_request_threshold_ms()
@app.middleware("http") @app.middleware("http")
async def request_correlation_context(request: Request, call_next): async def request_correlation_context(request: Request, call_next):
@@ -39,6 +53,42 @@ def create_govoplan_app(
response.headers["X-Correlation-ID"] = correlation_id response.headers["X-Correlation-ID"] = correlation_id
return response return response
@app.middleware("http")
async def slow_request_logging(request: Request, call_next):
started_at = time.perf_counter()
with collect_query_metrics() as db_metrics:
try:
response = await call_next(request)
except Exception:
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request failed method=%s path=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
exc_info=True,
)
raise
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request method=%s path=%s status=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
)
return response
app.middleware("http")(conditional_json_get_middleware) app.middleware("http")(conditional_json_get_middleware)
origins = [item.strip() for item in cors_origins if item.strip()] origins = [item.strip() for item in cors_origins if item.strip()]
+5 -2
View File
@@ -6,6 +6,7 @@ import importlib
from govoplan_core.core.discovery import discover_module_manifests from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.server.config import validate_object_path
ManifestFactory = Callable[[], ModuleManifest] ManifestFactory = Callable[[], ModuleManifest]
@@ -74,8 +75,10 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
def _load_builtin_manifest(module_id: str) -> ModuleManifest: def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id] target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = target.split(":", 1) module_name, function_name = validate_object_path(target)
module = importlib.import_module(module_name) if module_name != _BUILTIN_MANIFESTS[module_id].split(":", 1)[0]:
raise RegistryError(f"Built-in module manifest target changed unexpectedly: {module_id}")
module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
factory = getattr(module, function_name) factory = getattr(module, function_name)
manifest = factory() manifest = factory()
if not isinstance(manifest, ModuleManifest): if not isinstance(manifest, ModuleManifest):
+12 -7
View File
@@ -17,15 +17,15 @@ class Settings(BaseSettings):
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE") tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE") tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE") tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops", alias="ENABLED_MODULES") enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK") migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED") celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL") s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION") s3_region: str = Field(default="garage", alias="S3_REGION")
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID") s3_access_key_id: str = Field(default="GKgovoplandev0000000000000000000", alias="S3_ACCESS_KEY_ID")
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY") s3_secret_access_key: str = Field(default="govoplan-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
s3_bucket: str = Field(default="attachments", alias="S3_BUCKET") s3_bucket: str = Field(default="attachments", alias="S3_BUCKET")
# Managed file storage. Development defaults to local filesystem storage; # Managed file storage. Development defaults to local filesystem storage;
@@ -41,19 +41,24 @@ class Settings(BaseSettings):
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES") file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES") file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME") auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME") auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE") auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE") auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN") auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS") auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES") celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
calendar_outbox_terminal_retention_days: int = Field(
default=90,
ge=0,
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
)
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR") mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production. # Development bootstrap only. Do not use this in production.
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY") dev_bootstrap_api_key: str | None = Field(default="dev-govoplan-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED") dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED")
dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED") dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD") dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
+2 -1
View File
@@ -83,7 +83,6 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY,
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.tenancy.scope import create_scope_tables from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
@@ -1382,6 +1381,8 @@ class AccessContractTests(unittest.TestCase):
app_configurators=(), app_configurators=(),
) )
with temporary_database(f"sqlite:///{root / 'test.db'}") as database: with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
from govoplan_core.server.app import create_app
app = create_app(config) app = create_app(config)
create_scope_tables(database.engine) create_scope_tables(database.engine)
+238 -5
View File
@@ -26,10 +26,21 @@ os.environ["FILE_STORAGE_LOCAL_ROOT"] = str(_TEST_ROOT / "files")
os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox") os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox")
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false" os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
from govoplan_core.settings import Settings, settings as core_settings
# unittest discovery imports other contract modules before this smoke module.
# Refresh the shared settings object in place so modules that already imported
# it observe this test's isolated environment before the default app is built.
_isolated_settings = Settings()
for _field_name in Settings.model_fields:
setattr(core_settings, _field_name, getattr(_isolated_settings, _field_name))
from alembic import command
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.bootstrap import bootstrap_dev_data from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.migrations import alembic_config
from govoplan_core.db.session import configure_database, set_database from govoplan_core.db.session import configure_database, set_database
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
@@ -61,6 +72,16 @@ class ApiSmokeTests(unittest.TestCase):
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
create_scope_tables(engine) create_scope_tables(engine)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# This suite builds the current ORM schema directly. Record the matching
# release heads so module-lifecycle tests can safely run Alembic without
# misclassifying the create_all database as a historic dev revision.
if not getattr(type(self), "_migration_heads_stamped", False):
command.stamp(
alembic_config(database_url=os.environ["DATABASE_URL"]),
"heads",
purge=True,
)
type(self)._migration_heads_stamped = True
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True) shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True) shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
with SessionLocal() as session: with SessionLocal() as session:
@@ -310,6 +331,7 @@ class ApiSmokeTests(unittest.TestCase):
roles = self.client.get("/api/v1/admin/system/roles", headers=headers) roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
self.assertEqual(roles.status_code, 200, roles.text) self.assertEqual(roles.status_code, 200, roles.text)
system_admin = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin") system_admin = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin")
maintenance_operator = next(item for item in roles.json()["roles"] if item["slug"] == "maintenance_operator")
created = self.client.post( created = self.client.post(
"/api/v1/admin/system/accounts", "/api/v1/admin/system/accounts",
headers=headers, headers=headers,
@@ -319,7 +341,7 @@ class ApiSmokeTests(unittest.TestCase):
"password": "approver-password", "password": "approver-password",
"password_reset_required": False, "password_reset_required": False,
"is_active": True, "is_active": True,
"role_ids": [system_admin["id"]], "role_ids": [system_admin["id"], maintenance_operator["id"]],
"memberships": [{ "memberships": [{
"tenant_id": tenant_id, "tenant_id": tenant_id,
"is_active": True, "is_active": True,
@@ -364,12 +386,79 @@ class ApiSmokeTests(unittest.TestCase):
json={"email": "admin@example.local", "password": "test-admin"}, json={"email": "admin@example.local", "password": "test-admin"},
) )
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 200, response.text)
csrf = response.cookies.get("msm_csrf") csrf = response.cookies.get("govoplan_csrf")
self.assertTrue(csrf) self.assertTrue(csrf)
session_summary = self.client.get("/api/v1/auth/session")
self.assertEqual(session_summary.status_code, 200, session_summary.text)
session_payload = session_summary.json()
self.assertTrue(session_payload["authenticated"])
self.assertEqual(session_payload["auth_method"], "session")
self.assertNotIn("scopes", session_payload)
self.assertNotIn("roles", session_payload)
self.assertNotIn("groups", session_payload)
shell_summary = self.client.get("/api/v1/auth/shell")
self.assertEqual(shell_summary.status_code, 200, shell_summary.text)
shell_payload = shell_summary.json()
self.assertEqual(shell_payload["principal"]["auth_method"], "session")
self.assertFalse(shell_payload["profile_loaded"])
self.assertFalse(shell_payload["roles_loaded"])
self.assertFalse(shell_payload["groups_loaded"])
self.assertIsInstance(shell_payload["scopes"], list)
self.assertTrue(shell_payload["scopes"])
self.assertTrue(shell_payload["tenants"])
self.assertTrue(all(membership["roles"] == [] for membership in shell_payload["tenants"]))
self.assertNotIn("roles", shell_payload)
self.assertNotIn("groups", shell_payload)
self.assertNotIn("available_languages", shell_payload)
self.assertNotIn("default_language", shell_payload)
profile_summary = self.client.get("/api/v1/auth/profile")
self.assertEqual(profile_summary.status_code, 200, profile_summary.text)
profile_payload = profile_summary.json()
self.assertTrue(profile_payload["profile_loaded"])
self.assertEqual(profile_payload["user"]["id"], session_payload["user"]["id"])
self.assertIn("available_languages", profile_payload)
self.assertIn("default_language", profile_payload)
self.assertNotIn("roles", profile_payload)
self.assertNotIn("groups", profile_payload)
roles_summary = self.client.get("/api/v1/auth/roles")
self.assertEqual(roles_summary.status_code, 200, roles_summary.text)
roles_payload = roles_summary.json()
self.assertTrue(roles_payload["roles_loaded"])
self.assertTrue(roles_payload["roles"])
self.assertNotIn("groups", roles_payload)
groups_summary = self.client.get("/api/v1/auth/groups")
self.assertEqual(groups_summary.status_code, 200, groups_summary.text)
groups_payload = groups_summary.json()
self.assertTrue(groups_payload["groups_loaded"])
self.assertIsInstance(groups_payload["groups"], list)
self.assertNotIn("roles", groups_payload)
api_key_session = self.client.get("/api/v1/auth/session", headers={"X-API-Key": "test-api-key"})
self.assertEqual(api_key_session.status_code, 200, api_key_session.text)
self.assertEqual(api_key_session.json()["auth_method"], "api_key")
api_key_shell = self.client.get("/api/v1/auth/shell", headers={"X-API-Key": "test-api-key"})
self.assertEqual(api_key_shell.status_code, 200, api_key_shell.text)
api_key_shell_payload = api_key_shell.json()
self.assertEqual(api_key_shell_payload["principal"]["auth_method"], "api_key")
self.assertFalse(api_key_shell_payload["profile_loaded"])
self.assertFalse(api_key_shell_payload["roles_loaded"])
self.assertFalse(api_key_shell_payload["groups_loaded"])
self.assertIsInstance(api_key_shell_payload["scopes"], list)
me = self.client.get("/api/v1/auth/me") me = self.client.get("/api/v1/auth/me")
self.assertEqual(me.status_code, 200, me.text) self.assertEqual(me.status_code, 200, me.text)
me_payload = me.json() me_payload = me.json()
self.assertEqual(session_payload["user"]["id"], me_payload["user"]["id"])
self.assertEqual(session_payload["tenant"]["id"], me_payload["tenant"]["id"])
self.assertEqual(shell_payload["user"]["id"], me_payload["user"]["id"])
self.assertEqual(shell_payload["tenant"]["id"], me_payload["tenant"]["id"])
self.assertTrue(me_payload["profile_loaded"])
self.assertEqual(me_payload["principal"]["account_id"], me_payload["user"]["account_id"]) self.assertEqual(me_payload["principal"]["account_id"], me_payload["user"]["account_id"])
self.assertEqual(me_payload["principal"]["membership_id"], me_payload["user"]["id"]) self.assertEqual(me_payload["principal"]["membership_id"], me_payload["user"]["id"])
self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"]) self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"])
@@ -423,6 +512,20 @@ class ApiSmokeTests(unittest.TestCase):
imap_host="mock.imap", imap_host="mock.imap",
) )
bootstrapped = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/bootstrap",
headers=headers,
params={"folder": "INBOX", "limit": 2},
)
self.assertEqual(bootstrapped.status_code, 200, bootstrapped.text)
bootstrap_payload = bootstrapped.json()
self.assertEqual(bootstrap_payload["folder"], "INBOX")
self.assertTrue(bootstrap_payload["folders"]["ok"])
self.assertFalse(bootstrap_payload["folders"]["from_cache"])
self.assertFalse(bootstrap_payload["messages"]["from_cache"])
self.assertEqual(bootstrap_payload["messages"]["total_count"], 3)
self.assertEqual(len(bootstrap_payload["messages"]["messages"]), 2)
listed = self.client.get( listed = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages", f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
headers=headers, headers=headers,
@@ -433,6 +536,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(payload["folder"], "INBOX") self.assertEqual(payload["folder"], "INBOX")
self.assertEqual(payload["total_count"], 3) self.assertEqual(payload["total_count"], 3)
self.assertEqual(len(payload["messages"]), 2) self.assertEqual(len(payload["messages"]), 2)
self.assertTrue(payload["from_cache"])
self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"])) self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"]))
self.assertTrue(payload["cursor_stable"]) self.assertTrue(payload["cursor_stable"])
self.assertFalse(payload["full"]) self.assertFalse(payload["full"])
@@ -449,6 +553,14 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(len(next_payload["messages"]), 1) self.assertEqual(len(next_payload["messages"]), 1)
self.assertFalse(next_payload["next_cursor"]) self.assertFalse(next_payload["next_cursor"])
cached_next_page = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
headers=headers,
params={"folder": "INBOX", "cursor": payload["next_cursor"]},
)
self.assertEqual(cached_next_page.status_code, 200, cached_next_page.text)
self.assertTrue(cached_next_page.json()["from_cache"])
stale_cursor = encode_keyset_cursor( stale_cursor = encode_keyset_cursor(
"mail.mailbox.messages.v1", "mail.mailbox.messages.v1",
fingerprint=keyset_query_fingerprint( fingerprint=keyset_query_fingerprint(
@@ -739,7 +851,7 @@ class ApiSmokeTests(unittest.TestCase):
schema = self.client.get("/api/v1/schemas/campaign", headers=headers) schema = self.client.get("/api/v1/schemas/campaign", headers=headers)
self.assertEqual(schema.status_code, 200, schema.text) self.assertEqual(schema.status_code, 200, schema.text)
self.assertEqual(schema.json()["title"], "MultiMailer Campaign") self.assertEqual(schema.json()["title"], "GovOPlaN Campaign")
dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers) dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers)
self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text) self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text)
@@ -3269,7 +3381,7 @@ class ApiSmokeTests(unittest.TestCase):
"invoices/archive/202605-010001-report.XLSX", "invoices/archive/202605-010001-report.XLSX",
"invoices/202605-010001-90100010-9601741.XLSX", "invoices/202605-010001-90100010-9601741.XLSX",
}) })
self.assertFalse(any("multimailer-managed-build" in value for value in resolved_paths)) self.assertFalse(any("govoplan-managed-build" in value for value in resolved_paths))
jobs = self.client.get( jobs = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs", f"/api/v1/campaigns/{campaign_id}/jobs",
@@ -3342,6 +3454,127 @@ class ApiSmokeTests(unittest.TestCase):
"202605-010001-90100010-9601741.XLSX", "202605-010001-90100010-9601741.XLSX",
}) })
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
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",
}
},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Invoice", "text": "Please see the attached file."},
"attachments": {
"base_path": "invoices",
"base_paths": [
{
"id": "personal-invoices",
"name": "My invoices",
"source": f"managed:user:{user_id}",
"path": "invoices",
"allow_individual": True,
}
],
"global": [],
"allow_individual": True,
},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}],
"fields": {"invoice_number": "202605-010002"},
"attachments": [
{
"id": "exact-pattern",
"label": "Exact workbook",
"base_path_id": "personal-invoices",
"base_dir": "invoices",
"file_filter": "{{local:invoice_number}}.XLSX",
"required": True,
},
],
}
]
},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
"missing_required_attachment": "block",
},
"delivery": {"imap_append_sent": {"enabled": False}},
}
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
self.assertEqual(created.status_code, 200, created.text)
campaign_id = created.json()["campaign"]["id"]
version_id = created.json()["version"]["id"]
uploaded = self.client.post(
"/api/v1/files/upload",
headers=headers,
data={
"owner_type": "user",
"owner_id": user_id,
"path": "invoices",
},
files=[("files", ("202605-010002.XLSX", b"unlinked workbook", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))],
)
self.assertEqual(uploaded.status_code, 200, uploaded.text)
preview = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
headers=headers,
json={"include_unmatched": True, "include_unlinked_candidates": True},
)
self.assertEqual(preview.status_code, 200, preview.text)
preview_payload = preview.json()
self.assertEqual(preview_payload["matched_file_count"], 1)
self.assertEqual(preview_payload["unlinked_file_count"], 1)
self.assertEqual(preview_payload["rules"][0]["unlinked_match_count"], 1)
self.assertFalse(preview_payload["rules"][0]["matches"][0]["linked_to_campaign"])
dry_run = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/link-matches",
headers=headers,
json={"dry_run": True},
)
self.assertEqual(dry_run.status_code, 200, dry_run.text)
self.assertEqual(dry_run.json()["linked_file_count"], 0)
self.assertEqual(len(dry_run.json()["linkable_files"]), 1)
validated = self.client.post(
f"/api/v1/campaigns/versions/{version_id}/validate",
headers=headers,
json={"check_files": True, "link_unshared_matches": True},
)
self.assertEqual(validated.status_code, 200, validated.text)
self.assertTrue(validated.json()["ok"], validated.text)
linked_preview = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
headers=headers,
json={"include_unmatched": True, "include_unlinked_candidates": True},
)
self.assertEqual(linked_preview.status_code, 200, linked_preview.text)
linked_payload = linked_preview.json()
self.assertEqual(linked_payload["matched_file_count"], 1)
self.assertEqual(linked_payload["linked_file_count"], 1)
self.assertEqual(linked_payload["unlinked_file_count"], 0)
self.assertTrue(linked_payload["rules"][0]["matches"][0]["linked_to_campaign"])
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None: def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
headers, login = self._login() headers, login = self._login()
user_id = login["user"]["id"] user_id = login["user"]["id"]
@@ -5558,7 +5791,7 @@ class ApiSmokeTests(unittest.TestCase):
password="reader-password", password="reader-password",
role_ids=[str(access_role["id"])], role_ids=[str(access_role["id"])],
) )
other = self._create_user( self._create_user(
owner_headers, owner_headers,
email="campaign-other@example.local", email="campaign-other@example.local",
password="other-password", password="other-password",
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
class CalendarOutboxWorkerTests(unittest.TestCase):
def test_worker_commits_provider_outcome(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
provider = MagicMock()
provider.dispatch_due.return_value = {
"processed": 1,
"succeeded": 1,
"retrying": 0,
"failed": 0,
"operations": [{"id": "operation-1", "status": "succeeded"}],
}
with (
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
patch("govoplan_core.db.session.get_database", return_value=database),
):
result = dispatch_calendar_outbox.run("tenant-1", 25)
provider.dispatch_due.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with()
self.assertEqual(result["succeeded"], 1)
def test_calendar_worker_route_and_periodic_recovery_are_registered(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.calendar.dispatch_outbox"],
{"queue": "calendar"},
)
schedule = celery.conf.beat_schedule["calendar-outbox-every-minute"]
self.assertEqual(schedule["task"], "govoplan.calendar.dispatch_outbox")
self.assertEqual(schedule["schedule"], 60.0)
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -39,6 +40,22 @@ def database_migration_heads(connection) -> set[str]:
class DatabaseMigrationTests(unittest.TestCase): class DatabaseMigrationTests(unittest.TestCase):
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
logger = logging.getLogger("govoplan.request")
previous_disabled = logger.disabled
logger.disabled = False
try:
with tempfile.TemporaryDirectory(prefix="govoplan-migration-logging-test-") as directory:
database = Path(directory) / "logging.db"
command.stamp(
alembic_config(database_url=f"sqlite:///{database}", enabled_modules=()),
"heads",
)
self.assertFalse(logger.disabled)
finally:
logger.disabled = previous_disabled
def test_migration_tracks_use_separate_version_locations(self) -> None: def test_migration_tracks_use_separate_version_locations(self) -> None:
release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations") release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations")
dev_locations = alembic_config( dev_locations = alembic_config(
@@ -184,6 +201,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIsNone(result.reconciled_revision) self.assertIsNone(result.reconciled_revision)
self.assertEqual(current, configured_migration_heads(url)) self.assertEqual(current, configured_migration_heads(url))
self.assertEqual(result.current_revision, ",".join(sorted(current))) self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("calendar_sync_credentials", tables) self.assertIn("calendar_sync_credentials", tables)
self.assertIn("campaign_recipient_import_mapping_profiles", tables) self.assertIn("campaign_recipient_import_mapping_profiles", tables)
self.assertIn("file_connector_credentials", tables) self.assertIn("file_connector_credentials", tables)
@@ -216,6 +234,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertEqual(result.current_revision, ",".join(sorted(current))) self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("0f1e2d3c4b5a", current) self.assertIn("0f1e2d3c4b5a", current)
self.assertIn("audit_outbox_events", tables) self.assertIn("audit_outbox_events", tables)
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("file_connector_profiles", tables) self.assertIn("file_connector_profiles", tables)
self.assertIn("core_scopes", tables) self.assertIn("core_scopes", tables)
finally: finally:
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import unittest
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
class HttpFetchTests(unittest.TestCase):
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
self.assertTrue(is_http_url("http://example.test/catalog.json"))
def test_validate_http_url_rejects_non_http_urls_and_embedded_credentials(self) -> None:
for value in (
"file:///etc/passwd",
"/relative/catalog.json",
"https://user@example.test/catalog.json",
"https://user:secret@example.test/catalog.json",
):
with self.subTest(value=value):
self.assertFalse(is_http_url(value))
with self.assertRaises(ValueError):
validate_http_url(value)
if __name__ == "__main__":
unittest.main()
@@ -396,8 +396,11 @@ class IdentityOrganizationContractTests(unittest.TestCase):
from govoplan_organizations.backend.directory import SqlOrganizationDirectory from govoplan_organizations.backend.directory import SqlOrganizationDirectory
identity_directory = SqlIdentityDirectory() identity_directory = SqlIdentityDirectory()
idm_directory = SqlIdmDirectory()
organization_directory = SqlOrganizationDirectory() organization_directory = SqlOrganizationDirectory()
idm_directory = SqlIdmDirectory(
identities=identity_directory,
organizations=organization_directory,
)
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr] self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr] self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]
+37
View File
@@ -12,11 +12,18 @@ from govoplan_access.backend.db.models import Account, User
from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_identity.backend.directory import SqlIdentityDirectory
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from govoplan_idm.backend.api.v1.routes import router as idm_router from govoplan_idm.backend.api.v1.routes import router as idm_router
from govoplan_idm.backend.manifest import get_manifest from govoplan_idm.backend.manifest import get_manifest
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit
from tests.db_isolation import temporary_database from tests.db_isolation import temporary_database
@@ -50,7 +57,37 @@ class IdmApiTests(unittest.TestCase):
) )
def _app(self, scopes: set[str]) -> FastAPI: def _app(self, scopes: set[str]) -> FastAPI:
identity_directory = SqlIdentityDirectory()
organization_directory = SqlOrganizationDirectory()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="identity",
name="Identity",
version="test",
capability_factories={
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
CAPABILITY_IDENTITY_SEARCH: lambda context: identity_directory,
},
)
)
registry.register(
ModuleManifest(
id="organizations",
name="Organizations",
version="test",
capability_factories={
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
},
)
)
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
self.addCleanup(clear_runtime)
app = FastAPI() app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(idm_router, prefix="/api/v1") app.include_router(idm_router, prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes) app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes)
return app return app
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from govoplan_core.server.config import import_object, validate_object_path
from govoplan_core.server.registry import available_module_manifests
class ImportTrustBoundaryTests(unittest.TestCase):
def test_object_path_validation_rejects_invalid_shapes(self) -> None:
with self.assertRaisesRegex(ValueError, "module:attribute"):
validate_object_path("govoplan_core.server.default_config")
with self.assertRaisesRegex(ValueError, "valid Python module"):
validate_object_path("os;system:path")
with self.assertRaisesRegex(ValueError, "public attribute"):
validate_object_path("govoplan_core.server.default_config:_private")
def test_import_object_allows_default_govoplan_config_path(self) -> None:
value = import_object("govoplan_core.server.default_config:get_server_config")
self.assertTrue(callable(value))
def test_import_object_rejects_untrusted_stdlib_module(self) -> None:
with self.assertRaisesRegex(ValueError, "outside trusted import prefixes"):
import_object("os:path")
def test_import_object_allows_explicit_custom_prefix(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-config-import-") as directory:
root = Path(directory)
(root / "custom_config.py").write_text("value = 42\n", encoding="utf-8")
sys.path.insert(0, str(root))
try:
with patch.dict(os.environ, {"GOVOPLAN_TRUSTED_IMPORT_PREFIXES": "custom_config"}):
self.assertEqual(import_object("custom_config:value"), 42)
finally:
sys.path.remove(str(root))
sys.modules.pop("custom_config", None)
def test_builtin_manifest_imports_are_loaded_from_fixed_targets(self) -> None:
manifests = available_module_manifests(enabled_modules=("access",), ignore_load_errors=True)
if "access" in manifests:
self.assertEqual(manifests["access"].id, "access")
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -3,11 +3,24 @@ from __future__ import annotations
import unittest import unittest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from pydantic import ValidationError
from govoplan_core.core.install_config import env_template, validate_runtime_configuration from govoplan_core.core.install_config import env_template, validate_runtime_configuration
from govoplan_core.settings import Settings
class InstallConfigTests(unittest.TestCase): class InstallConfigTests(unittest.TestCase):
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
self.assertEqual(
Settings(
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="0"
).calendar_outbox_terminal_retention_days,
0,
)
with self.assertRaises(ValidationError):
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None: def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
result = validate_runtime_configuration({}, profile="self-hosted") result = validate_runtime_configuration({}, profile="self-hosted")
@@ -68,8 +81,10 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted) self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
self.assertIn("MASTER_KEY_B64=<generate", self_hosted) self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like) self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like) self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
if __name__ == "__main__": if __name__ == "__main__":
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import unittest
from govoplan_core.mail.config import normalize_split_transport_credentials
class MailConfigTests(unittest.TestCase):
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
payload = normalize_split_transport_credentials(
{
"smtp": {"host": "smtp.example.test", "username": "smtp-user", "password": "smtp-secret"},
"imap": {"host": "imap.example.test", "enabled": True, "username": "imap-user"},
"credentials": {"smtp": {"username": "existing"}},
}
)
self.assertEqual(
{
"smtp": {"host": "smtp.example.test"},
"imap": {"host": "imap.example.test"},
"credentials": {
"smtp": {"username": "existing", "password": "smtp-secret"},
"imap": {"username": "imap-user"},
},
},
payload,
)
if __name__ == "__main__":
unittest.main()
+57 -22
View File
@@ -14,7 +14,6 @@ import tempfile
import textwrap import textwrap
import tomllib import tomllib
import unittest import unittest
import urllib.error
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -35,6 +34,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_core.core.events import event_context from govoplan_core.core.events import event_context
from govoplan_core.core import module_installer as module_installer_module
from govoplan_core.core.migrations import migration_metadata_plan from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.core.module_management import ( from govoplan_core.core.module_management import (
ModuleInstallPlan, ModuleInstallPlan,
@@ -227,7 +227,7 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests)) self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ()) self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities) self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail")) self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
self.assertEqual(manifests["dashboard"].dependencies, ()) self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities) self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ()) self.assertEqual(manifests["docs"].dependencies, ())
@@ -272,14 +272,16 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read")) self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read")) self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read")) self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "system:tenants:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read")) self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
def test_core_webui_retired_legacy_admin_api_surface(self) -> None: def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src" webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
legacy_admin_import_pattern = re.compile(r"api/admin(?=[\"'#?]|$)")
self.assertFalse((webui_src / "api" / "admin.ts").exists()) self.assertFalse((webui_src / "api" / "admin.ts").exists())
for path in webui_src.rglob("*.ts*"): for path in webui_src.rglob("*.ts*"):
with self.subTest(path=path.relative_to(webui_src)): with self.subTest(path=path.relative_to(webui_src)):
self.assertNotIn("api/admin", path.read_text(encoding="utf-8")) self.assertIsNone(legacy_admin_import_pattern.search(path.read_text(encoding="utf-8")))
def test_platform_modules_own_live_legacy_model_tables(self) -> None: def test_platform_modules_own_live_legacy_model_tables(self) -> None:
from govoplan_admin.backend.db.models import GovernanceTemplate from govoplan_admin.backend.db.models import GovernanceTemplate
@@ -2474,7 +2476,8 @@ finally:
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__]) Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
def fake_run(*_args, **kwargs): def fake_run(*_args, **kwargs):
if kwargs.get("shell"): argv = tuple(_args[0]) if _args else ()
if kwargs.get("shell") or argv == ("restart-fails",):
return SimpleNamespace(returncode=1, stdout="", stderr="restart failed") return SimpleNamespace(returncode=1, stdout="", stderr="restart failed")
return SimpleNamespace(returncode=0, stdout="", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="")
@@ -2562,6 +2565,43 @@ finally:
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8")) self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8")) self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
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)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
backup_command = f"{sys.executable} -c {shlex.quote('print(1)')} && {sys.executable} -c {shlex.quote('print(2)')}"
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Database backup command failed"):
run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url="postgresql://db.example.invalid/govoplan",
runtime_dir=root / "installer",
migrate_database=True,
database_backup_command=backup_command,
database_restore_command="restore-database",
dry_run=True,
)
rejected = module_installer_module._run_operator_command("DATABASE_URL=postgres://db.example.invalid/govoplan pg_dump")
self.assertEqual(2, rejected.returncode)
self.assertIn("Environment assignments", rejected.stderr)
def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None: def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)
@@ -2654,11 +2694,13 @@ finally:
queued = queue_module_installer_request( queued = queue_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
requested_by="user-1", requested_by="user-1",
tenant_id="tenant-1",
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]}, options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
) )
request_id = str(queued["request_id"]) request_id = str(queued["request_id"])
self.assertEqual("queued", queued["status"]) self.assertEqual("queued", queued["status"])
self.assertEqual("tenant-1", queued["tenant_id"])
self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"]) self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"])
self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir))) self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir)))
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir) claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
@@ -2675,7 +2717,7 @@ finally:
self.assertEqual("completed", updated["status"]) self.assertEqual("completed", updated["status"])
self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"]) self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"])
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1") cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1", tenant_id="tenant-1")
cancelled = cancel_module_installer_request( cancelled = cancel_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=str(cancellable["request_id"]), request_id=str(cancellable["request_id"]),
@@ -2691,6 +2733,7 @@ finally:
) )
self.assertEqual("queued", retry["status"]) self.assertEqual("queued", retry["status"])
self.assertEqual(cancellable["request_id"], retry["retry_of"]) self.assertEqual(cancellable["request_id"], retry["retry_of"])
self.assertEqual("tenant-1", retry["tenant_id"])
settings = _settings(root) settings = _settings(root)
configure_database(settings.database_url) configure_database(settings.database_url)
@@ -3030,11 +3073,11 @@ finally:
"version_min": "0.1.0", "version_min": "0.1.0",
"version_max_exclusive": "0.2.0", "version_max_exclusive": "0.2.0",
}, modules["campaigns"]["requires_interfaces"]) }, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"]) self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower()) self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.7", modules["files"]["version"]) self.assertEqual("0.1.8", modules["files"]["version"])
self.assertIn("@v0.1.7", modules["files"]["python_ref"]) self.assertIn("@v0.1.8", modules["files"]["python_ref"])
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: 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)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
@@ -3067,16 +3110,6 @@ finally:
) )
body = signed_path.read_text(encoding="utf-8") body = signed_path.read_text(encoding="utf-8")
class _Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self) -> bytes:
return body.encode("utf-8")
env = { env = {
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json", "GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path), "GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
@@ -3085,11 +3118,11 @@ finally:
} }
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")} trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
with patch.dict(os.environ, env): with patch.dict(os.environ, env):
with patch("govoplan_core.core.module_package_catalog.urllib.request.urlopen", return_value=_Response()): with patch("govoplan_core.core.module_package_catalog.fetch_http_text", return_value=body):
fetched = validate_module_package_catalog(trusted_keys=trusted_keys) fetched = validate_module_package_catalog(trusted_keys=trusted_keys)
with patch( with patch(
"govoplan_core.core.module_package_catalog.urllib.request.urlopen", "govoplan_core.core.module_package_catalog.fetch_http_text",
side_effect=urllib.error.URLError("offline"), side_effect=OSError("offline"),
): ):
cached = validate_module_package_catalog(trusted_keys=trusted_keys) cached = validate_module_package_catalog(trusted_keys=trusted_keys)
@@ -3800,12 +3833,14 @@ finally:
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules) self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
def test_module_route_factories_receive_runtime_settings(self) -> None: def test_module_route_factories_receive_runtime_settings(self) -> None:
app, settings = self._app_for_modules(("files", "mail")) app, settings = self._app_for_modules(("calendar", "files", "mail"))
self.assertIsNotNone(app) self.assertIsNotNone(app)
from govoplan_calendar.backend.runtime import get_settings as get_calendar_settings
from govoplan_files.backend.runtime import get_settings as get_files_settings from govoplan_files.backend.runtime import get_settings as get_files_settings
from govoplan_mail.backend.runtime import get_settings as get_mail_settings from govoplan_mail.backend.runtime import get_settings as get_mail_settings
self.assertIs(settings, get_calendar_settings())
self.assertIs(settings, get_files_settings()) self.assertIs(settings, get_files_settings())
self.assertIs(settings, get_mail_settings()) self.assertIs(settings, get_mail_settings())
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
class PollContractTests(unittest.TestCase):
def test_response_ref_remains_backwards_compatible_and_can_carry_answers(self) -> None:
submitted_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
legacy = PollResponseRef(None, submitted_at)
detailed = PollResponseRef(
invitation_id="invitation-1",
submitted_at=submitted_at,
respondent_id="person-1",
answers=(PollAnswerRef(option_id="option-1", option_key="slot-1", value="available"),),
)
self.assertEqual(legacy.answers, ())
self.assertEqual(detailed.answers[0].value, "available")
def test_option_update_command_is_a_complete_immutable_snapshot(self) -> None:
command = PollOptionUpdateCommand(
label="Tuesday 10:00",
value={"slot_id": "slot-1"},
metadata={"source": "scheduling"},
)
self.assertEqual(command.label, "Tuesday 10:00")
self.assertEqual(command.value, {"slot_id": "slot-1"})
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import unittest
from pydantic import ValidationError
from govoplan_core.privacy.schemas import (
RETENTION_POLICY_FIELD_KEYS,
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyPatchItem,
default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
class PrivacySchemaContractTests(unittest.TestCase):
def test_default_allow_lower_level_limits_cover_every_retention_field(self) -> None:
self.assertEqual({key: True for key in RETENTION_POLICY_FIELD_KEYS}, default_allow_lower_level_limits())
def test_policy_item_fills_missing_lower_level_limits(self) -> None:
item = PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertFalse(item.allow_lower_level_limits["audit_detail_level"])
self.assertTrue(item.allow_lower_level_limits["store_raw_campaign_json"])
def test_policy_patch_keeps_lower_level_limits_sparse(self) -> None:
patch = PrivacyRetentionPolicyPatchItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertEqual({"audit_detail_level": False}, patch.allow_lower_level_limits)
self.assertIsNone(normalize_allow_lower_level_limits("", fill_defaults=False))
def test_unknown_lower_level_limit_field_is_rejected(self) -> None:
with self.assertRaises(ValidationError):
PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"unknown": True}})
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import os
from unittest import TestCase
from unittest.mock import patch
from fastapi import APIRouter
from fastapi.testclient import TestClient
from sqlalchemy import text
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.db.session import create_database_engine
from govoplan_core.server.fastapi import create_govoplan_app
class QueryMetricsTests(TestCase):
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
try:
with collect_query_metrics() as metrics:
with engine.connect() as connection:
self.assertEqual(1, connection.execute(text("select 1")).scalar_one())
self.assertEqual(1, metrics.query_count)
self.assertGreaterEqual(metrics.total_ms, 0.0)
self.assertGreaterEqual(metrics.slowest_ms, 0.0)
self.assertEqual(0, metrics.error_count)
finally:
engine.dispose()
def test_slow_request_log_includes_query_metrics(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
router = APIRouter()
@router.get("/query")
def query_route() -> dict[str, bool]:
with engine.connect() as connection:
connection.execute(text("select 1")).scalar_one()
return {"ok": True}
try:
with patch.dict(os.environ, {"GOVOPLAN_SLOW_REQUEST_MS": "0.001"}):
app = create_govoplan_app(
title="query metrics test",
version="0",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client, self.assertLogs("govoplan.request", level="WARNING") as logs:
response = client.get("/query")
self.assertEqual(200, response.status_code, response.text)
output = "\n".join(logs.output)
self.assertIn("db_query_count=1", output)
self.assertIn("db_time_ms=", output)
self.assertIn("db_slowest_ms=", output)
self.assertIn("db_error_count=0", output)
finally:
engine.dispose()
-187
View File
@@ -1,187 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "release-doctor.py"
def load_doctor_module():
spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseDoctorTests(unittest.TestCase):
def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None:
doctor = load_doctor_module()
names = set(doctor.release_repo_names(ROOT.parent))
self.assertIn("govoplan-core", names)
self.assertIn("govoplan-files", names)
self.assertIn("govoplan-idm", names)
def test_repo_findings_detect_dirty_repositories(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
pyproject_version="0.1.7",
local_tag_exists=True,
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("blocker", findings[0].severity)
self.assertEqual("repo-dirty", findings[0].check_id)
self.assertIn("git status --short", [item.command for item in findings[0].commands])
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
safe_directory_required=True,
safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files",
errors=("fatal: detected dubious ownership in repository",),
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("repo-safe-directory", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
self.assertTrue(findings[0].commands[0].mutating)
self.assertIn("safe.directory", findings[0].commands[0].command)
def test_dubious_ownership_detection_matches_git_error(self) -> None:
doctor = load_doctor_module()
self.assertTrue(
doctor.is_dubious_ownership_error(
"fatal: detected dubious ownership in repository at '/workspace/repo'\n"
"git config --global --add safe.directory /workspace/repo"
)
)
def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None:
doctor = load_doctor_module()
findings = doctor.migration_findings(
{
"release": {
"ok": True,
"graph_errors": [],
"strict_errors": ["current migration heads are not recorded"],
},
"dev": {"ok": True, "graph_errors": [], "strict_errors": []},
},
target_version="0.2.0",
)
self.assertEqual("migration-release-baseline", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
commands = [item.command for item in findings[0].commands]
self.assertTrue(any("--record-release 0.2.0" in item for item in commands))
def test_catalog_findings_detect_missing_signatures(self) -> None:
doctor = load_doctor_module()
findings = doctor.catalog_findings(
{
"catalog_exists": True,
"catalog_path": "/tmp/stable.json",
"core_release_version": "0.1.7",
"signature_count": 0,
"keyring_exists": True,
"key_count": 1,
},
target_version="0.1.7",
online=False,
)
self.assertEqual("catalog-signatures", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
def test_suggested_commands_are_deduplicated(self) -> None:
doctor = load_doctor_module()
suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo")
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(),
findings=(
doctor.Finding("a", "blocker", "A", "", (suggested,)),
doctor.Finding("b", "warning", "B", "", (suggested,)),
),
)
self.assertEqual((suggested,), doctor.collect_suggested_commands(report))
def test_default_report_is_concise_and_keeps_details_out(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),),
)
concise = doctor.render_text_report(report, detailed=False, include_commands=False)
detailed = doctor.render_text_report(report, detailed=True, include_commands=False)
self.assertIn("dirty=1", concise)
self.assertNotIn("M pyproject.toml", concise)
self.assertIn("M pyproject.toml", detailed)
def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(),
)
actions = dict(doctor.interactive_actions(report))
self.assertIn("commit-push-dirty", actions)
if __name__ == "__main__":
unittest.main()
-190
View File
@@ -1,190 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "release-migration-audit.py"
def load_audit_module():
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseMigrationAuditTests(unittest.TestCase):
def test_typed_alembic_variables_are_parsed(self) -> None:
audit = load_audit_module()
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
path = Path(directory) / "1234_example.py"
path.write_text(
"""
from __future__ import annotations
from typing import Sequence, Union
revision: str = "1234"
down_revision: Union[str, None] = "base"
depends_on: Union[str, None] = "core"
branch_labels: Union[str, Sequence[str], None] = None
""",
encoding="utf-8",
)
migration = audit.parse_migration_file("govoplan-core", path)
self.assertIsNotNone(migration)
self.assertEqual(migration.revision, "1234")
self.assertEqual(migration.down_revisions, ("base",))
self.assertEqual(migration.depends_on, ("core",))
self.assertEqual(migration.branch_labels, ())
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-core", "revision": "head"}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["head"])
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["strict_errors"], [])
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["core-head"])
self.assertEqual(report["strict_errors"], [])
def test_records_current_release_baseline(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
baseline = audit.record_release_baseline(
{"version": 1, "releases": []},
report,
release="0.2.0",
replace=False,
)
release = baseline["releases"][0]
self.assertEqual(release["release"], "0.2.0")
self.assertEqual(release["squash_policy"], "reviewed-manual")
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
def test_missing_down_revision_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing down_revision", report["graph_errors"][0])
def test_depends_on_participates_in_graph_validation_and_heads(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()),
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["current_heads"], ["files"])
def test_missing_depends_on_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing depends_on", report["graph_errors"][0])
def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
track="dev",
)
self.assertEqual(report["track"], "dev")
self.assertEqual(report["strict_errors"], [])
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.policy import (
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
PolicySourceStep,
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyPolicy,
SchedulingParticipantPrivacyRequest,
scheduling_participant_privacy_policy,
)
from govoplan_core.core.registry import PlatformRegistry
class _PrivacyPolicy:
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
del session
return SchedulingParticipantPrivacyDecision(
effective_visibility="aggregates_only",
reason=f"Participant roster is restricted for {request.scheduling_request_id}.",
details={"requested_visibility": request.requested_visibility},
)
class SchedulingPrivacyContractTests(unittest.TestCase):
def test_capability_name_and_request_context_are_stable(self) -> None:
self.assertEqual(
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
"policy.schedulingParticipantPrivacy",
)
request = SchedulingParticipantPrivacyRequest(
tenant_id="tenant-1",
scheduling_request_id="request-1",
participant_id="participant-1",
actor_user_id="user-1",
requested_visibility="names_and_statuses",
context={"request_status": "open"},
)
self.assertEqual(request.requested_visibility, "names_and_statuses")
self.assertEqual(request.context["request_status"], "open")
def test_decision_serializes_policy_provenance(self) -> None:
decision = SchedulingParticipantPrivacyDecision(
effective_visibility="aggregates_only",
reason="Tenant privacy policy restricts the participant roster.",
source_path=(
PolicySourceStep(
scope_type="tenant",
scope_id="tenant-1",
label="Tenant",
applied_fields=("scheduling_participant_visibility",),
),
),
details={"requested_visibility": "names_and_statuses"},
)
payload = decision.to_dict()
self.assertEqual(payload["effective_visibility"], "aggregates_only")
self.assertEqual(payload["source_path"][0]["path"], "tenant:tenant-1")
self.assertEqual(payload["details"]["requested_visibility"], "names_and_statuses")
def test_optional_provider_resolves_through_platform_registry(self) -> None:
provider = _PrivacyPolicy()
self.assertIsInstance(provider, SchedulingParticipantPrivacyPolicy)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="policy_test",
name="Policy test",
version="test",
capability_factories={
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider,
},
),
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertIs(scheduling_participant_privacy_policy(registry), provider)
def test_optional_provider_lookup_does_not_choose_a_fallback(self) -> None:
self.assertIsNone(scheduling_participant_privacy_policy(None))
self.assertIsNone(scheduling_participant_privacy_policy(PlatformRegistry()))
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
operation_for_object,
operation_for_soft_deletable,
previous_value,
)
class Base(DeclarativeBase):
pass
class ExampleRecord(Base):
__tablename__ = "example_records"
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class SqlalchemyChangeTrackingTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine, expire_on_commit=False)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_pending_object_is_created_and_can_receive_id(self) -> None:
record = ExampleRecord(id="", name="Draft", deleted_at=None)
self.session.add(record)
self.assertEqual(ensure_object_id(record, lambda: "record-1"), "record-1")
self.assertEqual(record.id, "record-1")
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "created")
def test_changed_object_reports_update_and_previous_value(self) -> None:
record = ExampleRecord(id="record-1", name="Before", deleted_at=None)
self.session.add(record)
self.session.flush()
record.name = "After"
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "updated")
self.assertEqual(previous_value(record, "name"), "Before")
def test_soft_delete_and_restore_are_classified(self) -> None:
deleted_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
record = ExampleRecord(id="record-1", name="Record", deleted_at=None)
self.session.add(record)
self.session.flush()
record.deleted_at = deleted_at
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "deleted")
self.session.flush()
record.deleted_at = None
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "created")
if __name__ == "__main__":
unittest.main()
+72 -3
View File
@@ -1,14 +1,15 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui", "@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui", "@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
@@ -18,9 +19,11 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui" "@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
@@ -60,6 +63,22 @@
} }
} }
}, },
"../../govoplan-addresses/webui": {
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-admin/webui": { "../../govoplan-admin/webui": {
"name": "@govoplan/admin-webui", "name": "@govoplan/admin-webui",
"version": "0.1.8", "version": "0.1.8",
@@ -228,6 +247,25 @@
} }
} }
}, },
"../../govoplan-notifications/webui": {
"name": "@govoplan/notifications-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-ops/webui": { "../../govoplan-ops/webui": {
"name": "@govoplan/ops-webui", "name": "@govoplan/ops-webui",
"version": "0.1.8", "version": "0.1.8",
@@ -282,6 +320,25 @@
} }
} }
}, },
"../../govoplan-scheduling/webui": {
"name": "@govoplan/scheduling-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1010,6 +1067,10 @@
"resolved": "../../govoplan-access/webui", "resolved": "../../govoplan-access/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/addresses-webui": {
"resolved": "../../govoplan-addresses/webui",
"link": true
},
"node_modules/@govoplan/admin-webui": { "node_modules/@govoplan/admin-webui": {
"resolved": "../../govoplan-admin/webui", "resolved": "../../govoplan-admin/webui",
"link": true "link": true
@@ -1046,6 +1107,10 @@
"resolved": "../../govoplan-mail/webui", "resolved": "../../govoplan-mail/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/notifications-webui": {
"resolved": "../../govoplan-notifications/webui",
"link": true
},
"node_modules/@govoplan/ops-webui": { "node_modules/@govoplan/ops-webui": {
"resolved": "../../govoplan-ops/webui", "resolved": "../../govoplan-ops/webui",
"link": true "link": true
@@ -1058,6 +1123,10 @@
"resolved": "../../govoplan-policy/webui", "resolved": "../../govoplan-policy/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/scheduling-webui": {
"resolved": "../../govoplan-scheduling/webui",
"link": true
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8", "@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/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -21,6 +21,8 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173", "preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs", "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: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:module-permutations": "node scripts/test-module-permutations.mjs", "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"
@@ -28,6 +30,7 @@
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui", "@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui", "@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui", "@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui", "@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
@@ -36,9 +39,11 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui" "@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -24,6 +24,7 @@
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8", "@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/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/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/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", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
@@ -31,6 +32,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8", "@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/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/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.8",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8", "@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/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8");
const normalized = source.replace(/\s+/g, " ");
function assertIncludes(fragment, message) {
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
}
assertIncludes(
"function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) { event.preventDefault(); event.stopPropagation();",
"drag enter/over must prevent the browser default and stop propagation"
);
assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler");
assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler");
assertIncludes(
"onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();",
"drag leave must prevent the browser default and stop propagation"
);
assertIncludes(
"onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);",
"drop must prevent the browser default and stop propagation before resolving files"
);
assertIncludes(
"if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }",
"disabled drops must route the disabled rejection reason"
);
assertIncludes(
"void resolveDroppedFiles(event.dataTransfer).then((files) => {",
"drop must route the event DataTransfer through the shared resolver"
);
assertIncludes(
"if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);",
"resolved files and empty drops must be routed through their explicit callbacks"
);
assertIncludes(
".catch(() => onRejectedDrop?.(\"unreadable\"))",
"resolver failures must route the unreadable rejection reason"
);
console.log("FileDropZone event and result routing structure is intact.");
+9 -2
View File
@@ -3,7 +3,9 @@ import { spawnSync } from "node:child_process";
const packageByModule = { const packageByModule = {
access: "@govoplan/access-webui", access: "@govoplan/access-webui",
admin: "@govoplan/admin-webui", admin: "@govoplan/admin-webui",
addresses: "@govoplan/addresses-webui",
audit: "@govoplan/audit-webui", audit: "@govoplan/audit-webui",
calendar: "@govoplan/calendar-webui",
campaigns: "@govoplan/campaign-webui", campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui", dashboard: "@govoplan/dashboard-webui",
docs: "@govoplan/docs-webui", docs: "@govoplan/docs-webui",
@@ -12,16 +14,19 @@ const packageByModule = {
mail: "@govoplan/mail-webui", mail: "@govoplan/mail-webui",
organizations: "@govoplan/organizations-webui", organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui", ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui" policy: "@govoplan/policy-webui",
scheduling: "@govoplan/scheduling-webui"
}; };
const cases = [ const cases = [
{ name: "core-only", modules: [] }, { name: "core-only", modules: [] },
{ name: "access-only", modules: ["access"] }, { name: "access-only", modules: ["access"] },
{ name: "admin-only", modules: ["admin"] }, { name: "admin-only", modules: ["admin"] },
{ name: "addresses-only", modules: ["addresses"] },
{ name: "access-with-admin", modules: ["access", "admin"] }, { name: "access-with-admin", modules: ["access", "admin"] },
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] }, { name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] }, { name: "dashboard-only", modules: ["dashboard"] },
{ name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] }, { name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] }, { name: "mail-only", modules: ["mail"] },
{ name: "organizations-only", modules: ["organizations"] }, { name: "organizations-only", modules: ["organizations"] },
@@ -29,8 +34,10 @@ const cases = [
{ name: "campaign-only", modules: ["campaigns"] }, { name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] }, { name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] }, { name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops"] } { name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "calendar", "scheduling"] }
]; ];
const npmExec = process.env.npm_execpath; const npmExec = process.env.npm_execpath;
+131 -39
View File
@@ -1,9 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchMe, updateProfile } from "./api/auth"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform"; import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client"; import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthTenant, AuthTenantMembership, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types"; import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell"; import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
@@ -32,6 +32,7 @@ export default function App() {
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null); const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]); const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null }); 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); const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
const [reloginMessage, setReloginMessage] = useState(""); const [reloginMessage, setReloginMessage] = useState("");
@@ -47,9 +48,9 @@ export default function App() {
saveApiSettings(next); saveApiSettings(next);
} }
function updateAuth(next: AuthInfo | null, accessToken?: string) { function updateAuth(next: AuthUpdate | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings; const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
setAuth(next ? normalizeAuthInfo(next) : null); setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
if (accessToken !== undefined) { if (accessToken !== undefined) {
setSettings(nextSettings); setSettings(nextSettings);
saveApiSettings(nextSettings); saveApiSettings(nextSettings);
@@ -87,6 +88,7 @@ export default function App() {
try { try {
const response = await fetchPlatformStatus(settings); const response = await fetchPlatformStatus(settings);
if (cancelled) return; if (cancelled) return;
setBackendReachable(true);
setMaintenanceMode(response.maintenance_mode); setMaintenanceMode(response.maintenance_mode);
if (response.i18n) { if (response.i18n) {
setSystemLanguages({ setSystemLanguages({
@@ -100,7 +102,10 @@ export default function App() {
}); });
} }
} catch { } catch {
if (!cancelled) setMaintenanceMode({ enabled: false, message: null }); if (!cancelled) {
setBackendReachable(false);
setMaintenanceMode({ enabled: false, message: null });
}
} }
} }
@@ -117,21 +122,30 @@ export default function App() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setCheckingSession(true); setCheckingSession(true);
fetchMe(settings).
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}). async function bootstrapAuth() {
catch(() => { try {
if (!cancelled) { const shellAuth = await fetchShellAuth(settings);
const cleared = { ...settings, accessToken: "" }; if (!cancelled) {
setSettings(cleared); setBackendReachable(true);
saveApiSettings(cleared); setAuth(normalizeAuthInfo(shellAuth));
setAuth(null); }
setPlatformModules(null); } catch (error) {
setRemoteWebModules([]); if (!cancelled) {
setBackendReachable(isApiError(error));
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
}
} finally {
if (!cancelled) setCheckingSession(false);
} }
}). }
finally(() => {
if (!cancelled) setCheckingSession(false); void bootstrapAuth();
});
return () => {cancelled = true;}; return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey]); }, [settings.apiBaseUrl, settings.apiKey]);
@@ -180,11 +194,25 @@ export default function App() {
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints); root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion); root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars); root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
if (preferences.theme === "system") {
delete root.dataset.theme; const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
} else { const applyTheme = () => {
root.dataset.theme = preferences.theme; const resolvedTheme = preferences.theme === "system" ?
systemDarkQuery?.matches ? "dark" : "light" :
preferences.theme;
root.dataset.theme = resolvedTheme;
root.dataset.themePreference = preferences.theme;
};
applyTheme();
if (preferences.theme !== "system" || !systemDarkQuery) {
return undefined;
} }
systemDarkQuery.addEventListener("change", applyTheme);
return () => {
systemDarkQuery.removeEventListener("change", applyTheme);
};
}, [ }, [
auth?.user.ui_preferences?.compact_tables, auth?.user.ui_preferences?.compact_tables,
auth?.user.ui_preferences?.show_inline_help_hints, auth?.user.ui_preferences?.show_inline_help_hints,
@@ -208,8 +236,11 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
const currentAuth = auth;
let cancelled = false;
let inFlight = false; let inFlight = false;
let lastRefreshAt = 0; let lastRefreshAt = 0;
let lastShellRefreshAt = Date.now();
async function refreshVisibleSession() { async function refreshVisibleSession() {
if (document.visibilityState === "hidden" || inFlight) return; if (document.visibilityState === "hidden" || inFlight) return;
@@ -219,8 +250,22 @@ export default function App() {
inFlight = true; inFlight = true;
lastRefreshAt = now; lastRefreshAt = now;
try { try {
setAuth(normalizeAuthInfo(await fetchMe(settings))); const sessionInfo = await fetchSession(settings);
} catch { if (cancelled) return;
setBackendReachable(true);
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
const shellAuth = await fetchShellAuth(settings);
if (cancelled) return;
lastShellRefreshAt = Date.now();
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
: normalizeAuthInfo(shellAuth));
}
} catch (error) {
if (!cancelled && !isApiError(error)) {
setBackendReachable(false);
}
// A background refresh must not log the user out on a transient network error. // A background refresh must not log the user out on a transient network error.
@@ -230,6 +275,7 @@ export default function App() {
window.addEventListener("focus", refreshVisibleSession); window.addEventListener("focus", refreshVisibleSession);
document.addEventListener("visibilitychange", refreshVisibleSession); document.addEventListener("visibilitychange", refreshVisibleSession);
return () => { return () => {
cancelled = true;
window.removeEventListener("focus", refreshVisibleSession); window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession); document.removeEventListener("visibilitychange", refreshVisibleSession);
}; };
@@ -240,7 +286,7 @@ export default function App() {
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}> <AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing"> <div className="public-landing">
<section className="public-card"> <section className="public-card">
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div> <div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
@@ -260,7 +306,7 @@ export default function App() {
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}> <AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} /> <PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
</AppShell> </AppShell>
</UnsavedChangesProvider> </UnsavedChangesProvider>
@@ -291,7 +337,7 @@ export default function App() {
moduleTranslations={moduleTranslations}> moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode}> <AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}> <Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}> <Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />
@@ -327,18 +373,40 @@ export default function App() {
} }
type AuthPayload = Partial<AuthInfo> & { type AuthPayload = AuthUpdate;
principal?: AuthInfo["principal"];
user?: Partial<AuthUser> | null; function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
tenant?: AuthTenant | null; if (!current) return next;
active_tenant?: AuthTenant | null; const nextActiveTenant = next.active_tenant ?? next.tenant ?? null;
tenants?: AuthTenantMembership[] | null; const currentActiveTenant = current.active_tenant ?? current.tenant;
}; const tenantChanged = Boolean(nextActiveTenant && nextActiveTenant.id !== currentActiveTenant.id);
return {
...current,
...next,
user: next.user ? { ...current.user, ...next.user } : current.user,
tenant: nextActiveTenant ?? current.tenant,
active_tenant: nextActiveTenant ?? currentActiveTenant,
tenants: next.tenants ?? current.tenants,
scopes: next.scopes ?? current.scopes,
roles: next.roles ?? (next.roles_loaded === false || tenantChanged ? [] : current.roles),
groups: next.groups ?? (next.groups_loaded === false || tenantChanged ? [] : current.groups),
principal: next.principal === undefined ? current.principal : next.principal,
available_languages: next.available_languages ?? (tenantChanged ? undefined : current.available_languages),
enabled_language_codes: next.enabled_language_codes ?? (tenantChanged ? undefined : current.enabled_language_codes),
default_language: next.default_language ?? (tenantChanged ? undefined : current.default_language),
profile_loaded: next.profile_loaded ?? (tenantChanged ? false : current.profile_loaded),
roles_loaded: next.roles_loaded ?? (tenantChanged ? false : current.roles_loaded),
groups_loaded: next.groups_loaded ?? (tenantChanged ? false : current.groups_loaded)
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo { function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null; const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null; const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal); const user = normalizeAuthUser(response.user, principal);
const profileLoaded = response.profile_loaded ?? hasFullProfilePayload(response);
const rolesLoaded = response.roles_loaded ?? response.roles !== undefined;
const groupsLoaded = response.groups_loaded ?? response.groups !== undefined;
if (!activeTenant) { if (!activeTenant) {
throw new Error("Authentication response did not include an active tenant."); throw new Error("Authentication response did not include an active tenant.");
@@ -356,12 +424,25 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
roles: response.roles ?? [], roles: response.roles ?? [],
groups: response.groups ?? [], groups: response.groups ?? [],
principal, principal,
available_languages: response.available_languages ?? [], available_languages: response.available_languages,
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [], enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" : undefined,
profile_loaded: profileLoaded,
roles_loaded: rolesLoaded,
groups_loaded: groupsLoaded
}; };
} }
function hasFullProfilePayload(response: AuthPayload): boolean {
return Boolean(
response.default_language ||
response.available_languages ||
response.enabled_language_codes ||
response.roles ||
response.groups
);
}
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null { function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
if (user?.id && user.account_id) { if (user?.id && user.account_id) {
return { return {
@@ -396,6 +477,17 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
}; };
} }
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
if (auth.principal?.api_key_id && sessionInfo.api_key_id && auth.principal.api_key_id !== sessionInfo.api_key_id) return false;
return true;
}
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences { function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system"; const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
return { return {
+56
View File
@@ -0,0 +1,56 @@
import type { ApiSettings } from "../types";
import { apiFetch, apiGetList } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
return apiGetList<PermissionItem, "permissions">(settings, "/api/v1/admin/permissions", "permissions");
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
return apiGetList<TenantAdminItem, "tenants">(settings, "/api/v1/admin/tenants", "tenants");
}
+25 -5
View File
@@ -1,4 +1,4 @@
import type { ApiSettings, AuthInfo, LoginResponse, UserUiPreferences } from "../types"; import type { ApiSettings, AuthGroupsInfo, AuthInfo, AuthProfileInfo, AuthRolesInfo, AuthSessionInfo, AuthShellInfo, LoginResponse, UserUiPreferences } from "../types";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
export async function login( export async function login(
@@ -15,8 +15,28 @@ export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me"); return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
} }
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> { export async function fetchSession(settings: ApiSettings): Promise<AuthSessionInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", { return apiFetch<AuthSessionInfo>(settings, "/api/v1/auth/session", { cache: "no-store" });
}
export async function fetchShellAuth(settings: ApiSettings): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/shell", { cache: "no-store" });
}
export async function fetchAuthProfile(settings: ApiSettings): Promise<AuthProfileInfo> {
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", { cache: "no-store" });
}
export async function fetchAuthRoles(settings: ApiSettings): Promise<AuthRolesInfo> {
return apiFetch<AuthRolesInfo>(settings, "/api/v1/auth/roles", { cache: "no-store" });
}
export async function fetchAuthGroups(settings: ApiSettings): Promise<AuthGroupsInfo> {
return apiFetch<AuthGroupsInfo>(settings, "/api/v1/auth/groups", { cache: "no-store" });
}
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/switch-tenant", {
method: "POST", method: "POST",
body: JSON.stringify({ tenant_id: tenantId }) body: JSON.stringify({ tenant_id: tenantId })
}); });
@@ -35,8 +55,8 @@ export async function updateProfile(
enabled_language_codes?: string[] | null; enabled_language_codes?: string[] | null;
ui_preferences?: Partial<UserUiPreferences> | null; ui_preferences?: Partial<UserUiPreferences> | null;
} }
): Promise<AuthInfo> { ): Promise<AuthProfileInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", { return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", {
method: "PATCH", method: "PATCH",
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
+54 -4
View File
@@ -1,9 +1,9 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
const STORAGE_KEY = "multimailer.apiSettings"; const STORAGE_KEY = "govoplan.apiSettings";
const LEGACY_STORAGE_KEYS = ["i18n:govoplan-core.multimailer_apisettings.1d1601d4"]; const LEGACY_STORAGE_KEYS: string[] = [];
const SESSION_STORAGE_KEY = "multimailer.session"; const SESSION_STORAGE_KEY = "govoplan.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf"; const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "govoplan_csrf";
const RECENT_SAFE_REQUEST_TTL_MS = 750; const RECENT_SAFE_REQUEST_TTL_MS = 750;
const MAX_RECENT_SAFE_REQUESTS = 100; const MAX_RECENT_SAFE_REQUESTS = 100;
const MAX_CONDITIONAL_SAFE_REQUESTS = 200; const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
@@ -77,6 +77,56 @@ export function apiUrl(settings: ApiSettings, path: string): string {
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath; return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
} }
export type ApiQueryValue = string | number | boolean | null | undefined;
export type ApiQueryParams = Record<string, ApiQueryValue | readonly ApiQueryValue[]>;
function queryValue(value: ApiQueryValue): string | null {
if (value === null || value === undefined || value === "") return null;
return String(value);
}
export function apiQuery(params: ApiQueryParams = {}): string {
const search = new URLSearchParams();
for (const [key, rawValue] of Object.entries(params)) {
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
for (const value of values) {
const normalized = queryValue(value);
if (normalized !== null) search.append(key, normalized);
}
}
const query = search.toString();
return query ? `?${query}` : "";
}
export function apiPath(path: string, params: ApiQueryParams = {}): string {
const query = apiQuery(params);
if (!query) return path;
return path.includes("?") ? `${path}&${query.slice(1)}` : `${path}${query}`;
}
export async function apiGetList<TItem, K extends string>(
settings: ApiSettings,
path: string,
key: K,
params: ApiQueryParams = {}
): Promise<TItem[]> {
const response = await apiFetch<Record<K, TItem[] | null | undefined>>(settings, apiPath(path, params));
return response[key] ?? [];
}
export function apiPost<TResponse>(settings: ApiSettings, path: string, init: Omit<RequestInit, "method"> = {}): Promise<TResponse> {
return apiFetch<TResponse>(settings, path, { ...init, method: "POST" });
}
export function apiPostJson<TResponse, TPayload = unknown>(
settings: ApiSettings,
path: string,
payload: TPayload,
init: Omit<RequestInit, "method" | "body"> = {}
): Promise<TResponse> {
return apiPost<TResponse>(settings, path, { ...init, body: JSON.stringify(payload) });
}
export function loadApiSettings(): ApiSettings { export function loadApiSettings(): ApiSettings {
const storedBaseUrl = loadStoredSetting("baseUrl"); const storedBaseUrl = loadStoredSetting("baseUrl");
const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`); const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`);
+143
View File
@@ -0,0 +1,143 @@
import type {
MailImapTransportSettings,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfileCredentials,
MailTransportCredentials,
MailTransportSettings
} from "../types";
export type {
MailCredentialPolicy,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile
} from "../types";
export type MailSmtpTestPayload = MailTransportSettings;
export type MailImapTestPayload = MailImapTransportSettings;
export type MailTransportCredentialsPayload = MailTransportCredentials;
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
from_cache?: boolean;
refreshing?: boolean;
indexed_at?: string | null;
details?: Record<string, unknown>;
};
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const satisfies readonly MailProfilePatternKey[];
export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
] as const;
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
export type MailPolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: MailProfilePolicy | null;
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: MailPolicySourceStep[];
parent_policy_sources?: MailPolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
+4 -11
View File
@@ -1,5 +1,5 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
import { apiFetch } from "./client"; import { apiFetch, apiPath } from "./client";
export type PrivacyRetentionPolicyFieldKey = export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json" | "store_raw_campaign_json"
@@ -78,13 +78,6 @@ export type RetentionRunResponse = {
}; };
}; };
function retentionPolicyQuery(scopeId?: string | null): string {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function getPrivacyRetentionPolicy( export function getPrivacyRetentionPolicy(
settings: ApiSettings, settings: ApiSettings,
scope: PrivacyRetentionPolicyScope, scope: PrivacyRetentionPolicyScope,
@@ -92,7 +85,7 @@ export function getPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> { ): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}` apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId })
); );
} }
@@ -103,7 +96,7 @@ export function explainPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyExplainResponse> { ): Promise<PrivacyRetentionPolicyExplainResponse> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${retentionPolicyQuery(scopeId)}` apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain`, { scope_id: scopeId })
); );
} }
@@ -116,7 +109,7 @@ export function updatePrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> { ): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`, apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId }),
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) } { method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
); );
} }
+103
View File
@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import FormField from "./FormField";
import PasswordField from "./PasswordField";
export type CredentialValues = {
username?: string | null;
password?: string | null;
};
export type CredentialFieldsProps = {
values: CredentialValues;
onChange: (patch: Partial<CredentialValues>) => void;
disabled?: boolean;
usernameDisabled?: boolean;
passwordDisabled?: boolean;
savedPassword?: boolean;
savedPasswordPlaceholder?: string;
heading?: ReactNode;
headingClassName?: string;
usernameLabel?: ReactNode;
passwordLabel?: ReactNode;
usernamePlaceholder?: string;
passwordPlaceholder?: string;
usernameAutoComplete?: string;
passwordAutoComplete?: string;
showUsername?: boolean;
showPassword?: boolean;
};
export type CredentialPanelProps = CredentialFieldsProps & {
className?: string;
gridClassName?: string;
children?: ReactNode;
};
export function CredentialFields({
values,
onChange,
disabled = false,
usernameDisabled = disabled,
passwordDisabled = disabled,
savedPassword = false,
savedPasswordPlaceholder = "••••••••",
heading,
headingClassName = "credential-panel-heading",
usernameLabel = "i18n:govoplan-core.username.84c29015",
passwordLabel = "i18n:govoplan-core.password.8be3c943",
usernamePlaceholder,
passwordPlaceholder,
usernameAutoComplete = "username",
passwordAutoComplete = "new-password",
showUsername = true,
showPassword = true
}: CredentialFieldsProps) {
return (
<>
{heading && <div className={headingClassName}>{heading}</div>}
{showUsername &&
<FormField label={usernameLabel}>
<input
value={stringValue(values.username)}
disabled={usernameDisabled}
autoComplete={usernameAutoComplete}
placeholder={usernamePlaceholder}
onChange={(event) => onChange({ username: event.target.value })} />
</FormField>
}
{showPassword &&
<FormField label={passwordLabel}>
<PasswordField
value={stringValue(values.password)}
disabled={passwordDisabled}
saved={savedPassword}
savedPlaceholder={savedPasswordPlaceholder}
placeholder={passwordPlaceholder}
autoComplete={passwordAutoComplete}
onValueChange={(password) => onChange({ password })} />
</FormField>
}
</>);
}
export default function CredentialPanel({
className = "",
gridClassName = "",
children,
...fieldProps
}: CredentialPanelProps) {
return (
<div className={`credential-panel ${className}`.trim()}>
<div className={`credential-panel-grid ${gridClassName}`.trim()}>
<CredentialFields {...fieldProps} />
</div>
{children && <div className="credential-panel-extra">{children}</div>}
</div>);
}
function stringValue(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
return String(value);
}
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import HoverTooltip from "./HoverTooltip";
export type DisabledActionTooltipProps = {
reason?: ReactNode;
children: ReactNode;
className?: string;
};
export default function DisabledActionTooltip({ reason, children, className = "" }: DisabledActionTooltipProps) {
if (!reason) return <>{children}</>;
return (
<HoverTooltip
content={reason}
tone="danger"
className={`disabled-action-tooltip ${className}`.trim()}
triggerTabIndex={0}>
{children}
</HoverTooltip>
);
}
+32 -6
View File
@@ -1,5 +1,8 @@
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react"; import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react";
import { UploadCloud } from "lucide-react"; import { UploadCloud } from "lucide-react";
import { resolveDroppedFiles } from "./resolveDroppedFiles";
type RejectedDropReason = "disabled" | "empty" | "unreadable";
export type FileDropZoneProps = { export type FileDropZoneProps = {
accept?: string; accept?: string;
@@ -14,6 +17,7 @@ export type FileDropZoneProps = {
note?: ReactNode; note?: ReactNode;
className?: string; className?: string;
inputLabel?: string; inputLabel?: string;
onRejectedDrop?: (reason: RejectedDropReason) => void;
onFiles: (files: File[]) => void | Promise<void>; onFiles: (files: File[]) => void | Promise<void>;
}; };
@@ -30,6 +34,7 @@ export default function FileDropZone({
note, note,
className = "", className = "",
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608", inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
onRejectedDrop,
onFiles onFiles
}: FileDropZoneProps) { }: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -52,6 +57,13 @@ export default function FileDropZone({
} }
} }
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
if (!interactionDisabled) setDragActive(true);
}
return ( return (
<> <>
<div <div
@@ -70,15 +82,29 @@ export default function FileDropZone({
inputRef.current?.click(); inputRef.current?.click();
} }
}} }}
onDragOver={(event) => { onDragEnter={prepareFileDrag}
onDragOver={prepareFileDrag}
onDragLeave={(event) => {
event.preventDefault(); event.preventDefault();
if (!interactionDisabled) setDragActive(true); event.stopPropagation();
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
setDragActive(false);
}} }}
onDragLeave={() => setDragActive(false)}
onDrop={(event) => { onDrop={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
setDragActive(false); setDragActive(false);
if (!interactionDisabled) void handleFiles(event.dataTransfer.files); if (interactionDisabled) {
onRejectedDrop?.("disabled");
return;
}
void resolveDroppedFiles(event.dataTransfer).then((files) => {
if (files.length === 0) {
onRejectedDrop?.("empty");
return;
}
void handleFiles(files);
}).catch(() => onRejectedDrop?.("unreadable"));
}}> }}>
{showProgress ? {showProgress ?
@@ -110,4 +136,4 @@ export default function FileDropZone({
</>); </>);
} }
+228
View File
@@ -0,0 +1,228 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type HoverTooltipTone = "default" | "danger";
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
export type HoverTooltipProps = {
content: ReactNode;
children: ReactNode;
className?: string;
tone?: HoverTooltipTone;
ariaLabel?: string;
triggerTabIndex?: number;
openDelayMs?: number;
};
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const BASE_TOOLTIP_STYLE: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
borderRadius: 7,
boxShadow: "var(--shadow-popover)",
fontSize: 12,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function tooltipStyleForTone(tone: HoverTooltipTone): CSSProperties {
if (tone === "danger") {
return {
border: "1px solid var(--border-danger-soft)",
background: "var(--danger-muted-bg)",
color: "var(--danger-text-tooltip)",
fontWeight: 700
};
}
return {
border: "1px solid var(--line-dark)",
background: "var(--surface)",
color: "var(--text)",
fontWeight: 500
};
}
function arrowStyleForTone(tone: HoverTooltipTone): Pick<CSSProperties, "borderColor" | "background"> {
if (tone === "danger") {
return {
borderColor: "var(--border-danger-soft)",
background: "var(--danger-muted-bg)"
};
}
return {
borderColor: "var(--line-dark)",
background: "var(--surface)"
};
}
export default function HoverTooltip({
content,
children,
className = "",
tone = "default",
ariaLabel,
triggerTabIndex,
openDelayMs = 350
}: HoverTooltipProps) {
const { translateText } = usePlatformLanguage();
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
if (!content) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, openDelayMs);
}, [clearOpenTimer, content, openDelayMs]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove
? topCandidate
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
const translatedAriaLabel = ariaLabel ? translateText(ariaLabel) : undefined;
const tooltipStyle: CSSProperties = {
...BASE_TOOLTIP_STYLE,
...tooltipStyleForTone(tone),
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowColors = arrowStyleForTone(tone);
const arrowStyle: CSSProperties = position?.placement === "bottom"
? {
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid",
borderTop: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
}
: {
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid",
borderBottom: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={className}
tabIndex={triggerTabIndex}
aria-label={translatedAriaLabel}
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
{children}
</span>
{isOpen && typeof document !== "undefined" && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof content === "string" ? translateText(content) : content}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}
+10 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Archive, LockKeyhole, Paperclip } from "lucide-react"; import { Archive, Link2, LockKeyhole, Paperclip, X } from "lucide-react";
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext"; import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
import SegmentedControl from "./SegmentedControl"; import SegmentedControl from "./SegmentedControl";
@@ -14,6 +14,7 @@ export type MessageDisplayAttachment = {
contentType?: string | null; contentType?: string | null;
sizeBytes?: number | null; sizeBytes?: number | null;
detail?: string | null; detail?: string | null;
linkedToCampaign?: boolean | null;
archiveGroup?: string | null; archiveGroup?: string | null;
archiveLabel?: string | null; archiveLabel?: string | null;
protected?: boolean | null; protected?: boolean | null;
@@ -159,9 +160,15 @@ function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachm
const contentType = formatContentType(attachment.contentType); const contentType = formatContentType(attachment.contentType);
const size = formatBytes(attachment.sizeBytes); const size = formatBytes(attachment.sizeBytes);
const hasMeta = Boolean(contentType || size); const hasMeta = Boolean(contentType || size);
const LinkIcon = attachment.linkedToCampaign === true ? Link2 : attachment.linkedToCampaign === false ? X : Paperclip;
const linkStateClass = attachment.linkedToCampaign === true ?
" is-linked" :
attachment.linkedToCampaign === false ?
" is-unlinked" :
"";
return ( return (
<div className="message-display-attachment-row"> <div className={`message-display-attachment-row${linkStateClass}`}>
<Paperclip size={14} aria-hidden="true" /> <LinkIcon size={14} aria-hidden="true" />
<span> <span>
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong> <strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>} {attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
+20 -1
View File
@@ -5,15 +5,21 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
type ToggleSwitchProps = { type ToggleSwitchProps = {
label: ReactNode; label: ReactNode;
activeLabel?: ReactNode;
inactiveLabel?: ReactNode;
checked: boolean; checked: boolean;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
disabled?: boolean; disabled?: boolean;
help?: ReactNode; help?: ReactNode;
}; };
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) { export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
const renderedLabel = typeof label === "string" ? translateText(label) : label; const renderedLabel = typeof label === "string" ? translateText(label) : label;
const renderedInactiveLabel = typeof inactiveLabel === "string" ? translateText(inactiveLabel) : inactiveLabel;
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
return ( return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}> <label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
<input <input
@@ -21,12 +27,25 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
type="checkbox" type="checkbox"
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
aria-label={inputLabel}
onChange={(event) => onChange?.(event.target.checked)} onChange={(event) => onChange?.(event.target.checked)}
/> />
{hasStateLabels && inactiveLabel !== undefined &&
<span className="toggle-switch-state-label is-inactive" data-selected={!checked || undefined}>
{renderedInactiveLabel}
</span>
}
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span> <span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
{hasStateLabels && activeLabel !== undefined &&
<span className="toggle-switch-state-label is-active" data-selected={checked || undefined}>
{renderedActiveLabel}
</span>
}
{!hasStateLabels &&
<span className="toggle-switch-copy"> <span className="toggle-switch-copy">
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel> <FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
</span> </span>
}
</label> </label>
); );
} }
@@ -1,4 +1,5 @@
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react"; import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react"; import type { CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Button from "../Button"; import Button from "../Button";
@@ -15,6 +16,7 @@ type EmailAddressInputProps = {
value: MailboxAddress[]; value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void; onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void; onAddressAdded?: (address: MailboxAddress) => void;
onSuggestionQueryChange?: (query: string) => void;
suggestions?: MailboxAddress[]; suggestions?: MailboxAddress[];
allowMultiple?: boolean; allowMultiple?: boolean;
clearOnAdd?: boolean; clearOnAdd?: boolean;
@@ -31,6 +33,7 @@ export default function EmailAddressInput({
value, value,
onChange, onChange,
onAddressAdded, onAddressAdded,
onSuggestionQueryChange,
suggestions = [], suggestions = [],
allowMultiple = true, allowMultiple = true,
clearOnAdd = false, clearOnAdd = false,
@@ -42,6 +45,7 @@ export default function EmailAddressInput({
compact = false, compact = false,
showAddButton showAddButton
}: EmailAddressInputProps) { }: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage();
const inputId = useId(); const inputId = useId();
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]); const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]); const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
@@ -51,9 +55,18 @@ export default function EmailAddressInput({
const [dialogEmail, setDialogEmail] = useState(""); const [dialogEmail, setDialogEmail] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({}); const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const lastSuggestionQueryRef = useRef<string | null>(null);
const addButtonRef = useRef<HTMLButtonElement | null>(null); const addButtonRef = useRef<HTMLButtonElement | null>(null);
const canUseAddButton = showAddButton ?? allowMultiple; const canUseAddButton = showAddButton ?? allowMultiple;
useEffect(() => {
const query = entryText.trim();
if (query === "" && lastSuggestionQueryRef.current === null) return;
if (query === lastSuggestionQueryRef.current) return;
lastSuggestionQueryRef.current = query;
onSuggestionQueryChange?.(query);
}, [entryText, onSuggestionQueryChange]);
const filteredSuggestions = useMemo(() => { const filteredSuggestions = useMemo(() => {
const query = entryText.trim().toLowerCase(); const query = entryText.trim().toLowerCase();
if (!query) return normalizedSuggestions.slice(0, 6); if (!query) return normalizedSuggestions.slice(0, 6);
@@ -168,18 +181,18 @@ export default function EmailAddressInput({
if (event.key === "Escape") setDialogOpen(false); if (event.key === "Escape") setDialogOpen(false);
}}> }}>
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4> <h4 id={`${inputId}-dialog-title`}>{translateText("i18n:govoplan-core.add_address.a71075c4")}</h4>
<label> <label>
<span>i18n:govoplan-core.name.709a2322</span> <span>{translateText("i18n:govoplan-core.name.709a2322")}</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus /> <input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={translateText(namePlaceholder)} autoFocus />
</label> </label>
<label> <label>
<span>i18n:govoplan-core.email_address.c94d3175</span> <span>{translateText("i18n:govoplan-core.email_address.c94d3175")}</span>
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" /> <input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
</label> </label>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button type="button" onClick={() => setDialogOpen(false)}>i18n:govoplan-core.cancel.77dfd213</Button> <Button type="button" onClick={() => setDialogOpen(false)}>{translateText("i18n:govoplan-core.cancel.77dfd213")}</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button> <Button type="button" variant="primary" onClick={commitDialogAddress}>{translateText(addLabel)}</Button>
</div> </div>
</div>, </div>,
document.body document.body
@@ -189,11 +202,11 @@ export default function EmailAddressInput({
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}> <div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}> <div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite"> <div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>} {normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
{normalizedValue.map((address) => { {normalizedValue.map((address) => {
const valid = isValidEmailAddress(address.email); const valid = isValidEmailAddress(address.email);
return ( return (
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}> <span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : translateText("i18n:govoplan-core.invalid_email_address.9e4ee6d7")}>
<span className="email-chip-main">{addressDisplayName(address)}</span> <span className="email-chip-main">{addressDisplayName(address)}</span>
{address.name && <span className="email-chip-address">{address.email}</span>} {address.name && <span className="email-chip-address">{address.email}</span>}
{!disabled && {!disabled &&
@@ -217,11 +230,11 @@ export default function EmailAddressInput({
setError(""); setError("");
}} }}
onKeyDown={handleTextKeyDown} onKeyDown={handleTextKeyDown}
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })} placeholder={translateText(i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: translateText(namePlaceholder), value1: emailPlaceholder }))}
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" /> aria-label={translateText("i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0")} />
{canUseAddButton && {canUseAddButton &&
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="i18n:govoplan-core.open_address_form.f8ee560f" title="i18n:govoplan-core.add_address_with_form.13b3b3e7" onClick={() => setDialogOpen((open) => !open)}> <button ref={addButtonRef} type="button" className="email-address-plus" aria-label={translateText("i18n:govoplan-core.open_address_form.f8ee560f")} title={translateText("i18n:govoplan-core.add_address_with_form.13b3b3e7")} onClick={() => setDialogOpen((open) => !open)}>
+ +
</button> </button>
} }
@@ -230,7 +243,7 @@ export default function EmailAddressInput({
</div> </div>
{!disabled && filteredSuggestions.length > 0 && entryText.trim() && {!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20"> <div className="email-address-suggestions" role="listbox" aria-label={translateText("i18n:govoplan-core.address_suggestions.45ba4a20")}>
{filteredSuggestions.map((item) => {filteredSuggestions.map((item) =>
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option"> <button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
<span>{addressDisplayName(item)}</span> <span>{addressDisplayName(item)}</span>
@@ -239,7 +252,7 @@ export default function EmailAddressInput({
)} )}
</div> </div>
} }
{error && <p className="form-help danger-text">{error}</p>} {error && <p className="form-help danger-text">{translateText(error)}</p>}
{addressDialog} {addressDialog}
</div>); </div>);
+10 -178
View File
@@ -1,188 +1,20 @@
import type { CSSProperties, ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import HoverTooltip from "../HoverTooltip";
import { createPortal } from "react-dom";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
type InlineHelpProps = { type InlineHelpProps = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}; };
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
const OPEN_DELAY_MS = 350;
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",
borderRadius: 7,
background: "var(--surface)",
boxShadow: "var(--shadow-popover)",
color: "var(--text)",
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
export default function InlineHelp({ children, className = "" }: InlineHelpProps) { export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
const { translateText } = usePlatformLanguage();
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, OPEN_DELAY_MS);
}, [clearOpenTimer]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove ?
topCandidate :
clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
if (!children) return null; if (!children) return null;
const tooltipStyle: CSSProperties = {
...tooltipBaseStyle,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowStyle: CSSProperties = position?.placement === "bottom" ?
{
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid var(--line-dark)",
borderTop: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
} :
{
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid var(--line-dark)",
borderBottom: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
};
return ( return (
<> <HoverTooltip
<span content={children}
ref={triggerRef} className={`inline-help ${className}`.trim()}
className={`inline-help ${className}`.trim()} ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
tabIndex={-1} triggerTabIndex={-1}>
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")} <span className="inline-help-mark" aria-hidden="true">?</span>
aria-describedby={isOpen ? tooltipId : undefined} </HoverTooltip>
onMouseEnter={openWithDelay} );
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
<span className="inline-help-mark" aria-hidden="true">?</span>
</span>
{isOpen && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof children === "string" ? translateText(children) : children}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>);
} }
@@ -1,10 +1,9 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import Button from "../Button"; import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
import DismissibleAlert from "../DismissibleAlert"; import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField"; import FormField from "../FormField";
import PasswordField from "../PasswordField";
import SegmentedControl from "../SegmentedControl"; import SegmentedControl from "../SegmentedControl";
import ToggleSwitch from "../ToggleSwitch";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string; export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
@@ -48,6 +47,9 @@ export type MailServerFolderLookupResult = {
details?: Record<string, unknown> | null; details?: Record<string, unknown> | null;
}; };
export type MailServerSettingsSection = "smtp" | "imap";
export type MailServerSettingsMode = "all" | "server" | "credentials";
export type MailServerSettingsPanelProps = { export type MailServerSettingsPanelProps = {
smtp: MailServerSmtpSettings; smtp: MailServerSmtpSettings;
imap: MailServerImapSettings; imap: MailServerImapSettings;
@@ -69,40 +71,22 @@ export type MailServerSettingsPanelProps = {
imapActionDisabled?: boolean; imapActionDisabled?: boolean;
smtpTestLabel?: string; smtpTestLabel?: string;
imapTestLabel?: string; imapTestLabel?: string;
folderLookupLabel?: string;
busyAction?: "smtp" | "imap" | "folders" | string | null; busyAction?: "smtp" | "imap" | "folders" | string | null;
onTestSmtp?: () => void; onTestSmtp?: () => void;
onTestImap?: () => void; onTestImap?: () => void;
onLookupFolders?: () => void;
smtpTestResult?: MailServerConnectionTestResult | null; smtpTestResult?: MailServerConnectionTestResult | null;
imapTestResult?: MailServerConnectionTestResult | null; imapTestResult?: MailServerConnectionTestResult | null;
folderLookupResult?: MailServerFolderLookupResult | null;
onUseDetectedFolder?: () => void;
useDetectedFolderDisabled?: boolean;
mockToggle?: {
label?: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
};
append?: {
enabled: boolean;
folder: string;
disabled?: boolean;
folderDisabled?: boolean;
onEnabledChange: (enabled: boolean) => void;
onFolderChange: (folder: string) => void;
};
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
floatingResults?: boolean; floatingResults?: boolean;
initialSection?: MailServerSettingsSection; initialSection?: MailServerSettingsSection;
visibleSections?: readonly MailServerSettingsSection[];
mode?: MailServerSettingsMode;
}; };
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const; export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number]; export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
const securityOptions = mailServerSecurityOptions; const securityOptions = mailServerSecurityOptions;
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number { export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
if (security === "tls") return 465; if (security === "tls") return 465;
@@ -198,6 +182,19 @@ export function hasMailImapSettings(values: Array<string | number | null | undef
return values.some((value) => String(value ?? "").trim() !== ""); return values.some((value) => String(value ?? "").trim() !== "");
} }
export function resolveMailServerSettingsActiveSection(
activeSection: MailServerSettingsSection,
initialSection: MailServerSettingsSection,
previousInitialSection: MailServerSettingsSection,
visibleSectionIds: readonly MailServerSettingsSection[])
: MailServerSettingsSection {
const fallbackSection = visibleSectionIds[0] ?? "smtp";
const initialSectionVisible = visibleSectionIds.includes(initialSection);
if (previousInitialSection !== initialSection && initialSectionVisible) return initialSection;
if (!visibleSectionIds.includes(activeSection)) return initialSectionVisible ? initialSection : fallbackSection;
return activeSection;
}
export default function MailServerSettingsPanel({ export default function MailServerSettingsPanel({
smtp, smtp,
imap, imap,
@@ -219,22 +216,17 @@ export default function MailServerSettingsPanel({
imapActionDisabled = imapServerDisabled, imapActionDisabled = imapServerDisabled,
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981", smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c", imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
busyAction = null, busyAction = null,
onTestSmtp, onTestSmtp,
onTestImap, onTestImap,
onLookupFolders,
smtpTestResult = null, smtpTestResult = null,
imapTestResult = null, imapTestResult = null,
folderLookupResult = null,
onUseDetectedFolder,
useDetectedFolderDisabled = false,
mockToggle,
append,
disabled = false, disabled = false,
className = "", className = "",
floatingResults = false, floatingResults = false,
initialSection = "smtp" initialSection = "smtp",
visibleSections,
mode = "all"
}: MailServerSettingsPanelProps) { }: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled; const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled; const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
@@ -248,17 +240,31 @@ export default function MailServerSettingsPanel({
const imapSecurity = stringValue(imap.security, "tls"); const imapSecurity = stringValue(imap.security, "tls");
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity))); const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity))); const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto"); const allSections: {id: MailServerSettingsSection;label: string;}[] = [
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
const appendTargetHelp = append ?
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
const sections: {id: MailServerSettingsSection;label: string;}[] = [
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" }, { id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }, { id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }];
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }]; const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
const visibleSectionIds = sections.map((section) => section.id);
const fallbackSection = visibleSectionIds[0] ?? "smtp";
const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
const sectionKey = visibleSectionIds.join("|");
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
const previousInitialSectionRef = useRef(initialSection);
const showSectionSwitcher = sections.length > 1;
const showServerFields = mode !== "credentials";
const showCredentialFields = mode !== "server";
useEffect(() => {
const nextSection = resolveMailServerSettingsActiveSection(
activeSection,
initialSection,
previousInitialSectionRef.current,
visibleSectionIds
);
previousInitialSectionRef.current = initialSection;
if (nextSection !== activeSection) setActiveSection(nextSection);
}, [activeSection, initialSection, sectionKey]);
function patchSmtpSecurity(security: MailServerSecurity) { function patchSmtpSecurity(security: MailServerSecurity) {
@@ -283,6 +289,7 @@ export default function MailServerSettingsPanel({
return ( return (
<div className={`mail-server-settings-panel ${className}`.trim()}> <div className={`mail-server-settings-panel ${className}`.trim()}>
{showSectionSwitcher &&
<SegmentedControl <SegmentedControl
className="mail-server-segmented-control" className="mail-server-segmented-control"
size="equal" size="equal"
@@ -291,27 +298,28 @@ export default function MailServerSettingsPanel({
onChange={setActiveSection} onChange={setActiveSection}
options={sections} options={sections}
/> />
}
<div className="mail-server-settings-view"> <div className="mail-server-settings-view">
{activeSection === "smtp" && {activeSection === "smtp" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570"> <section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
<div className="form-grid compact responsive-form-grid mail-server-form-grid"> <div className="form-grid compact responsive-form-grid mail-server-form-grid">
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div> {showServerFields &&
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField> <>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField> <FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div> <FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField> </>
<FormField label="i18n:govoplan-core.password.8be3c943"> }
<PasswordField {showCredentialFields &&
value={stringValue(smtpCredentialValues.password)} <CredentialFields
values={smtpCredentialValues}
onChange={patchSmtpCredentials}
disabled={smtpCredentialFieldsDisabled} disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved} savedPassword={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder} savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
autoComplete="new-password" }
onValueChange={(password) => patchSmtpCredentials({ password })} />
</FormField>
</div> </div>
{onTestSmtp && {onTestSmtp &&
<div className="button-row compact-actions mail-server-actions"> <div className="button-row compact-actions mail-server-actions">
@@ -325,22 +333,22 @@ export default function MailServerSettingsPanel({
{activeSection === "imap" && {activeSection === "imap" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247"> <section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
<div className="form-grid compact responsive-form-grid mail-server-form-grid"> <div className="form-grid compact responsive-form-grid mail-server-form-grid">
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div> {showServerFields &&
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField> <>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField> <FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField> <FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div> <FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField> </>
<FormField label="i18n:govoplan-core.password.8be3c943"> }
<PasswordField {showCredentialFields &&
value={stringValue(imapCredentialValues.password)} <CredentialFields
values={imapCredentialValues}
onChange={patchImapCredentials}
disabled={imapCredentialFieldsDisabled} disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved} savedPassword={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder} savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
autoComplete="new-password" }
onValueChange={(password) => patchImapCredentials({ password })} />
</FormField>
</div> </div>
{onTestImap && {onTestImap &&
<div className="button-row compact-actions mail-server-actions"> <div className="button-row compact-actions mail-server-actions">
@@ -351,41 +359,6 @@ export default function MailServerSettingsPanel({
</section> </section>
} }
{activeSection === "advanced" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
{mockToggle &&
<div className="mail-server-field-span mail-server-toggle-row">
<ToggleSwitch
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
checked={mockToggle.checked}
disabled={disabled || mockToggle.disabled}
onChange={mockToggle.onChange} />
</div>
}
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
{append &&
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
</div>
}
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
<div className="field-with-action mail-server-folder-field">
<input
value={appendTargetFolder}
disabled={appendTargetDisabled}
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
placeholder="i18n:govoplan-core.auto.0d612c12" />
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
</div>
</FormField>
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
</div>
</section>
}
</div> </div>
</div>); </div>);
@@ -407,20 +380,18 @@ export function MailServerActionResult({ result, floating = false }: {result: Ma
export function MailServerFolderLookupResultView({ export function MailServerFolderLookupResultView({
result, result,
disabled = false, disabled = false,
onUseDetected onUseDetected,
compact = true,
floatingFailures = false
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
if (!result) return null; if (!result) return null;
if (!result.ok) { if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>; return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
} }
const folders = result.folders ?? []; const folders = result.folders ?? [];
return ( return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}> <DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p> <p>{result.message}</p>
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p> <p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>} {result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
+101
View File
@@ -0,0 +1,101 @@
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
type FileSystemDirectoryHandleLike = {
kind: "directory";
values?: () => AsyncIterable<FileSystemHandleLike>;
};
type WebKitFileEntryLike = {
isFile: true;
isDirectory: false;
file: (
success: (file: File) => void,
error?: (error: DOMException) => void
) => void;
};
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
type DropItemWithFileHandles = DataTransferItem & {
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
webkitGetAsEntry?: () => unknown;
};
/**
* Resolve a browser file drop exactly once, preferring the standard file list
* before progressively trying item and directory-drag compatibility APIs.
*/
export async function resolveDroppedFiles(
dataTransfer: Pick<DataTransfer, "files" | "items">
): Promise<File[]> {
const directFiles = Array.from(dataTransfer.files);
if (directFiles.length > 0) return directFiles;
const items = Array.from(dataTransfer.items).filter(
(item) => item.kind === "file"
) as DropItemWithFileHandles[];
const itemFiles = items
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (itemFiles.length > 0) return itemFiles;
const handleFiles = await filesFromDataTransferHandles(items);
if (handleFiles.length > 0) return handleFiles;
return filesFromWebKitEntries(items);
}
async function filesFromDataTransferHandles(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const files: File[] = [];
for (const item of items) {
let handle: FileSystemHandleLike | null | undefined;
try {
handle = await item.getAsFileSystemHandle?.();
} catch {
handle = null;
}
if (!handle) continue;
files.push(...await filesFromFileSystemHandle(handle));
}
return files;
}
async function filesFromFileSystemHandle(
handle: FileSystemHandleLike
): Promise<File[]> {
if (handle.kind === "file") return [await handle.getFile()];
const values = handle.values?.();
if (!values) return [];
const files: File[] = [];
for await (const child of values) {
files.push(...await filesFromFileSystemHandle(child));
}
return files;
}
async function filesFromWebKitEntries(
items: DropItemWithFileHandles[]
): Promise<File[]> {
const entries = items.flatMap((item) => {
const entry = item.webkitGetAsEntry?.() as unknown;
return isWebKitEntryLike(entry) ? [entry] : [];
});
const files = await Promise.all(entries.map(fileFromWebKitEntry));
return files.filter((file): file is File => Boolean(file));
}
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
if (!value || typeof value !== "object") return false;
const entry = value as { isFile?: unknown; isDirectory?: unknown };
return (
typeof entry.isFile === "boolean" &&
typeof entry.isDirectory === "boolean"
);
}
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
if (!entry.isFile) return Promise.resolve(null);
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
+6 -1
View File
@@ -150,7 +150,7 @@ type ColumnResizeState = {
behavior: DataGridResizeBehavior; behavior: DataGridResizeBehavior;
}; };
const STORAGE_PREFIX = "multimailer.datagrid."; const STORAGE_PREFIX = "govoplan.datagrid.";
const FILTER_POPOVER_WIDTH = 320; const FILTER_POPOVER_WIDTH = 320;
const FILTER_POPOVER_MARGIN = 12; const FILTER_POPOVER_MARGIN = 12;
const MIN_HEADER_LABEL_WIDTH = 72; const MIN_HEADER_LABEL_WIDTH = 72;
@@ -860,6 +860,7 @@ export function DataGridRowActions({
return ( return (
<div className="data-grid-row-actions"> <div className="data-grid-row-actions">
<Button <Button
type="button"
variant="primary" variant="primary"
className="data-grid-row-action is-add" className="data-grid-row-action is-add"
aria-label={translatedAddLabel} aria-label={translatedAddLabel}
@@ -870,6 +871,7 @@ export function DataGridRowActions({
<Plus size={16} aria-hidden="true" /> <Plus size={16} aria-hidden="true" />
</Button> </Button>
<Button <Button
type="button"
variant="secondary" variant="secondary"
className="data-grid-row-action is-reorder" className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel} aria-label={translatedMoveUpLabel}
@@ -880,6 +882,7 @@ export function DataGridRowActions({
<ArrowUp size={16} aria-hidden="true" /> <ArrowUp size={16} aria-hidden="true" />
</Button> </Button>
<Button <Button
type="button"
variant="secondary" variant="secondary"
className="data-grid-row-action is-reorder" className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel} aria-label={translatedMoveDownLabel}
@@ -890,6 +893,7 @@ export function DataGridRowActions({
<ArrowDown size={16} aria-hidden="true" /> <ArrowDown size={16} aria-hidden="true" />
</Button> </Button>
<Button <Button
type="button"
variant="danger" variant="danger"
className="data-grid-row-action is-remove" className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel} aria-label={translatedRemoveLabel}
@@ -917,6 +921,7 @@ export function DataGridEmptyAction({
return ( return (
<div className="data-grid-row-actions data-grid-empty-row-actions"> <div className="data-grid-row-actions data-grid-empty-row-actions">
<Button <Button
type="button"
variant="primary" variant="primary"
className="data-grid-row-action is-add" className="data-grid-row-action is-add"
aria-label={translatedLabel} aria-label={translatedLabel}
+121 -39
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, MailProfilesUiCapability, UserUiPreferences, UserUiTheme } from "../../types"; import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
import Card from "../../components/Card"; import Card from "../../components/Card";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField"; import PasswordField from "../../components/PasswordField";
@@ -8,15 +8,16 @@ import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle"; import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client"; import { apiFetch } from "../../api/client";
import { updateProfile } from "../../api/auth"; import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import SegmentedControl from "../../components/SegmentedControl";
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard"; import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
import { usePlatformUiCapability } from "../../platform/ModuleContext"; import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
import { hasAnyScope, hasScope } from "../../utils/permissions"; import { hasAnyScope, hasScope } from "../../utils/permissions";
import { usePlatformLanguage } from "../../i18n/LanguageContext"; import { usePlatformLanguage } from "../../i18n/LanguageContext";
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | "notifications"; type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
const DEFAULT_UI_PREFERENCES: UserUiPreferences = { const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false, compact_tables: false,
@@ -32,8 +33,8 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" } { value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
]; ];
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] { function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
return [ const groups: ModuleSubnavGroup<SettingsSection>[] = [
{ {
title: "i18n:govoplan-core.account.f967543b", title: "i18n:govoplan-core.account.f967543b",
items: [ items: [
@@ -47,11 +48,29 @@ function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boole
items: [ items: [
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" }, { id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" }, { id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }, { id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }]
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
}]; }];
for (const section of contributedSections) {
const groupId = section.group || "ui";
const title = section.groupTitle || (groupId === "account" ? "i18n:govoplan-core.account.f967543b" : groupId === "ui" ? "i18n:govoplan-core.ui_settings.9e9cc5ea" : groupId);
let group = groups.find((item) => item.title === title);
if (!group) {
group = { title, items: [] };
groups.push(group);
}
group.items.push({ id: section.id, label: section.label });
}
for (const group of groups) {
group.items.sort((left, right) => {
const leftId = "id" in left ? left.id : "";
const rightId = "id" in right ? right.id : "";
return (sectionOrder(leftId, contributedSections) - sectionOrder(rightId, contributedSections)) || left.label.localeCompare(right.label);
});
}
return groups;
} }
export default function SettingsPage({ export default function SettingsPage({
@@ -64,19 +83,26 @@ export default function SettingsPage({
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;}) { }: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;}) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { requestNavigation } = useUnsavedChanges(); const { requestNavigation } = useUnsavedChanges();
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors"); const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage(); const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null; const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? 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", "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 canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors), [canUseFileConnectors, canUseMailProfiles]); const contributedSections = useMemo(
const requestedSection = searchParams.get("section") as SettingsSection | null; () => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface"; [auth, settingsSectionCapabilities]
);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
const requestedSection = searchParams.get("section");
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
const activeContributedSection = contributedSections.find((section) => section.id === active) ?? null;
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences); const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(""); const [testResult, setTestResult] = useState("");
@@ -118,12 +144,30 @@ export default function SettingsPage({
}); });
useEffect(() => { useEffect(() => {
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) { if (requestedSection && !settingsSectionAvailable(availableSectionIds, requestedSection)) {
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
next.set("section", "interface"); next.set("section", "interface");
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
} }
}, [requestedSection, searchParams, setSearchParams, settingsSubnav]); }, [availableSectionIds, requestedSection, searchParams, setSearchParams]);
useEffect(() => {
if (auth.profile_loaded) return;
let cancelled = false;
fetchAuthProfile(settings)
.then((next) => {if (!cancelled) onAuthChange(next);})
.catch(() => undefined);
return () => {cancelled = true;};
}, [auth.profile_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (auth.roles_loaded) return;
let cancelled = false;
fetchAuthRoles(settings)
.then((next) => {if (!cancelled) onAuthChange(next);})
.catch(() => undefined);
return () => {cancelled = true;};
}, [auth.roles_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => { useEffect(() => {
setProfileName(auth.user.display_name || ""); setProfileName(auth.user.display_name || "");
@@ -329,12 +373,16 @@ export default function SettingsPage({
</select> </select>
</FormField> </FormField>
<FormField label="i18n:govoplan-core.theme.a797e309"> <FormField label="i18n:govoplan-core.theme.a797e309">
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}> <SegmentedControl
{UI_THEME_OPTIONS.map((item) => options={UI_THEME_OPTIONS.map((item) => ({ id: item.value, label: item.label }))}
<option key={item.value} value={item.value}>{item.label}</option> value={theme}
)} onChange={setTheme}
</select> role="group"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-core.theme.a797e309" />
</FormField> </FormField>
<ThemePreview theme={theme} />
<dl className="detail-list compact-detail-list"> <dl className="detail-list compact-detail-list">
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div> <div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div> <div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
@@ -413,24 +461,15 @@ export default function SettingsPage({
</div> </div>
} }
{active === "notifications" && {activeContributedSection &&
<div className="dashboard-grid settings-dashboard-grid"> activeContributedSection.render({
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12"> settings,
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p> auth,
<div className="placeholder-stack"> onAuthChange,
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span> activeSection: active,
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span> availableSections: availableSectionIds,
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span> selectSection
</div> })
</Card>
<Card title="i18n:govoplan-core.quiet_ui_mode.1b0bd558">
<div className="placeholder-stack">
<span>i18n:govoplan-core.mute_non_critical_banners.27b23a4a</span>
<span>i18n:govoplan-core.batch_repetitive_notices.cc893559</span>
<span>i18n:govoplan-core.keep_validation_and_send_warnings_visible.9d6e0cf4</span>
</div>
</Card>
</div>
} }
</div> </div>
</section> </section>
@@ -439,8 +478,30 @@ export default function SettingsPage({
} }
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection { function settingsSectionAvailable(sections: ReadonlySet<string>, section: string | null | undefined): section is SettingsSection {
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section))); return Boolean(section && sections.has(section));
}
function canUseSettingsContribution(auth: AuthInfo, section: SettingsSectionContribution): boolean {
if (section.allOf?.some((scope) => !hasScope(auth, scope))) {
return false;
}
if (section.anyOf && !hasAnyScope(auth, section.anyOf)) {
return false;
}
return true;
}
function sectionOrder(sectionId: string, contributedSections: SettingsSectionContribution[]): number {
const builtInOrder: Record<string, number> = {
profile: 10,
"mail-profiles": 20,
"file-connectors": 30,
interface: 10,
workspace: 20,
"local-connection": 30
};
return builtInOrder[sectionId] ?? contributedSections.find((section) => section.id === sectionId)?.order ?? 100;
} }
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences { function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
@@ -457,3 +518,24 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
function themeLabel(value: UserUiTheme): string { function themeLabel(value: UserUiTheme): string {
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label; return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
} }
function ThemePreview({ theme }: {theme: UserUiTheme;}) {
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme];
return (
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309">
{variants.map((variant) =>
<div key={variant} className="theme-preview" data-preview-theme={variant}>
<div className="theme-preview-header">
<span>{themeLabel(variant)}</span>
<i />
</div>
<div className="theme-preview-body">
<strong>GovOPlaN</strong>
<span />
<span />
</div>
</div>
)}
</div>);
}
+27 -1
View File
@@ -3,6 +3,26 @@ export * from "./types";
export * from "./api/client"; export * from "./api/client";
export * from "./api/auth"; export * from "./api/auth";
export * from "./api/platform"; export * from "./api/platform";
export * from "./api/adminCommon";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts";
export type {
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestPayload,
MailPolicySourceStep,
MailProfilePatternRules,
MailProfilePolicyLimitKey,
MailProfilePolicyLimitPermissions,
MailProfilePolicyResponse,
MailServerProfileCredentialsPayload,
MailServerProfileListResponse,
MailServerProfilePayload,
MailSmtpTestPayload,
MailTransportCredentialsPayload,
MockMailboxMessage,
MockMailboxMessageResponse
} from "./api/mailContracts";
export * from "./api/privacyRetention"; export * from "./api/privacyRetention";
export * from "./platform/modules"; export * from "./platform/modules";
export * from "./platform/ModuleContext"; export * from "./platform/ModuleContext";
@@ -32,8 +52,12 @@ export { default as ConfirmDialog } from "./components/ConfirmDialog";
export { default as ConnectionTree } from "./components/ConnectionTree"; export { default as ConnectionTree } from "./components/ConnectionTree";
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree"; export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
export { default as ColorPickerField } from "./components/ColorPickerField"; export { default as ColorPickerField } from "./components/ColorPickerField";
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField"; export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
export { default as Dialog } from "./components/Dialog"; export { default as Dialog } from "./components/Dialog";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
export { default as DismissibleAlert } from "./components/DismissibleAlert"; export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock"; export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock"; export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
@@ -44,6 +68,8 @@ export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog";
export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog"; export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog";
export { default as GuidedReviewList } from "./components/GuidedReviewList"; export { default as GuidedReviewList } from "./components/GuidedReviewList";
export type { GuidedReviewItem } 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 LoadingFrame } from "./components/LoadingFrame"; export { default as LoadingFrame } from "./components/LoadingFrame";
export { default as LoadingIndicator } from "./components/LoadingIndicator"; export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree"; export { default as ExplorerTree } from "./components/ExplorerTree";
@@ -71,7 +97,7 @@ export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard"
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard"; export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
export { default as EmailAddressInput } from "./components/email/EmailAddressInput"; export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel"; export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel"; 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 FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp"; export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid"; export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";
+7 -5
View File
@@ -1,5 +1,5 @@
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import type { ApiSettings, AuthInfo, PlatformNavItem } from "../types"; import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
import IconRail from "./IconRail"; import IconRail from "./IconRail";
import Titlebar from "./Titlebar"; import Titlebar from "./Titlebar";
import BreadcrumbBar from "./BreadcrumbBar"; import BreadcrumbBar from "./BreadcrumbBar";
@@ -9,10 +9,11 @@ type Props = {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo | null; auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void; onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean; publicMode?: boolean;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null }; maintenanceMode?: { enabled: boolean; message?: string | null };
backendReachable?: boolean;
}; };
export default function AppShell({ export default function AppShell({
@@ -23,7 +24,8 @@ export default function AppShell({
onAuthChange, onAuthChange,
publicMode = false, publicMode = false,
navItems = [], navItems = [],
maintenanceMode maintenanceMode,
backendReachable = true
}: Props) { }: Props) {
const location = useLocation(); const location = useLocation();
@@ -32,7 +34,7 @@ export default function AppShell({
<div className="app-shell public-shell"> <div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} /> <IconRail compact auth={auth} navItems={navItems} />
<div className="app-main public-main"> <div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} /> <Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<main className="public-content">{children}</main> <main className="public-content">{children}</main>
</div> </div>
</div> </div>
@@ -43,7 +45,7 @@ export default function AppShell({
<div className="app-shell"> <div className="app-shell">
<IconRail auth={auth} navItems={navItems} /> <IconRail auth={auth} navItems={navItems} />
<div className="app-main"> <div className="app-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} /> <Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<BreadcrumbBar pathname={location.pathname} /> <BreadcrumbBar pathname={location.pathname} />
<main className="app-content">{children}</main> <main className="app-content">{children}</main>
</div> </div>
+13 -3
View File
@@ -10,6 +10,8 @@ import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext"; import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
export default function HelpMenu() { export default function HelpMenu() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
@@ -50,8 +52,11 @@ export default function HelpMenu() {
} }
function openDocs(type: "user" | "admin") { function openDocs(type: "user" | "admin") {
if (!docsAvailable) return;
setOpen(false); setOpen(false);
if (!docsAvailable) {
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
return;
}
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
params.set("context", helpContext.id); params.set("context", helpContext.id);
navigate(`/docs?${params.toString()}`); navigate(`/docs?${params.toString()}`);
@@ -79,10 +84,10 @@ export default function HelpMenu() {
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small> <HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
</button> </button>
<hr /> <hr />
<button className="dropdown-item" onClick={() => openDocs("user")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}> <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")} <BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button> </button>
<button className="dropdown-item" onClick={() => openDocs("admin")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}> <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")} <BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button> </button>
<hr /> <hr />
@@ -96,6 +101,11 @@ export default function HelpMenu() {
} }
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
const params = new URLSearchParams({ type, context: context.id });
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
}
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) { function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
return ( return (
+49 -11
View File
@@ -1,4 +1,4 @@
import { Settings } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router-dom"; import { NavLink, useLocation } from "react-router-dom";
import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { useEffect, useMemo, useState, type MouseEvent } from "react";
import type { AuthInfo, PlatformNavItem } from "../types"; import type { AuthInfo, PlatformNavItem } from "../types";
@@ -7,6 +7,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav"; const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] { function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems]. return [...navItems].
@@ -22,14 +23,11 @@ export default function IconRail({
compact = false, compact = false,
auth = null, auth = null,
navItems = [] navItems = []
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) { }: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
const location = useLocation(); const location = useLocation();
const items = visibleNavItems(auth, navItems); const items = visibleNavItems(auth, navItems);
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets()); const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]); const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
@@ -46,15 +44,27 @@ export default function IconRail({
}); });
}, [location.hash, location.pathname, location.search, topLevelItems]); }, [location.hash, location.pathname, location.search, topLevelItems]);
function toggleExpanded() {
setExpanded((current) => {
const next = !current;
saveRailExpanded(next);
return next;
});
}
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) { function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return; if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault(); event.preventDefault();
navigate(target); navigate(target);
} }
const railExpanded = !compact && expanded;
return ( return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}> <aside className={`icon-rail ${compact ? "compact" : ""} ${railExpanded ? "expanded" : ""}`}>
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div> <div className="icon-rail-header">
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
</div>
{!compact && {!compact &&
<> <>
@@ -62,9 +72,11 @@ export default function IconRail({
{items.map(({ to, label, icon: Icon }) => { {items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to; const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to); const active = modulePathActive(location.pathname, to);
const renderedLabel = translateText(label);
return ( return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}> <NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
{Icon ? <Icon size={20} /> : label.slice(0, 1)} {Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>); </NavLink>);
})} })}
@@ -72,7 +84,17 @@ export default function IconRail({
<div className="icon-rail-bottom"> <div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}> <NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
<Settings size={20} /> <Settings size={20} />
<span className="icon-nav-label">{translateText("i18n:govoplan-core.settings.c7f73bb5")}</span>
</NavLink> </NavLink>
<button
type="button"
className="icon-nav-item icon-rail-toggle"
aria-label={railExpanded ? "Collapse navigation" : "Expand navigation"}
title={railExpanded ? "Collapse navigation" : "Expand navigation"}
onClick={toggleExpanded}>
{railExpanded ? <PanelLeftClose size={20} aria-hidden="true" /> : <PanelLeftOpen size={20} aria-hidden="true" />}
<span className="icon-nav-label">{railExpanded ? "Collapse" : "Expand"}</span>
</button>
</div> </div>
</> </>
} }
@@ -107,7 +129,23 @@ function saveRememberedTargets(targets: Record<string, string>): void {
try { try {
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets)); window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
} catch { } catch {
// Remembered navigation is a convenience only. // Remembered navigation is a convenience only.
}} }}
function loadRailExpanded(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(RAIL_EXPANDED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
function saveRailExpanded(expanded: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(RAIL_EXPANDED_STORAGE_KEY, expanded ? "true" : "false");
} catch {
// Rail width is a presentation preference only.
}
}
+76 -6
View File
@@ -1,24 +1,32 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
import { Check, LogOut, Settings, UserCircle } from "lucide-react"; import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types"; import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
import HelpMenu from "./HelpMenu"; import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu"; import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal"; import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert"; import DismissibleAlert from "../components/DismissibleAlert";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { apiFetch, isApiError } from "../api/client";
import { logout, switchTenant } from "../api/auth"; import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions"; import { hasAnyScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import { usePlatformModules } from "../platform/ModuleContext";
type NotificationSummary = {
unread: number;
show_unread_badge?: boolean;
};
type Props = { type Props = {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo | null; auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void; onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
maintenanceMode?: {enabled: boolean;message?: string | null;}; maintenanceMode?: {enabled: boolean;message?: string | null;};
backendReachable?: boolean;
}; };
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode }: Props) { export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode, backendReachable = true }: Props) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const { requestNavigation } = useUnsavedChanges(); const { requestNavigation } = useUnsavedChanges();
const [accountOpen, setAccountOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false);
@@ -26,9 +34,11 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null); const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
const [tenantError, setTenantError] = useState(""); const [tenantError, setTenantError] = useState("");
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const accountRef = useRef<HTMLDivElement>(null); const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null); const tenantRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const modules = usePlatformModules();
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null; const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []); const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
@@ -41,6 +51,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
"system:tenants:suspend"] "system:tenants:suspend"]
); );
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants)); const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
useEffect(() => { useEffect(() => {
function onPointerDown(event: MouseEvent) { function onPointerDown(event: MouseEvent) {
@@ -56,6 +68,41 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
return () => window.removeEventListener("mousedown", onPointerDown); return () => window.removeEventListener("mousedown", onPointerDown);
}, []); }, []);
useEffect(() => {
if (!auth || !notificationsAvailable) {
setUnreadNotificationCount(0);
return;
}
let cancelled = false;
async function refreshNotificationSummary() {
try {
const summary = await apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
if (!cancelled) {
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
}
} catch (error) {
if (!cancelled) {
setUnreadNotificationCount(0);
}
if (!isApiError(error, 401, 403, 404)) {
console.error("Failed to load notification summary", error);
}
}
}
void refreshNotificationSummary();
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
window.addEventListener("focus", refreshNotificationSummary);
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.removeEventListener("focus", refreshNotificationSummary);
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
};
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function handleLogin(response: LoginResponse) { function handleLogin(response: LoginResponse) {
onAuthChange(response, ""); onAuthChange(response, "");
} }
@@ -109,9 +156,21 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
navigate("/admin?section=system-settings"); navigate("/admin?section=system-settings");
} }
function openNotificationCenter() {
navigate("/notifications");
}
return ( return (
<header className="titlebar"> <header className="titlebar">
{maintenanceMode?.enabled && {!backendReachable ?
<div
className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
title="System not reachable / offline!">
System not reachable / offline!
</div> :
maintenanceMode?.enabled &&
<button <button
type="button" type="button"
className="maintenance-topbar-link" className="maintenance-topbar-link"
@@ -159,8 +218,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="titlebar-spacer" /> <div className="titlebar-spacer" />
<HelpMenu />
<LanguageMenu /> <LanguageMenu />
<HelpMenu />
{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")}>
<Bell size={18} />
{unreadNotificationCount > 0 &&
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
{notificationBadgeLabel}
</span>
}
</button>
}
<div className="context-menu-wrap" ref={accountRef}> <div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}> <button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
+9 -3
View File
@@ -1,4 +1,4 @@
import { Activity, Building2, CalendarDays, ClipboardPenLine, FileText, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react"; 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 installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
import { import {
@@ -39,9 +39,12 @@ type RemoteAssetManifest = {
const iconByName: Record<string, LucideIcon> = { const iconByName: Record<string, LucideIcon> = {
activity: Activity, activity: Activity,
admin: Shield, admin: Shield,
bell: Bell,
"book-user": BookUser,
building: Building2, building: Building2,
"building-2": Building2, "building-2": Building2,
calendar: CalendarDays, calendar: CalendarDays,
"calendar-clock": CalendarClock,
campaign: Mails, campaign: Mails,
"clipboard-pen-line": ClipboardPenLine, "clipboard-pen-line": ClipboardPenLine,
dashboard: LayoutDashboard, dashboard: LayoutDashboard,
@@ -51,9 +54,12 @@ const iconByName: Record<string, LucideIcon> = {
form: Form, form: Form,
"layout-template": LayoutTemplate, "layout-template": LayoutTemplate,
mail: Mail, mail: Mail,
notifications: Bell,
organizations: Building2,
operator: RadioTower, operator: RadioTower,
"radio-tower": RadioTower, "radio-tower": RadioTower,
reports: FileText, reports: ClipboardPenLine,
templates: LayoutTemplate,
users: Users users: Users
}; };
@@ -138,7 +144,7 @@ alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)
let promise = remoteModuleCache.get(cacheKey); let promise = remoteModuleCache.get(cacheKey);
if (!promise) { if (!promise) {
promise = loadRemoteWebModule(module).catch((error) => { promise = loadRemoteWebModule(module).catch((error) => {
console.warn(`GovOPlaN remote module ${module.id} was not loaded:`, error); console.warn("GovOPlaN remote module was not loaded:", module.id, error);
return null; return null;
}); });
remoteModuleCache.set(cacheKey, promise); remoteModuleCache.set(cacheKey, promise);
+6 -6
View File
@@ -17,8 +17,8 @@
overflow: auto; overflow: auto;
height: 100%; height: 100%;
background: background:
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem), radial-gradient(circle at 18% 18%, var(--accent-auth-glow), transparent 24rem),
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem), radial-gradient(circle at 78% 14%, var(--blue-auth-glow), transparent 22rem),
var(--bg); var(--bg);
} }
@@ -32,7 +32,7 @@
.public-card { .public-card {
width: min(720px, 100%); width: min(720px, 100%);
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: 42px 48px; padding: 42px 48px;
@@ -134,9 +134,9 @@
.titlebar-link:hover, .titlebar-link:hover,
.account-pill:hover, .account-pill:hover,
.context-menu-wrap:focus-within .account-pill { .context-menu-wrap:focus-within .account-pill {
background: rgba(0,0,0,.055); background: var(--titlebar-hover-bg);
color: var(--text-strong); color: var(--text-strong);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.04); box-shadow: inset 0 0 0 1px rgba(var(--shadow-color-rgb), .04);
} }
.dropdown-menu { .dropdown-menu {
@@ -151,7 +151,7 @@
.dropdown-item:hover, .dropdown-item:hover,
.dropdown-item.active { .dropdown-item.active {
background: rgba(239, 107, 58, .10); background: var(--accent-hover-bg);
color: var(--text-strong); color: var(--text-strong);
} }
+6 -6
View File
@@ -1,6 +1,6 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: #e7e4df; color: #666; text-transform: uppercase; } .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: #d6eee9; color: #34796d; } .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: #ffedc6; color: #a06b00; } .status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-failed, .status-failed-permanent { background: #f8d1cc; color: #b13e35; } .status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-queued, .status-sending { background: #d8e8f4; color: #386a90; } .status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
.status-inactive, .status-locked { background: #e7e4df; color: #666; } .status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }
+292 -141
View File
@@ -19,9 +19,9 @@
display: inline-block; display: inline-block;
width: 18px; width: 18px;
height: 13px; height: 13px;
border: 2px solid #8c8881; border: 2px solid var(--loading-line);
border-radius: 3px; border-radius: 3px;
background: rgba(255,255,255,.7); background: var(--loading-envelope-bg);
animation: loading-envelope-float .74s ease-in-out infinite alternate; animation: loading-envelope-float .74s ease-in-out infinite alternate;
} }
.loading-envelope::before, .loading-envelope::before,
@@ -31,7 +31,7 @@
top: 1px; top: 1px;
width: 10px; width: 10px;
height: 10px; height: 10px;
border-top: 2px solid #8c8881; border-top: 2px solid var(--loading-line);
} }
.loading-envelope::before { .loading-envelope::before {
left: 1px; left: 1px;
@@ -64,19 +64,19 @@
align-items: center; align-items: center;
gap: 7px; gap: 7px;
max-width: 100%; max-width: 100%;
border: 1px solid #c9c5bd; border: 1px solid var(--control-border);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(#ffffff, #f2f1ef); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control-strong);
padding: 5px 8px 5px 11px; padding: 5px 8px 5px 11px;
color: var(--text-strong); color: var(--text-strong);
font-size: 13px; font-size: 13px;
line-height: 1.2; line-height: 1.2;
} }
.email-chip.invalid { .email-chip.invalid {
border-color: #c96b63; border-color: var(--danger-border);
background: #f6e3df; background: var(--danger-soft);
color: #873c35; color: var(--danger-text-deep);
} }
.email-chip-main { .email-chip-main {
overflow: hidden; overflow: hidden;
@@ -99,14 +99,14 @@
height: 18px; height: 18px;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
background: rgba(0,0,0,.08); background: var(--hover-tint);
color: #57534d; color: var(--control-text-muted);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
padding: 0; padding: 0;
} }
.email-chip-remove:hover { background: rgba(0,0,0,.15); } .email-chip-remove:hover { background: var(--hover-tint-strong); }
.email-chip-empty { .email-chip-empty {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
@@ -148,7 +148,7 @@
align-items: center; align-items: center;
gap: 8px 10px; gap: 8px 10px;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--muted); color: var(--muted);
@@ -224,14 +224,40 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.password-field-toggle:hover { .password-field-toggle:hover {
background: rgba(0, 0, 0, .06); background: var(--hover-tint-soft);
color: var(--text-strong); color: var(--text-strong);
} }
.password-field-toggle:focus-visible { .password-field-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff); outline: var(--focus-outline);
outline-offset: 1px; outline-offset: 1px;
} }
.credential-panel {
display: grid;
gap: 12px;
min-width: 0;
}
.credential-panel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
align-items: start;
}
.credential-panel-heading {
grid-column: 1 / -1;
margin: 2px 0 -4px;
color: var(--text-strong);
font-size: 13px;
font-weight: 800;
}
.credential-panel-extra {
display: grid;
gap: 10px;
}
.color-picker-field, .color-picker-field,
.date-field, .date-field,
.time-field { .time-field {
@@ -261,9 +287,9 @@
z-index: 1; z-index: 1;
width: 18px; width: 18px;
height: 18px; height: 18px;
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 4px; border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45); box-shadow: var(--shadow-inset-highlight);
} }
.color-picker-trigger, .color-picker-trigger,
@@ -287,7 +313,7 @@
.color-picker-trigger:focus-visible, .color-picker-trigger:focus-visible,
.date-field-trigger:hover:not(:disabled), .date-field-trigger:hover:not(:disabled),
.date-field-trigger:focus-visible { .date-field-trigger:focus-visible {
background: rgba(0,0,0,.08); background: var(--hover-tint);
color: var(--text-strong); color: var(--text-strong);
} }
@@ -307,10 +333,10 @@
width: max-content; width: max-content;
min-width: 220px; min-width: 220px;
padding: 10px; padding: 10px;
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
box-shadow: 0 16px 34px rgba(0,0,0,.18); box-shadow: var(--shadow-menu);
} }
.color-picker-grid { .color-picker-grid {
@@ -322,10 +348,10 @@
.color-picker-grid button { .color-picker-grid button {
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 1px solid rgba(0,0,0,.2); border: 1px solid var(--black-border-soft);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45); box-shadow: var(--shadow-inset-highlight);
} }
.color-picker-grid button.is-selected { .color-picker-grid button.is-selected {
@@ -355,7 +381,7 @@
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 4px; border-radius: 4px;
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
@@ -401,9 +427,9 @@
} }
.date-field-grid button.is-selected { .date-field-grid button.is-selected {
border-color: #5aa99b; border-color: var(--success-border);
background: var(--green); background: var(--green);
color: #fff; color: var(--on-accent);
font-weight: 800; font-weight: 800;
} }
@@ -426,7 +452,7 @@
} }
.admin-overview-link { .admin-overview-link {
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel-soft); background: var(--panel-soft);
padding: 14px; padding: 14px;
@@ -436,7 +462,7 @@
} }
.admin-overview-link:hover { .admin-overview-link:hover {
background: #fff; background: var(--surface);
border-color: var(--line-dark); border-color: var(--line-dark);
} }
@@ -467,7 +493,7 @@
grid-template-columns: minmax(0, 1fr) 170px; grid-template-columns: minmax(0, 1fr) 170px;
align-items: center; align-items: center;
gap: 18px; gap: 18px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -551,7 +577,7 @@
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -616,7 +642,7 @@
.module-install-preflight { .module-install-preflight {
display: grid; display: grid;
gap: 10px; gap: 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel-soft); background: var(--panel-soft);
padding: 12px 14px; padding: 12px 14px;
@@ -657,7 +683,7 @@
min-height: 30px; min-height: 30px;
padding: 6px 8px; padding: 6px 8px;
border-radius: 4px; border-radius: 4px;
background: rgba(255,255,255,.58); background: var(--panel-glass);
} }
.module-install-preflight-issue strong { .module-install-preflight-issue strong {
@@ -686,9 +712,9 @@
gap: 4px; gap: 4px;
align-content: start; align-content: start;
min-height: 86px; min-height: 86px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: rgba(255,255,255,.6); background: var(--panel-glass);
padding: 9px 10px; padding: 9px 10px;
} }
@@ -743,7 +769,7 @@
grid-template-columns: 130px 150px 170px minmax(260px, 1fr); grid-template-columns: 130px 150px 170px minmax(260px, 1fr);
gap: 12px; gap: 12px;
align-items: end; align-items: end;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -818,7 +844,7 @@
grid-template-columns: minmax(0, 1fr) minmax(220px, 34%); grid-template-columns: minmax(0, 1fr) minmax(220px, 34%);
align-items: center; align-items: center;
gap: 14px; gap: 14px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 6px; border-radius: 6px;
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -976,9 +1002,9 @@
max-height: 300px; max-height: 300px;
overflow: auto; overflow: auto;
padding: 8px; padding: 8px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9); background: var(--surface-muted);
} }
.admin-selection-item { .admin-selection-item {
@@ -992,7 +1018,7 @@
} }
.admin-selection-item:hover { .admin-selection-item:hover {
background: var(--surface, #fff); background: var(--surface);
} }
.admin-selection-item.disabled { .admin-selection-item.disabled {
@@ -1036,8 +1062,8 @@
.guided-config-sidebar { .guided-config-sidebar {
min-width: 0; min-width: 0;
border-right: 1px solid var(--line); border-right: var(--border-line);
background: #f6f5f3; background: var(--panel-soft);
} }
.guided-config-sidebar .stepper { .guided-config-sidebar .stepper {
@@ -1109,7 +1135,7 @@
gap: 14px; gap: 14px;
min-width: 0; min-width: 0;
padding: 15px; padding: 15px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 7px; border-radius: 7px;
background: var(--panel); background: var(--panel);
} }
@@ -1134,7 +1160,7 @@
} }
.advanced-options-panel { .advanced-options-panel {
border: 1px solid var(--line); border: var(--border-line);
border-radius: 7px; border-radius: 7px;
background: var(--panel); background: var(--panel);
} }
@@ -1248,7 +1274,7 @@
} }
.action-blocker-technical { .action-blocker-technical {
background: rgba(255,255,255,.56); background: var(--panel-glass);
} }
.guided-review-list { .guided-review-list {
@@ -1271,7 +1297,7 @@
align-items: start; align-items: start;
min-width: 0; min-width: 0;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: var(--border-line);
border-left-width: 4px; border-left-width: 4px;
border-left-color: var(--line-dark); border-left-color: var(--line-dark);
border-radius: 7px; border-radius: 7px;
@@ -1327,7 +1353,7 @@
.guided-config-sidebar { .guided-config-sidebar {
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
} }
.guided-config-sidebar .stepper { .guided-config-sidebar .stepper {
@@ -1350,19 +1376,19 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 7px; gap: 7px;
min-height: 44px; min-height: 44px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035); box-shadow: var(--shadow-control-surface);
padding: 7px 8px; padding: 7px 8px;
} }
.email-address-editor:focus-within { .email-address-editor:focus-within {
border-color: #9bb7d3; border-color: var(--input-border-focus);
box-shadow: 0 0 0 3px rgba(82, 130, 177, .14), inset 0 1px 0 rgba(255,255,255,.8); box-shadow: var(--focus-ring), var(--shadow-inset-highlight-strong);
} }
.email-address-editor.has-error { .email-address-editor.has-error {
border-color: #c96b63; border-color: var(--danger-border);
box-shadow: 0 0 0 3px rgba(201, 107, 99, .13); box-shadow: var(--danger-focus-ring-soft);
} }
.email-address-editor .email-chip-list { .email-address-editor .email-chip-list {
display: flex; display: flex;
@@ -1402,10 +1428,10 @@
justify-content: center; justify-content: center;
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 1px solid #c7c2b8; border: 1px solid var(--control-border);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(#fff, #f0efec); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: #4f4b46; color: var(--control-text);
cursor: pointer; cursor: pointer;
font-size: 19px; font-size: 19px;
font-weight: 800; font-weight: 800;
@@ -1413,8 +1439,8 @@
padding: 0; padding: 0;
} }
.email-address-plus:hover { .email-address-plus:hover {
border-color: #aaa49a; border-color: var(--control-border-hover);
background: linear-gradient(#fff, #e9e7e3); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
} }
.email-address-popover { .email-address-popover {
position: absolute; position: absolute;
@@ -1424,9 +1450,9 @@
width: min(360px, calc(100vw - 64px)); width: min(360px, calc(100vw - 64px));
display: grid; display: grid;
gap: 10px; gap: 10px;
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
box-shadow: var(--shadow-popover); box-shadow: var(--shadow-popover);
padding: 14px; padding: 14px;
} }
@@ -1449,10 +1475,10 @@
display: grid; display: grid;
gap: 4px; gap: 4px;
max-width: 560px; max-width: 560px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--surface);
box-shadow: 0 8px 24px rgba(0,0,0,.08); box-shadow: 0 8px 24px var(--hover-tint);
padding: 6px; padding: 6px;
} }
.email-address-suggestions button { .email-address-suggestions button {
@@ -1521,10 +1547,10 @@
flex: 0 0 auto; flex: 0 0 auto;
width: 42px; width: 42px;
height: 24px; height: 24px;
border: 1px solid #aeb4bb; border: 1px solid var(--toggle-track-border);
border-radius: 999px; border-radius: 999px;
background: #cfd4da; background: var(--toggle-track-bg);
box-shadow: inset 0 1px 2px rgba(0,0,0,.12); box-shadow: var(--shadow-control-inset-strong);
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease; transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
} }
.toggle-switch-thumb { .toggle-switch-thumb {
@@ -1534,24 +1560,35 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 999px; border-radius: 999px;
background: #fff; background: var(--surface);
box-shadow: 0 1px 2px rgba(0,0,0,.22); box-shadow: var(--shadow-thumb);
transition: transform .16s ease; transition: transform .16s ease;
} }
.toggle-switch-input:checked + .toggle-switch-track { .toggle-switch-input:checked ~ .toggle-switch-track {
border-color: #0d6efd; border-color: var(--primary);
background: #0d6efd; background: var(--primary);
} }
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb { .toggle-switch-input:checked ~ .toggle-switch-track .toggle-switch-thumb {
transform: translateX(18px); transform: translateX(18px);
} }
.toggle-switch-input:focus-visible + .toggle-switch-track { .toggle-switch-input:focus-visible ~ .toggle-switch-track {
box-shadow: 0 0 0 3px rgba(13,110,253,.2), inset 0 1px 2px rgba(0,0,0,.12); box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
} }
.toggle-switch-input:disabled + .toggle-switch-track { .toggle-switch-input:disabled ~ .toggle-switch-track {
filter: grayscale(.15); filter: grayscale(.15);
opacity: .7; opacity: .7;
} }
.toggle-switch-state-label {
color: var(--muted);
font-size: 13px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
}
.toggle-switch-state-label[data-selected="true"] {
color: var(--text-strong);
font-weight: 800;
}
.toggle-switch-copy { .toggle-switch-copy {
display: grid; display: grid;
gap: 2px; gap: 2px;
@@ -1604,7 +1641,7 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
border-radius: 999px; border-radius: 999px;
color: #686560; color: var(--text-subtle);
background: var(--line-dark); background: var(--line-dark);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
@@ -1627,7 +1664,7 @@
.policy-table { .policy-table {
display: grid; display: grid;
gap: 0; gap: 0;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden; overflow: hidden;
} }
@@ -1646,7 +1683,7 @@
gap: 14px; gap: 14px;
padding: 12px 14px; padding: 12px 14px;
background: var(--surface); background: var(--surface);
border-top: 1px solid var(--line); border-top: var(--border-line);
} }
.policy-row:first-child { .policy-row:first-child {
border-top: 0; border-top: 0;
@@ -1712,7 +1749,7 @@
width: max-content; width: max-content;
max-width: min(320px, calc(100vw - 48px)); max-width: min(320px, calc(100vw - 48px));
transform: translate(-50%, 3px); transform: translate(-50%, 3px);
border: 1px solid var(--line-dark); border: var(--border-line-dark);
border-radius: 7px; border-radius: 7px;
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow-popover); box-shadow: var(--shadow-popover);
@@ -1734,8 +1771,8 @@
top: 100%; top: 100%;
width: 9px; width: 9px;
height: 9px; height: 9px;
border-right: 1px solid var(--line-dark); border-right: var(--border-line-dark);
border-bottom: 1px solid var(--line-dark); border-bottom: var(--border-line-dark);
background: var(--surface); background: var(--surface);
transform: translate(-50%, -5px) rotate(45deg); transform: translate(-50%, -5px) rotate(45deg);
} }
@@ -1767,8 +1804,8 @@
} }
.field-input-missing { .field-input-missing {
border-color: #b42318 !important; border-color: var(--danger-border-deep) !important;
box-shadow: 0 0 0 3px rgba(180, 35, 24, .14) !important; box-shadow: var(--danger-focus-ring) !important;
} }
.disabled-action-tooltip { .disabled-action-tooltip {
@@ -1776,19 +1813,27 @@
display: inline-flex; display: inline-flex;
} }
.disabled-action-tooltip:focus-visible {
outline: none;
}
.disabled-action-tooltip:focus-visible > .btn {
box-shadow: var(--focus-ring);
}
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after, .disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after,
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after { .disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: calc(100% + 10px); bottom: calc(100% + 10px);
z-index: 80; z-index: 20000;
width: min(280px, 72vw); width: min(280px, 72vw);
padding: 9px 11px; padding: 9px 11px;
border: 1px solid rgba(180, 35, 24, .24); border: 1px solid var(--border-danger-soft);
border-radius: var(--radius-sm, 8px); border-radius: var(--radius-sm, 8px);
color: #7a241c; color: var(--danger-text-tooltip);
background: #fff7f5; background: var(--danger-muted-bg);
box-shadow: var(--shadow-popover, 0 16px 32px rgba(15, 23, 42, .18)); box-shadow: var(--shadow-popover);
content: attr(data-tooltip); content: attr(data-tooltip);
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -1819,7 +1864,7 @@
min-height: 120px; min-height: 120px;
padding: 1.25rem; padding: 1.25rem;
border-radius: var(--radius-lg, 18px); border-radius: var(--radius-lg, 18px);
background: rgba(255, 255, 255, 0.00); background: var(--transparent-surface);
backdrop-filter: blur(1.5px); backdrop-filter: blur(1.5px);
margin: -10px; margin: -10px;
} }
@@ -1829,11 +1874,11 @@
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid var(--border-soft, rgba(15, 23, 42, 0.12)); border: 1px solid var(--border-soft);
border-radius: 999px; border-radius: 999px;
color: var(--text, #172033); color: var(--text);
background: rgba(255, 255, 255, 0.86); background: var(--panel-glass-strong);
box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12)); box-shadow: var(--shadow-soft);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
} }
@@ -1853,21 +1898,21 @@
justify-content: center; justify-content: center;
width: 30px; width: 30px;
height: 30px; height: 30px;
border: 1px solid #c9c3b9; border: 1px solid var(--control-border);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(#ffffff, #f1efeb); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: #4f4a43; color: var(--control-text);
cursor: pointer; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control);
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease; transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
} }
.card-collapse-toggle:hover { .card-collapse-toggle:hover {
border-color: #aaa299; border-color: var(--control-border-pressed);
background: linear-gradient(#ffffff, #e8e5df); background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-pressed));
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08); box-shadow: var(--shadow-control-hover);
} }
.card-collapse-toggle:focus-visible { .card-collapse-toggle:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22); outline: 3px solid var(--focus-ring-color);
outline-offset: 2px; outline-offset: 2px;
} }
.card-collapse-toggle svg { .card-collapse-toggle svg {
@@ -1907,7 +1952,7 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
position: relative; position: relative;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); box-shadow: var(--shadow-dismissible);
} }
.alert-dismissible .alert-message { .alert-dismissible .alert-message {
@@ -1915,6 +1960,21 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.compact-alert {
padding: 10px 12px;
margin-bottom: 10px;
font-size: 13px;
line-height: 1.4;
}
.compact-alert .alert-message > :first-child {
margin-top: 0;
}
.compact-alert .alert-message > :last-child {
margin-bottom: 0;
}
.alert-floating-stack { .alert-floating-stack {
position: fixed; position: fixed;
top: 131px; top: 131px;
@@ -1932,9 +1992,9 @@
top: auto; top: auto;
width: 100%; width: 100%;
margin: 0; margin: 0;
border: 1px solid rgba(15, 23, 42, 0.14); border: 1px solid var(--border-soft);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2); box-shadow: var(--shadow-floating);
pointer-events: auto; pointer-events: auto;
} }
@@ -1953,7 +2013,7 @@
.alert-dismiss:hover, .alert-dismiss:hover,
.alert-dismiss:focus-visible { .alert-dismiss:focus-visible {
opacity: 1; opacity: 1;
background: rgba(255, 255, 255, 0.45); background: var(--panel-glass-muted);
} }
@@ -2015,7 +2075,7 @@
.explorer-tree-node-wrap:hover, .explorer-tree-node-wrap:hover,
.explorer-tree-node-wrap:focus-within, .explorer-tree-node-wrap:focus-within,
.explorer-tree-node-wrap.is-active { .explorer-tree-node-wrap.is-active {
background: rgba(13, 110, 253, .08); background: var(--primary-soft);
outline: none; outline: none;
} }
@@ -2029,14 +2089,14 @@
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
border-right: 1px solid var(--line); border-right: var(--border-line);
background: linear-gradient(180deg, var(--panel-soft), var(--panel)); background: linear-gradient(180deg, var(--panel-soft), var(--panel));
} }
.file-tree-heading { .file-tree-heading {
flex: 0 0 auto; flex: 0 0 auto;
padding: 13px 14px; padding: 13px 14px;
border-bottom: 1px solid var(--line); border-bottom: var(--border-line);
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -2054,7 +2114,7 @@
.file-tree-space + .file-tree-space { .file-tree-space + .file-tree-space {
margin-top: 10px; margin-top: 10px;
padding-top: 10px; padding-top: 10px;
border-top: 1px solid var(--line); border-top: var(--border-line);
} }
.file-tree-node-wrap { .file-tree-node-wrap {
@@ -2143,8 +2203,8 @@
} }
.file-tree-node-wrap.is-selected .file-tree-select { .file-tree-node-wrap.is-selected .file-tree-select {
background: #0d6efd; background: var(--primary);
color: #fff; color: var(--on-accent);
} }
.file-tree-children { .file-tree-children {
@@ -2159,10 +2219,10 @@
justify-content: start; justify-content: start;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
border: 1px solid #c9c3b9; border: var(--border-line-dark);
border-radius: 8px; border-radius: 8px;
background: #c9c3b9; background: var(--line-dark);
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control);
} }
.segmented-control-size-content { .segmented-control-size-content {
@@ -2186,18 +2246,18 @@
min-height: 32px; min-height: 32px;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: linear-gradient(#ffffff, #f1efeb); background: linear-gradient(var(--surface), var(--panel));
color: #4f4a43; color: var(--text);
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05); box-shadow: var(--shadow-control);
transition: background .18s ease, box-shadow .18s ease, color .18s ease; transition: background .18s ease, box-shadow .18s ease, color .18s ease;
} }
.segmented-control-option + .segmented-control-option { .segmented-control-option + .segmented-control-option {
border-left: 1px solid #c9c3b9; border-left: var(--border-line-dark);
} }
.segmented-control-option:first-child { .segmented-control-option:first-child {
@@ -2210,13 +2270,13 @@
.segmented-control-option:hover, .segmented-control-option:hover,
.segmented-control-option:focus-visible { .segmented-control-option:focus-visible {
background: linear-gradient(#ffffff, #e8e5df); background: linear-gradient(var(--surface), var(--bar));
color: #4f4a43; color: var(--text-strong);
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08); box-shadow: var(--shadow-control-hover);
} }
.segmented-control-option:focus-visible { .segmented-control-option:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22); outline: 3px solid var(--focus-ring-color);
outline-offset: -1px; outline-offset: -1px;
} }
@@ -2224,7 +2284,7 @@
.segmented-control-option.active { .segmented-control-option.active {
background: var(--line); background: var(--line);
color: var(--text-strong); color: var(--text-strong);
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45); box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
} }
.segmented-control-option.is-active:hover, .segmented-control-option.is-active:hover,
@@ -2232,7 +2292,7 @@
.segmented-control-option.active:hover, .segmented-control-option.active:hover,
.segmented-control-option.active:focus-visible { .segmented-control-option.active:focus-visible {
background: var(--line); background: var(--line);
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45); box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .14), var(--shadow-inset-highlight);
} }
.segmented-control-option:disabled { .segmented-control-option:disabled {
@@ -2240,6 +2300,84 @@
opacity: .55; opacity: .55;
} }
.theme-preview-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
}
.theme-preview {
--preview-bg: var(--theme-preview-light-bg);
--preview-bar: var(--theme-preview-light-bar);
--preview-surface: var(--theme-preview-light-surface);
--preview-line: var(--theme-preview-light-line);
--preview-text: var(--theme-preview-light-text);
--preview-muted: var(--theme-preview-light-muted);
display: grid;
gap: 0;
min-height: 112px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--preview-bg);
}
.theme-preview[data-preview-theme="dark"] {
--preview-bg: var(--theme-preview-dark-bg);
--preview-bar: var(--theme-preview-dark-bar);
--preview-surface: var(--theme-preview-dark-surface);
--preview-line: var(--theme-preview-dark-line);
--preview-text: var(--theme-preview-dark-text);
--preview-muted: var(--theme-preview-dark-muted);
}
.theme-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--preview-line);
background: var(--preview-bar);
color: var(--preview-text);
font-size: 12px;
font-weight: 800;
}
.theme-preview-header i {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.theme-preview-body {
display: grid;
align-content: start;
gap: 8px;
padding: 12px;
background: var(--preview-surface);
color: var(--preview-text);
}
.theme-preview-body strong {
font-size: 13px;
}
.theme-preview-body span {
display: block;
height: 8px;
border-radius: 4px;
background: var(--preview-line);
}
.theme-preview-body span:last-child {
width: 68%;
background: var(--preview-muted);
opacity: .45;
}
.message-display-panel { .message-display-panel {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -2327,7 +2465,7 @@
max-height: 420px; max-height: 420px;
margin: 0; margin: 0;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
color: var(--text); color: var(--text);
@@ -2342,7 +2480,7 @@
.message-display-html-frame { .message-display-html-frame {
height: 420px; height: 420px;
background: #fff; background: var(--white);
} }
.message-display-attachments { .message-display-attachments {
@@ -2372,11 +2510,23 @@
gap: 8px; gap: 8px;
align-items: start; align-items: start;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
} }
.message-display-attachment-row > svg {
margin-top: 1px;
}
.message-display-attachment-row.is-linked > svg {
color: var(--green);
}
.message-display-attachment-row.is-unlinked > svg {
color: var(--red);
}
.message-display-attachment-row > span { .message-display-attachment-row > span {
display: grid; display: grid;
gap: 3px; gap: 3px;
@@ -2413,7 +2563,7 @@
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
} }
@@ -2468,9 +2618,9 @@
gap: 12px; gap: 12px;
} }
.mail-server-segmented-control { .mail-server-segmented-control {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
justify-self: center; justify-self: center;
width: min(420px, 100%); width: min(320px, 100%);
} }
.mail-server-settings-view { .mail-server-settings-view {
min-width: 0; min-width: 0;
@@ -2498,7 +2648,7 @@
letter-spacing: .01em; letter-spacing: .01em;
} }
.form-grid.compact.mail-server-form-grid { .form-grid.compact.mail-server-form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.mail-server-form-grid { .mail-server-form-grid {
align-items: start; align-items: start;
@@ -2514,7 +2664,7 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.mail-server-toggle-row { .mail-server-toggle-row {
border: 1px solid var(--line); border: var(--border-line);
border-radius: 8px; border-radius: 8px;
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -2544,7 +2694,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 24px; min-height: 24px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 99px; border-radius: 99px;
background: var(--panel-soft); background: var(--panel-soft);
padding: 3px 9px; padding: 3px 9px;
@@ -2556,9 +2706,10 @@
.mail-server-settings-grid { grid-template-columns: 1fr; } .mail-server-settings-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .mail-server-segmented-control { width: min(320px, 100%); }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.credential-panel-grid { grid-template-columns: 1fr; }
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; } .form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; } .mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
.mail-server-segmented-control { width: 100%; } .mail-server-segmented-control { width: 100%; }
@@ -2588,20 +2739,20 @@
.file-drop-zone:hover, .file-drop-zone:hover,
.file-drop-zone:focus-visible, .file-drop-zone:focus-visible,
.file-drop-zone.is-active { .file-drop-zone.is-active {
border-color: #0d6efd; border-color: var(--primary);
background: rgba(13, 110, 253, .08); background: var(--primary-soft);
} }
.file-drop-zone:focus-visible { .file-drop-zone:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18); box-shadow: var(--primary-focus-ring);
} }
.file-drop-zone[aria-disabled="true"] { .file-drop-zone[aria-disabled="true"] {
cursor: not-allowed; cursor: not-allowed;
opacity: .65; opacity: .65;
} }
.file-drop-zone.is-busy { .file-drop-zone.is-busy {
border-color: #0d6efd; border-color: var(--primary);
background: rgba(13, 110, 253, .08); background: var(--primary-soft);
cursor: progress; cursor: progress;
opacity: 1; opacity: 1;
} }
@@ -2613,7 +2764,7 @@
width: 58px; width: 58px;
height: 58px; height: 58px;
border-radius: 50%; border-radius: 50%;
background: conic-gradient(#0d6efd var(--file-drop-progress), rgba(13, 110, 253, .16) 0); background: conic-gradient(var(--primary) var(--file-drop-progress), var(--primary-soft-strong) 0);
color: var(--text-strong); color: var(--text-strong);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -2625,14 +2776,14 @@
inset: 6px; inset: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--panel-soft); background: var(--panel-soft);
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .12); box-shadow: var(--primary-inset-ring);
} }
.file-drop-progress > span { .file-drop-progress > span {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.file-drop-progress.is-indeterminate { .file-drop-progress.is-indeterminate {
background: conic-gradient(#0d6efd 0 32%, rgba(13, 110, 253, .16) 32% 100%); background: conic-gradient(var(--primary) 0 32%, var(--primary-soft-strong) 32% 100%);
animation: file-drop-progress-spin .85s linear infinite; animation: file-drop-progress-spin .85s linear infinite;
} }
@keyframes file-drop-progress-spin { @keyframes file-drop-progress-spin {

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