Compare commits
29 Commits
57fe6c6006
...
v0.1.13
| Author | SHA1 | Date | |
|---|---|---|---|
| d487726f4d | |||
| e6fc07da37 | |||
| e6d589eb07 | |||
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 | |||
| 987ca894ed | |||
| 4caa326878 | |||
| 8c4c4456c6 | |||
| 6abe292ac8 | |||
| fea2807754 | |||
| 22646c614c | |||
| 17376332a2 | |||
| 0946bc84a9 | |||
| a18499cbb5 | |||
| 36d7b73bb5 | |||
| b89a2d15f1 | |||
| 7f923afdad | |||
| a7683c5d4a | |||
| 41ad057f7e | |||
| bf0729eb59 | |||
| c4b90181e0 | |||
| 55ed194a99 | |||
| b3b0cf0fca | |||
| fa9119bea7 | |||
| 70ca772138 | |||
| 2eae5c4df6 |
@@ -5,7 +5,11 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
try:
|
||||||
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
if exc.name != "govoplan_access":
|
||||||
|
raise
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.core.migrations import migration_metadata_plan
|
from govoplan_core.core.migrations import migration_metadata_plan
|
||||||
|
|||||||
@@ -453,6 +453,18 @@ The manifest should declare:
|
|||||||
- navigation metadata using serializable icon names
|
- navigation metadata using serializable icon names
|
||||||
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
||||||
|
|
||||||
|
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
|
||||||
|
only when every authenticated tenant member must receive that narrow baseline
|
||||||
|
while the contributing module is installed. Access derives the explicit grant
|
||||||
|
from the active manifest set during authorization without mutating the request
|
||||||
|
transaction. It may materialize a non-assignable role row for administration,
|
||||||
|
but no per-user assignment is required and role edits cannot remove the
|
||||||
|
baseline.
|
||||||
|
This is not a shortcut for feature authorization: keep the template narrow and
|
||||||
|
continue to enforce each domain action's own permission and resource policy.
|
||||||
|
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
|
||||||
|
templates are rejected by registry validation.
|
||||||
|
|
||||||
Backend nav metadata must use icon-name strings, not frontend components:
|
Backend nav metadata must use icon-name strings, not frontend components:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ The trust layer should provide:
|
|||||||
- key rotation and epoch tracking
|
- key rotation and epoch tracking
|
||||||
- recovery policy hooks
|
- recovery policy hooks
|
||||||
|
|
||||||
|
Recovery must be organizationally governed. A server-held universal plaintext
|
||||||
|
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
|
||||||
|
grant needs an explicit assurance profile, authority/quorum, audit trail, and
|
||||||
|
user-visible consequence.
|
||||||
|
|
||||||
## Role And Function Postboxes
|
## Role And Function Postboxes
|
||||||
|
|
||||||
Role-bound access needs special handling. A postbox can be bound to an
|
Role-bound access needs special handling. A postbox can be bound to an
|
||||||
@@ -75,6 +80,30 @@ rewrapping service:
|
|||||||
Key epochs are required when role membership changes. Older messages may remain
|
Key epochs are required when role membership changes. Older messages may remain
|
||||||
readable according to policy, but new access must use the current epoch.
|
readable according to policy, but new access must use the current epoch.
|
||||||
|
|
||||||
|
The function-bound container exists independently of membership. It may remain
|
||||||
|
vacant and continue to receive ciphertext without falling back to an unrelated
|
||||||
|
personal mailbox. Zero, one, or several incumbents are valid states. Each
|
||||||
|
incumbent receives an independently auditable, device-bound wrapped-key path;
|
||||||
|
the postbox is never copied into their account ownership.
|
||||||
|
|
||||||
|
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
|
||||||
|
encryption permits the normal rotation path to rewrap per-message data keys
|
||||||
|
rather than rewrite large ciphertext objects; a security policy may require
|
||||||
|
full content re-encryption for selected compromise or cryptographic-profile
|
||||||
|
events. The history available to a new incumbent must be selected policy (all
|
||||||
|
retained history, a bounded historical window, or assignment-time content) and
|
||||||
|
recorded with the grant.
|
||||||
|
|
||||||
|
Delegation is a time-bounded represented-function grant, not a copy or
|
||||||
|
substitution of the postbox. Expiry or withdrawal stops future key release and
|
||||||
|
actions. It cannot revoke plaintext already decrypted, printed, exported, or
|
||||||
|
captured outside the platform. Multiple simultaneous incumbents and delegates
|
||||||
|
remain distinguishable in key-fetch and action evidence.
|
||||||
|
|
||||||
|
Postbox content and signed manifests are immutable. Correction or replacement
|
||||||
|
creates a linked new object/version; it never silently substitutes ciphertext
|
||||||
|
or evidence that another actor may already have inspected.
|
||||||
|
|
||||||
## External Recipients
|
## External Recipients
|
||||||
|
|
||||||
External recipients may need one-time or time-limited access without a full
|
External recipients may need one-time or time-limited access without a full
|
||||||
|
|||||||
21
docs/THROTTLING.md
Normal file
21
docs/THROTTLING.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Shared fixed-window throttling
|
||||||
|
|
||||||
|
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||||
|
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||||
|
become store keys. A configured Redis instance provides atomic counters shared
|
||||||
|
across API workers; development, a missing Redis configuration, and temporary
|
||||||
|
Redis outages use a bounded process-local fallback. When Redis fails, local
|
||||||
|
attempts are still mirrored so losing the distributed store does not reset the
|
||||||
|
active worker's protection window.
|
||||||
|
|
||||||
|
Callers define one or more `ThrottleDimension` values with a controlled
|
||||||
|
namespace, a subject and a positive limit. They must call `check` before an
|
||||||
|
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
|
||||||
|
dimension after successful verification. A blocked decision includes a
|
||||||
|
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
|
||||||
|
|
||||||
|
The first consumer is Scheduling's anonymous participation password challenge.
|
||||||
|
Its namespace is `poll-participation-password`; its subject combines tenant,
|
||||||
|
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
|
||||||
|
login throttle predates this primitive and should be migrated onto it in a
|
||||||
|
separate compatibility-preserving slice.
|
||||||
@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
| UX-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-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||||
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||||
|
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
|
||||||
|
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||||
|
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||||
|
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||||
|
|
||||||
## Confirmed Implementation Decisions
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
@@ -191,9 +195,10 @@ explicitly retains the exception.
|
|||||||
Decision: Scheduling requests provide a concrete reference application of the
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
universal placement and component rules.
|
universal placement and component rules.
|
||||||
|
|
||||||
- The `Scheduling requests` header owns one `Add` action.
|
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||||
- Its left panel is used for the creation view only, not as a permanent second
|
requests for me`; it is list context, not a second creation affordance.
|
||||||
creation affordance.
|
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
|
||||||
|
the shared view/create/edit surface in the right main panel.
|
||||||
- Basic information, Calendar integration, candidate slots, and participants
|
- Basic information, Calendar integration, candidate slots, and participants
|
||||||
use the central `Card` component as four logical sections.
|
use the central `Card` component as four logical sections.
|
||||||
- Candidate slots and participants use the central `DataGrid`, including its
|
- Candidate slots and participants use the central `DataGrid`, including its
|
||||||
@@ -207,6 +212,59 @@ universal placement and component rules.
|
|||||||
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||||
not Scheduling-local component variants.
|
not Scheduling-local component variants.
|
||||||
|
|
||||||
|
### DUE-011: Field Help, Discard, And Table Action Contracts
|
||||||
|
|
||||||
|
Decision: the central components own these interactions; modules compose them
|
||||||
|
instead of reproducing their behavior.
|
||||||
|
|
||||||
|
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
|
||||||
|
compositions use `FieldLabel` explicitly when the meaning or limitation is
|
||||||
|
not self-explanatory.
|
||||||
|
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||||
|
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||||
|
applying `ui-hide-help-hints` at the document root.
|
||||||
|
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||||
|
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||||
|
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||||
|
the same shared unsaved-changes dialog. A browser tab/window unload remains a
|
||||||
|
browser-controlled confirmation because browsers do not permit a custom
|
||||||
|
modal at that boundary.
|
||||||
|
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
|
||||||
|
`disabledReason` for row state; omit an action only when that action does not
|
||||||
|
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||||
|
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||||
|
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
|
||||||
|
complete logical row set and applies filtering and sorting before slicing a
|
||||||
|
page. Server mode receives only the loaded page, requires `onQueryChange`,
|
||||||
|
and the backend applies every emitted filter/sort before pagination while
|
||||||
|
returning `totalRows` for the filtered result. Server list filters declare
|
||||||
|
their complete option domain instead of deriving it from the loaded page.
|
||||||
|
External filter affordances such as summary-count shortcuts update the
|
||||||
|
grid's `query` contract; the grid header controls and backend query therefore
|
||||||
|
always display and execute the same filter state.
|
||||||
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
|
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||||
|
|
||||||
|
#### FieldLabel Omission Register
|
||||||
|
|
||||||
|
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||||
|
listed here. Module repositories keep an equivalent register in their durable
|
||||||
|
UI documentation until a central cross-repository audit is available.
|
||||||
|
|
||||||
|
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||||
|
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||||
|
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||||
|
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||||
|
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
|
||||||
|
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
|
||||||
|
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
|
||||||
|
|
||||||
|
#### Alert Exception Register
|
||||||
|
|
||||||
|
No `window.alert` or global `alert` exception is authorized.
|
||||||
|
|
||||||
## Implementation Sequence
|
## Implementation Sequence
|
||||||
|
|
||||||
| Phase | Scope | Output |
|
| Phase | Scope | Output |
|
||||||
@@ -277,6 +335,14 @@ Every new or changed admin/configuration surface should answer:
|
|||||||
- Are logical sections, tabular collections, boolean settings, and repeatable
|
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||||
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||||
structured row per person respectively?
|
structured row per person respectively?
|
||||||
|
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
|
||||||
|
recorded with its rationale and accessible-name source?
|
||||||
|
- Do explicit Discard and dirty navigation use the shared unsaved-changes
|
||||||
|
registration/dialog rather than a page-local confirmation?
|
||||||
|
- Does every row retain the table's action set in the same order, disabling
|
||||||
|
unavailable actions and reserving the same empty-row slots?
|
||||||
|
- Is feedback rendered with a central dialog/alert component, with no
|
||||||
|
unauthorized `window.alert` or global `alert` call?
|
||||||
- If automation is involved, can the user see the trigger, system actor,
|
- 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?
|
||||||
|
|||||||
@@ -81,8 +81,8 @@
|
|||||||
],
|
],
|
||||||
"recorded_at": "2026-07-11T01:39:45Z",
|
"recorded_at": "2026-07-11T01:39:45Z",
|
||||||
"release": "0.1.7",
|
"release": "0.1.7",
|
||||||
"track": "release",
|
"squash_policy": "reviewed-manual",
|
||||||
"squash_policy": "reviewed-manual"
|
"track": "release"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heads": [
|
"heads": [
|
||||||
@@ -165,8 +165,380 @@
|
|||||||
],
|
],
|
||||||
"recorded_at": "2026-07-20T02:45:41Z",
|
"recorded_at": "2026-07-20T02:45:41Z",
|
||||||
"release": "0.1.8",
|
"release": "0.1.8",
|
||||||
"track": "release",
|
"squash_policy": "reviewed-manual",
|
||||||
"squash_policy": "reviewed-manual"
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "4d5e6f7a9203"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"4d5e6f7a9203"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T02:42:27Z",
|
||||||
|
"release": "0.1.11",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T07:06:21Z",
|
||||||
|
"release": "0.1.12",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T08:39:02Z",
|
||||||
|
"release": "0.1.13",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 1
|
"version": 1
|
||||||
|
|||||||
@@ -84,7 +84,15 @@
|
|||||||
"provides_interfaces": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "mail.campaign_delivery",
|
"name": "mail.campaign_delivery",
|
||||||
"version": "1.4.0"
|
"version": "0.2.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requires_interfaces": [
|
||||||
|
{
|
||||||
|
"name": "campaigns.access",
|
||||||
|
"version_min": "0.1.0",
|
||||||
|
"version_max_exclusive": "0.2.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.9"
|
version = "0.1.13"
|
||||||
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"
|
||||||
@@ -27,6 +27,12 @@ where = ["src"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
govoplan_core = ["py.typed"]
|
govoplan_core = ["py.typed"]
|
||||||
|
|
||||||
|
[tool.setuptools.data-files]
|
||||||
|
"govoplan_core_runtime" = ["alembic.ini"]
|
||||||
|
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
|
||||||
|
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
|
||||||
|
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
govoplan-config = "govoplan_core.commands.config:main"
|
govoplan-config = "govoplan_core.commands.config:main"
|
||||||
govoplan-devserver = "govoplan_core.devserver:main"
|
govoplan-devserver = "govoplan_core.devserver:main"
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ 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,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
# Deployment-wide connector egress policy. Enable private networks only when
|
# Deployment-wide connector egress policy. Enable private networks only when
|
||||||
# this installation intentionally integrates with internal services.
|
# this installation intentionally integrates with internal services.
|
||||||
@@ -405,6 +406,7 @@ REDIS_URL=redis://127.0.0.1:56379/0
|
|||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class RoleTemplate:
|
|||||||
level: PermissionLevel = "tenant"
|
level: PermissionLevel = "tenant"
|
||||||
managed: bool = True
|
managed: bool = True
|
||||||
protected: bool = False
|
protected: bool = False
|
||||||
|
default_authenticated: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -69,6 +70,15 @@ class FrontendRoute:
|
|||||||
order: int = 100
|
order: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PublicFrontendRoute:
|
||||||
|
"""Explicitly allowlisted route that can render without authentication."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
component: str
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FrontendModule:
|
class FrontendModule:
|
||||||
module_id: str
|
module_id: str
|
||||||
@@ -79,6 +89,7 @@ class FrontendModule:
|
|||||||
asset_manifest_integrity: str | None = None
|
asset_manifest_integrity: str | None = None
|
||||||
asset_manifest_contract_version: str = "1"
|
asset_manifest_contract_version: str = "1"
|
||||||
routes: tuple[FrontendRoute, ...] = ()
|
routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
|
||||||
@@ -236,6 +247,35 @@ class DocumentationTopic:
|
|||||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||||
|
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||||
|
|
||||||
|
Topic conditions are alternatives. Every alternative therefore needs an
|
||||||
|
explicit scope constraint; otherwise one unscoped alternative would make
|
||||||
|
the workflow visible regardless of the actor's task authority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw_kind = topic.metadata.get("kind")
|
||||||
|
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||||
|
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||||
|
return ()
|
||||||
|
if not topic.conditions:
|
||||||
|
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||||
|
|
||||||
|
unscoped_alternatives = tuple(
|
||||||
|
index
|
||||||
|
for index, condition in enumerate(topic.conditions, start=1)
|
||||||
|
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||||
|
)
|
||||||
|
if not unscoped_alternatives:
|
||||||
|
return ()
|
||||||
|
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
|
||||||
|
return (
|
||||||
|
"every user workflow condition alternative must declare required_scopes or any_scopes; "
|
||||||
|
f"unscoped alternative(s): {alternatives}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DocumentationContext:
|
class DocumentationContext:
|
||||||
registry: object
|
registry: object
|
||||||
|
|||||||
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
|
||||||
|
|
||||||
|
# Identity search is deliberately absent here. ``identity.search`` is an
|
||||||
|
# instance-wide canonical-identity directory and has no tenant/principal
|
||||||
|
# visibility contract. Ordinary task pickers must use principal-aware search
|
||||||
|
# providers instead.
|
||||||
|
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleSearchError(ValueError):
|
||||||
|
"""Stable, non-diagnostic error raised by people-search providers."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PersonSearchCandidate:
|
||||||
|
"""A policy-filtered selection target returned by a directory provider."""
|
||||||
|
|
||||||
|
selection_key: str
|
||||||
|
kind: str
|
||||||
|
reference_id: str
|
||||||
|
display_name: str
|
||||||
|
email: str | None = None
|
||||||
|
source_module: str | None = None
|
||||||
|
source_label: str | None = None
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PeopleSearchGroup:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
candidates: tuple[PersonSearchCandidate, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PeopleSearchProvider(Protocol):
|
||||||
|
"""Server-side, principal-aware people/delivery-target search.
|
||||||
|
|
||||||
|
Implementations must derive visibility from ``principal`` and may only
|
||||||
|
return records the principal can discover in its active tenant and policy
|
||||||
|
context. Callers remain responsible for authorizing the task for which the
|
||||||
|
picker is used (for example, creating a scheduling request).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> Sequence[PeopleSearchGroup]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def people_search_providers(
|
||||||
|
registry: object | None,
|
||||||
|
*,
|
||||||
|
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||||
|
) -> tuple[PeopleSearchProvider, ...]:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
providers: list[PeopleSearchProvider] = []
|
||||||
|
for capability_name in capability_names:
|
||||||
|
if not registry.has_capability(capability_name):
|
||||||
|
continue
|
||||||
|
capability = registry.capability(capability_name)
|
||||||
|
if isinstance(capability, PeopleSearchProvider):
|
||||||
|
providers.append(capability)
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
|
||||||
|
def search_visible_people(
|
||||||
|
registry: object | None,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
"""Collect grouped results without importing optional feature modules.
|
||||||
|
|
||||||
|
``limit`` is enforced per provider so one directory cannot starve another
|
||||||
|
result group. Providers are expected to return at most that many candidates
|
||||||
|
in total. Duplicate group keys are merged and candidate selection keys are
|
||||||
|
de-duplicated while preserving provider order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_query = str(query or "").strip()
|
||||||
|
normalized_limit = max(1, min(int(limit), 100))
|
||||||
|
ordered_group_keys: list[str] = []
|
||||||
|
labels: dict[str, str] = {}
|
||||||
|
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
|
||||||
|
candidate_keys_by_group: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
for provider in people_search_providers(registry, capability_names=capability_names):
|
||||||
|
groups = provider.search_people(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=normalized_query,
|
||||||
|
limit=normalized_limit,
|
||||||
|
)
|
||||||
|
provider_candidate_count = 0
|
||||||
|
for group in groups:
|
||||||
|
if not group.key:
|
||||||
|
continue
|
||||||
|
if group.key not in candidates_by_group:
|
||||||
|
ordered_group_keys.append(group.key)
|
||||||
|
labels[group.key] = group.label
|
||||||
|
candidates_by_group[group.key] = []
|
||||||
|
candidate_keys_by_group[group.key] = set()
|
||||||
|
for candidate in group.candidates:
|
||||||
|
if provider_candidate_count >= normalized_limit:
|
||||||
|
break
|
||||||
|
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
|
||||||
|
continue
|
||||||
|
candidate_keys_by_group[group.key].add(candidate.selection_key)
|
||||||
|
candidates_by_group[group.key].append(candidate)
|
||||||
|
provider_candidate_count += 1
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key=group_key,
|
||||||
|
label=labels[group_key],
|
||||||
|
candidates=tuple(candidates_by_group[group_key]),
|
||||||
|
)
|
||||||
|
for group_key in ordered_group_keys
|
||||||
|
if candidates_by_group[group_key]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
|
||||||
|
normalized_kind = str(kind).strip().casefold()
|
||||||
|
normalized_reference = str(reference_id).strip()
|
||||||
|
normalized_email = str(email or "").strip().casefold()
|
||||||
|
if not normalized_kind or not normalized_reference:
|
||||||
|
raise ValueError("Person selection keys require a kind and reference id.")
|
||||||
|
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
|
||||||
|
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
|
||||||
|
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
|
||||||
|
"PeopleSearchError",
|
||||||
|
"PeopleSearchGroup",
|
||||||
|
"PeopleSearchProvider",
|
||||||
|
"PersonSearchCandidate",
|
||||||
|
"people_search_providers",
|
||||||
|
"person_selection_key",
|
||||||
|
"search_visible_people",
|
||||||
|
]
|
||||||
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionOrderCommand:
|
||||||
|
"""Complete active option order supplied by an owning module.
|
||||||
|
|
||||||
|
Providers must reject partial, duplicate, or unknown option collections.
|
||||||
|
An exact repeat is an idempotent no-op. Reordering changes positions only;
|
||||||
|
option identities and their existing answers remain valid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
option_ids: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollInvitationCommand:
|
class PollInvitationCommand:
|
||||||
respondent_id: str | None = None
|
respondent_id: str | None = None
|
||||||
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementCommand:
|
||||||
|
"""Retire live responses while preserving their auditable history.
|
||||||
|
|
||||||
|
Owning modules provide every server-trusted identity that can refer to the
|
||||||
|
participant. Providers must soft-delete matching live responses, retain
|
||||||
|
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
respondent_ids: tuple[str, ...] = ()
|
||||||
|
invitation_id: str | None = None
|
||||||
|
reason: str = "participant_removed"
|
||||||
|
idempotency_key: str = ""
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollOptionRef:
|
class PollOptionRef:
|
||||||
id: str
|
id: str
|
||||||
@@ -138,6 +166,16 @@ class PollResponseRef:
|
|||||||
answers: tuple[PollAnswerRef, ...] = ()
|
answers: tuple[PollAnswerRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementRef:
|
||||||
|
"""Outcome of an auditable response-retirement request."""
|
||||||
|
|
||||||
|
response_ids: tuple[str, ...] = ()
|
||||||
|
retired_at: datetime | None = None
|
||||||
|
newly_retired_count: int = 0
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class PollSchedulingProvider(Protocol):
|
class PollSchedulingProvider(Protocol):
|
||||||
def create_poll(
|
def create_poll(
|
||||||
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
|
|||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def reorder_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollOptionOrderCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
"""Atomically replace the complete active option order."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseRetirementProvider(Protocol):
|
||||||
|
"""Optional extension for owning modules that remove participants."""
|
||||||
|
|
||||||
|
def retire_responses(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollResponseRetirementCommand,
|
||||||
|
) -> PollResponseRetirementRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||||
if registry is None or not hasattr(registry, "has_capability"):
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
return None
|
return None
|
||||||
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
|
|||||||
) -> PollResponseSubmissionProvider | None:
|
) -> PollResponseSubmissionProvider | None:
|
||||||
provider = poll_scheduling_provider(registry)
|
provider = poll_scheduling_provider(registry)
|
||||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_retirement_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollResponseRetirementProvider | None:
|
||||||
|
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, PollResponseRetirementProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
272
src/govoplan_core/core/poll_participation.py
Normal file
272
src/govoplan_core/core/poll_participation.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.poll import (
|
||||||
|
PollAnswerRequest,
|
||||||
|
PollInvitationRef,
|
||||||
|
PollOptionRequest,
|
||||||
|
PollResponseRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
|
||||||
|
# Policy-attestation identifier, not a credential or credential default.
|
||||||
|
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
|
||||||
|
PARTICIPATION_POLICY_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def participation_token_fingerprint(token: str) -> str:
|
||||||
|
"""Return a non-reversible identifier suitable for audit/throttle keys."""
|
||||||
|
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseGatewayRef:
|
||||||
|
"""Stable identity of the module resource governing a participation link."""
|
||||||
|
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollParticipationPolicy:
|
||||||
|
"""Generic response rules snapshotted onto one signed invitation.
|
||||||
|
|
||||||
|
``single_choice`` treats every non-``unavailable`` availability answer as
|
||||||
|
a selection. Capacity is reserved only by ``available`` answers (and by
|
||||||
|
selected answers for non-availability polls), so ``maybe`` never consumes
|
||||||
|
a place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version: int = PARTICIPATION_POLICY_VERSION
|
||||||
|
single_choice: bool = False
|
||||||
|
allow_maybe: bool = True
|
||||||
|
max_participants_per_option: int | None = None
|
||||||
|
allow_comments: bool = False
|
||||||
|
participant_email_required: bool = False
|
||||||
|
anonymous_password_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollGovernedInvitationCommand:
|
||||||
|
gateway: PollResponseGatewayRef
|
||||||
|
policy: PollParticipationPolicy
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollGovernedResponseCommand:
|
||||||
|
"""Submission already authorized by the named in-process gateway.
|
||||||
|
|
||||||
|
Passwords never cross this boundary. A gateway that owns a password
|
||||||
|
verifier reports the completed check through ``verified_requirements``.
|
||||||
|
Poll independently re-enforces the remaining snapshotted rules while
|
||||||
|
holding its Poll-row lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
participant_email: str | None = None
|
||||||
|
participant_is_authenticated: bool = False
|
||||||
|
answers: tuple[PollAnswerRequest, ...] = ()
|
||||||
|
comment: str | None = None
|
||||||
|
verified_requirements: frozenset[str] = frozenset()
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollGovernedResponseRef:
|
||||||
|
response: PollResponseRef
|
||||||
|
participant_email: str | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionMutationRef:
|
||||||
|
id: str
|
||||||
|
position: int
|
||||||
|
replayed: bool = False
|
||||||
|
invalidated_response_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationRevocationRef:
|
||||||
|
id: str
|
||||||
|
revoked_at: datetime
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationExpiryRef:
|
||||||
|
id: str
|
||||||
|
expires_at: datetime | None
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollParticipationContextRef:
|
||||||
|
invitation_id: str
|
||||||
|
tenant_id: str
|
||||||
|
poll_id: str
|
||||||
|
gateway: PollResponseGatewayRef
|
||||||
|
policy: PollParticipationPolicy
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
response: PollGovernedResponseRef | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollParticipationGatewayProvider(Protocol):
|
||||||
|
def create_governed_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollGovernedInvitationCommand,
|
||||||
|
) -> PollInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_participation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
respondent_id: str | None = None,
|
||||||
|
participant_email: str | None = None,
|
||||||
|
participant_is_authenticated: bool = False,
|
||||||
|
verified_requirements: frozenset[str] = frozenset(),
|
||||||
|
) -> PollParticipationContextRef:
|
||||||
|
"""Resolve and prefill one valid invitation for the exact gateway."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def submit_governed_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
command: PollGovernedResponseCommand,
|
||||||
|
) -> PollGovernedResponseRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_authenticated_participation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
respondent_id: str,
|
||||||
|
) -> PollParticipationContextRef:
|
||||||
|
"""Resolve one governed invitation without retaining its public token."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def submit_authenticated_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
respondent_id: str,
|
||||||
|
command: PollGovernedResponseCommand,
|
||||||
|
) -> PollGovernedResponseRef:
|
||||||
|
"""Submit atomically for the exact authenticated invitation identity."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def add_option(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollOptionRequest,
|
||||||
|
) -> PollOptionMutationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def remove_option(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str,
|
||||||
|
) -> PollOptionMutationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def revoke_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
) -> PollInvitationRevocationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_invitation_expiry(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
gateway: PollResponseGatewayRef,
|
||||||
|
expires_at: datetime | None,
|
||||||
|
) -> PollInvitationExpiryRef:
|
||||||
|
"""Expire or extend a non-revoked governed invitation in place."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def poll_participation_gateway_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollParticipationGatewayProvider | None:
|
||||||
|
"""Resolve the governed Poll gateway without importing its implementation."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||||
|
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
||||||
|
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
||||||
|
"PARTICIPATION_POLICY_VERSION",
|
||||||
|
"PollGovernedInvitationCommand",
|
||||||
|
"PollGovernedResponseCommand",
|
||||||
|
"PollGovernedResponseRef",
|
||||||
|
"PollInvitationExpiryRef",
|
||||||
|
"PollInvitationRevocationRef",
|
||||||
|
"PollOptionMutationRef",
|
||||||
|
"PollParticipationContextRef",
|
||||||
|
"PollParticipationGatewayProvider",
|
||||||
|
"PollParticipationPolicy",
|
||||||
|
"PollResponseGatewayRef",
|
||||||
|
"participation_token_fingerprint",
|
||||||
|
"poll_participation_gateway_provider",
|
||||||
|
]
|
||||||
@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
PublicFrontendRoute,
|
||||||
ResourceAclProvider,
|
ResourceAclProvider,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
@@ -194,6 +196,7 @@ class PlatformRegistry:
|
|||||||
available_capabilities=available_capabilities,
|
available_capabilities=available_capabilities,
|
||||||
)
|
)
|
||||||
permissions = _collect_manifest_permissions(ordered)
|
permissions = _collect_manifest_permissions(ordered)
|
||||||
|
_validate_public_frontend_route_uniqueness(ordered)
|
||||||
_validate_interface_closure(ordered)
|
_validate_interface_closure(ordered)
|
||||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||||
|
|
||||||
@@ -320,9 +323,34 @@ def _validate_role_template_scopes(
|
|||||||
*,
|
*,
|
||||||
known_scopes: set[str],
|
known_scopes: set[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
seen_templates: dict[tuple[str, str], str] = {}
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
for template in manifest.role_templates:
|
for template in manifest.role_templates:
|
||||||
|
template_key = (template.level, template.slug)
|
||||||
|
previous_module = seen_templates.get(template_key)
|
||||||
|
if previous_module is not None:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate {template.level} role template slug {template.slug!r} "
|
||||||
|
f"in modules {previous_module!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
seen_templates[template_key] = manifest.id
|
||||||
|
if template.default_authenticated and template.level != "tenant":
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be tenant-level"
|
||||||
|
)
|
||||||
|
if template.default_authenticated and not template.managed:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be managed"
|
||||||
|
)
|
||||||
for scope in template.permissions:
|
for scope in template.permissions:
|
||||||
|
if template.default_authenticated and (
|
||||||
|
scope in {"*", "tenant:*", "system:*"}
|
||||||
|
or _WILDCARD_RE.match(scope)
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} "
|
||||||
|
"must use explicit permissions, not wildcard scopes"
|
||||||
|
)
|
||||||
if _role_template_scope_known(scope, known_scopes):
|
if _role_template_scope_known(scope, known_scopes):
|
||||||
continue
|
continue
|
||||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||||
@@ -344,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
_validate_manifest_frontend(manifest)
|
_validate_manifest_frontend(manifest)
|
||||||
for item in manifest.nav_items:
|
for item in manifest.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
for issue in user_workflow_scope_condition_issues(topic):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||||
@@ -403,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
|||||||
)
|
)
|
||||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
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}")
|
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||||
for route in (*frontend.routes, *frontend.settings_routes):
|
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
@@ -416,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
|||||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_public_frontend_route_uniqueness(
|
||||||
|
manifests: tuple[ModuleManifest, ...],
|
||||||
|
) -> None:
|
||||||
|
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
if manifest.frontend is None:
|
||||||
|
continue
|
||||||
|
for route in manifest.frontend.public_routes:
|
||||||
|
previous = owners.get(route.path)
|
||||||
|
if previous is not None:
|
||||||
|
previous_module, _previous_route = previous
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate public frontend route {route.path!r} in modules "
|
||||||
|
f"{previous_module!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
owners[route.path] = (manifest.id, route)
|
||||||
|
|
||||||
|
|
||||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
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:
|
||||||
|
|||||||
349
src/govoplan_core/core/throttling.py
Normal file
349
src/govoplan_core/core/throttling.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||||
|
_REDIS_INCREMENT_SCRIPT = """
|
||||||
|
local count = redis.call('INCR', KEYS[1])
|
||||||
|
if count == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
local ttl = redis.call('TTL', KEYS[1])
|
||||||
|
return {count, ttl}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FixedWindowBucket:
|
||||||
|
count: int = 0
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDimension:
|
||||||
|
"""One independently limited dimension without putting its value in keys."""
|
||||||
|
|
||||||
|
namespace: str
|
||||||
|
subject: str
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDecision:
|
||||||
|
allowed: bool
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowStore(Protocol):
|
||||||
|
def read(self, key: str) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryFixedWindowStore:
|
||||||
|
"""Bounded process-local store for development and distributed-store loss."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
max_entries: int = 10_000,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._entries: dict[str, tuple[int, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._max_entries = max(2, max_entries)
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
return FixedWindowBucket()
|
||||||
|
count, expires_at = entry
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
self._make_room(now=now, incoming_key=key)
|
||||||
|
count = 1
|
||||||
|
expires_at = now + window_seconds
|
||||||
|
else:
|
||||||
|
count = entry[0] + 1
|
||||||
|
expires_at = entry[1]
|
||||||
|
self._entries[key] = (count, expires_at)
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
|
||||||
|
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if entry[1] <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||||
|
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||||
|
return
|
||||||
|
for key, (_count, expires_at) in tuple(self._entries.items()):
|
||||||
|
if expires_at <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
while len(self._entries) >= self._max_entries:
|
||||||
|
self._entries.pop(next(iter(self._entries)))
|
||||||
|
|
||||||
|
|
||||||
|
class RedisFixedWindowStore:
|
||||||
|
"""Atomic Redis fixed-window counters shared across API workers."""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str) -> None:
|
||||||
|
self._client = Redis.from_url(
|
||||||
|
redis_url,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=0.25,
|
||||||
|
socket_timeout=0.25,
|
||||||
|
health_check_interval=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
pipeline = self._client.pipeline(transaction=False)
|
||||||
|
pipeline.get(key)
|
||||||
|
pipeline.ttl(key)
|
||||||
|
raw_count, raw_ttl = pipeline.execute()
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(raw_count or 0),
|
||||||
|
retry_after_seconds=max(0, int(raw_ttl or 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
result = self._client.eval(
|
||||||
|
_REDIS_INCREMENT_SCRIPT,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
window_seconds,
|
||||||
|
)
|
||||||
|
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||||
|
raise RedisError("Unexpected fixed-window throttle response from Redis")
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(result[0]),
|
||||||
|
retry_after_seconds=max(1, int(result[1])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._client.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
class ResilientFixedWindowStore:
|
||||||
|
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
primary: FixedWindowStore | None,
|
||||||
|
fallback: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
retry_seconds: int = 30,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._primary = primary
|
||||||
|
self._fallback = fallback
|
||||||
|
self._retry_seconds = max(1, retry_seconds)
|
||||||
|
self._clock = clock
|
||||||
|
self._primary_unavailable_until = 0.0
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.read(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
return _stricter_bucket(primary.read(key), fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
primary_result = primary.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
return _stricter_bucket(primary_result, fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._fallback.delete(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
primary.delete(key)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
|
||||||
|
def _available_primary(self) -> FixedWindowStore | None:
|
||||||
|
if self._primary is None:
|
||||||
|
return None
|
||||||
|
with self._state_lock:
|
||||||
|
if self._clock() < self._primary_unavailable_until:
|
||||||
|
return None
|
||||||
|
return self._primary
|
||||||
|
|
||||||
|
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||||
|
should_log = False
|
||||||
|
with self._state_lock:
|
||||||
|
now = self._clock()
|
||||||
|
if now >= self._primary_unavailable_until:
|
||||||
|
should_log = True
|
||||||
|
self._primary_unavailable_until = now + self._retry_seconds
|
||||||
|
if should_log:
|
||||||
|
logger.warning(
|
||||||
|
"Distributed throttling is unavailable; using the process-local fallback (%s)",
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stricter_bucket(
|
||||||
|
first: FixedWindowBucket,
|
||||||
|
second: FixedWindowBucket,
|
||||||
|
) -> FixedWindowBucket:
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=max(first.count, second.count),
|
||||||
|
retry_after_seconds=max(
|
||||||
|
first.retry_after_seconds,
|
||||||
|
second.retry_after_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowThrottle:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
window_seconds: int,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._window_seconds = max(1, window_seconds)
|
||||||
|
self._key_prefix = key_prefix.rstrip(":")
|
||||||
|
|
||||||
|
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=False)
|
||||||
|
|
||||||
|
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=True)
|
||||||
|
|
||||||
|
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
|
||||||
|
for dimension in tuple(dimensions):
|
||||||
|
self._store.delete(self._key(dimension))
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
self,
|
||||||
|
dimensions: tuple[ThrottleDimension, ...],
|
||||||
|
*,
|
||||||
|
increment: bool,
|
||||||
|
) -> ThrottleDecision:
|
||||||
|
if not dimensions:
|
||||||
|
raise ValueError("At least one throttle dimension is required")
|
||||||
|
blocked_retry_after = 0
|
||||||
|
for dimension in dimensions:
|
||||||
|
if dimension.limit < 1:
|
||||||
|
raise ValueError("Throttle limits must be positive")
|
||||||
|
key = self._key(dimension)
|
||||||
|
state = (
|
||||||
|
self._store.increment(key, window_seconds=self._window_seconds)
|
||||||
|
if increment
|
||||||
|
else self._store.read(key)
|
||||||
|
)
|
||||||
|
if state.count >= dimension.limit:
|
||||||
|
blocked_retry_after = max(
|
||||||
|
blocked_retry_after,
|
||||||
|
max(1, state.retry_after_seconds),
|
||||||
|
)
|
||||||
|
return ThrottleDecision(
|
||||||
|
allowed=blocked_retry_after == 0,
|
||||||
|
retry_after_seconds=blocked_retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _key(self, dimension: ThrottleDimension) -> str:
|
||||||
|
namespace = dimension.namespace.strip().casefold()
|
||||||
|
if not _NAMESPACE_PATTERN.fullmatch(namespace):
|
||||||
|
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
|
||||||
|
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
|
||||||
|
return f"{self._key_prefix}:{namespace}:{subject_digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixed_window_throttle(
|
||||||
|
*,
|
||||||
|
redis_url: str | None,
|
||||||
|
window_seconds: int,
|
||||||
|
redis_retry_seconds: int = 30,
|
||||||
|
max_local_entries: int = 10_000,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> FixedWindowThrottle:
|
||||||
|
primary = (
|
||||||
|
RedisFixedWindowStore(redis_url)
|
||||||
|
if redis_url is not None and redis_url.strip()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
store = ResilientFixedWindowStore(
|
||||||
|
primary,
|
||||||
|
InMemoryFixedWindowStore(max_entries=max_local_entries),
|
||||||
|
retry_seconds=redis_retry_seconds,
|
||||||
|
)
|
||||||
|
return FixedWindowThrottle(
|
||||||
|
store,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FixedWindowBucket",
|
||||||
|
"FixedWindowStore",
|
||||||
|
"FixedWindowThrottle",
|
||||||
|
"InMemoryFixedWindowStore",
|
||||||
|
"RedisFixedWindowStore",
|
||||||
|
"ResilientFixedWindowStore",
|
||||||
|
"ThrottleDecision",
|
||||||
|
"ThrottleDimension",
|
||||||
|
"build_fixed_window_throttle",
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import sysconfig
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -462,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
|
|||||||
|
|
||||||
def _repo_root() -> Path:
|
def _repo_root() -> Path:
|
||||||
packaged_root = Path(__file__).resolve().parents[3]
|
packaged_root = Path(__file__).resolve().parents[3]
|
||||||
|
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
|
||||||
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
||||||
candidates = [
|
candidates = [
|
||||||
Path(configured).expanduser() if configured else None,
|
Path(configured).expanduser() if configured else None,
|
||||||
Path.cwd(),
|
Path.cwd(),
|
||||||
packaged_root,
|
packaged_root,
|
||||||
|
installed_runtime_root,
|
||||||
]
|
]
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if candidate is None:
|
if candidate is None:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.i18n import system_i18n_payload
|
from govoplan_core.i18n import system_i18n_payload
|
||||||
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"path": route.path,
|
||||||
|
"component": route.component,
|
||||||
|
"order": route.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||||
if frontend is None:
|
if frontend is None:
|
||||||
return None
|
return None
|
||||||
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
|||||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||||
|
"public_routes": [
|
||||||
|
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||||
|
],
|
||||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
|
||||||
|
"""Return only assets and routes explicitly approved for signed-out use."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"module_id": frontend.module_id,
|
||||||
|
"package_name": frontend.package_name,
|
||||||
|
"asset_manifest": frontend.asset_manifest,
|
||||||
|
"asset_manifest_signature": frontend.asset_manifest_signature,
|
||||||
|
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||||
|
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||||
|
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||||
|
"public_routes": [
|
||||||
|
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||||
capabilities: list[str] = []
|
capabilities: list[str] = []
|
||||||
if manifest_id == "mail":
|
if manifest_id == "mail":
|
||||||
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/public-modules")
|
||||||
|
def public_modules(request: Request):
|
||||||
|
registry = _registry(request)
|
||||||
|
return {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": manifest.id,
|
||||||
|
"name": manifest.name,
|
||||||
|
"version": manifest.version,
|
||||||
|
"frontend": _public_frontend_payload(manifest.frontend),
|
||||||
|
}
|
||||||
|
for manifest in registry.manifests()
|
||||||
|
if manifest.frontend is not None
|
||||||
|
and manifest.frontend.public_routes
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/permissions")
|
@router.get("/permissions")
|
||||||
def permissions(request: Request):
|
def permissions(request: Request):
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ class Settings(BaseSettings):
|
|||||||
ge=0,
|
ge=0,
|
||||||
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||||
)
|
)
|
||||||
|
scheduling_cancellation_notice_days: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
le=90,
|
||||||
|
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
|
||||||
|
)
|
||||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
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.
|
||||||
|
|||||||
@@ -100,6 +100,37 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
||||||
|
|
||||||
|
def _create_test_mail_profile(
|
||||||
|
self,
|
||||||
|
headers: dict[str, str],
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
include_imap: bool = False,
|
||||||
|
) -> str:
|
||||||
|
credentials = {
|
||||||
|
"smtp": {"username": "sender@example.org", "password": "test-secret"},
|
||||||
|
}
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"name": name,
|
||||||
|
"smtp": {
|
||||||
|
"host": "mock.smtp",
|
||||||
|
"port": 2525,
|
||||||
|
"security": "starttls",
|
||||||
|
},
|
||||||
|
"credentials": credentials,
|
||||||
|
}
|
||||||
|
if include_imap:
|
||||||
|
payload["imap"] = {
|
||||||
|
"host": "mock.imap",
|
||||||
|
"port": 993,
|
||||||
|
"security": "tls",
|
||||||
|
"sent_folder": "Sent",
|
||||||
|
}
|
||||||
|
credentials["imap"] = {"username": "sender@example.org", "password": "test-secret"}
|
||||||
|
response = self.client.post("/api/v1/mail/profiles", headers=headers, json=payload)
|
||||||
|
self.assertEqual(response.status_code, 201, response.text)
|
||||||
|
return str(response.json()["id"])
|
||||||
|
|
||||||
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
|
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
payload = {
|
payload = {
|
||||||
@@ -224,6 +255,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
external_id: str,
|
external_id: str,
|
||||||
recipient_count: int = 1,
|
recipient_count: int = 1,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name=f"{external_id} delivery")
|
||||||
entries = [
|
entries = [
|
||||||
{
|
{
|
||||||
"id": f"recipient-{index + 1}",
|
"id": f"recipient-{index + 1}",
|
||||||
@@ -237,15 +269,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
|
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
|
||||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "mock.smtp",
|
|
||||||
"port": 2525,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -740,10 +764,40 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
assert version is not None
|
assert version is not None
|
||||||
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
||||||
snapshot = version.execution_snapshot or {}
|
snapshot = version.execution_snapshot or {}
|
||||||
self.assertIsNone(snapshot.get("smtp", {}).get("password"))
|
self.assertEqual(snapshot["snapshot_version"], "5")
|
||||||
self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp")
|
self.assertEqual(snapshot["mail_profile_id"], profile_id)
|
||||||
|
self.assertNotIn("smtp", snapshot)
|
||||||
|
self.assertNotIn("imap", snapshot)
|
||||||
|
self.assertTrue(snapshot["smtp_transport_revision"])
|
||||||
|
self.assertTrue(snapshot["imap_transport_revision"])
|
||||||
|
|
||||||
def test_mail_profile_can_inherit_server_without_credentials(self) -> None:
|
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
|
||||||
|
self.assertEqual(deleted.status_code, 200, deleted.text)
|
||||||
|
self.assertFalse(deleted.json()["is_active"])
|
||||||
|
self.assertFalse(deleted.json()["smtp_password_configured"])
|
||||||
|
self.assertFalse(deleted.json()["imap_password_configured"])
|
||||||
|
|
||||||
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
|
|
||||||
|
with SessionLocal() as session:
|
||||||
|
profile = session.get(MailServerProfile, profile_id)
|
||||||
|
self.assertIsNotNone(profile)
|
||||||
|
assert profile is not None
|
||||||
|
self.assertIsNone(profile.smtp_password_encrypted)
|
||||||
|
self.assertIsNone(profile.imap_password_encrypted)
|
||||||
|
audit = (
|
||||||
|
session.query(AuditLog)
|
||||||
|
.filter(
|
||||||
|
AuditLog.action == "mail.profile_credentials_deleted",
|
||||||
|
AuditLog.object_id == profile_id,
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
self.assertEqual(audit.details["deleted_protocols"], ["smtp", "imap"])
|
||||||
|
self.assertEqual(audit.details["deletion_reason"], "profile_deactivated")
|
||||||
|
self.assertNotIn("secret", repr(audit.details))
|
||||||
|
|
||||||
|
def test_campaign_runtime_does_not_receive_mail_owned_profile_credentials(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
created_profile = self.client.post(
|
created_profile = self.client.post(
|
||||||
"/api/v1/mail/profiles",
|
"/api/v1/mail/profiles",
|
||||||
@@ -775,15 +829,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
|
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
|
||||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": profile_id},
|
||||||
"mail_profile_id": profile_id,
|
|
||||||
"inherit_smtp_credentials": False,
|
|
||||||
"inherit_imap_credentials": False,
|
|
||||||
"credentials": {
|
|
||||||
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
|
||||||
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||||
@@ -810,31 +856,43 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(built.status_code, 200, built.text)
|
self.assertEqual(built.status_code, 200, built.text)
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import CampaignVersion
|
from govoplan_campaign.backend.db.models import CampaignVersion
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config
|
from govoplan_campaign.backend.integrations import mail_integration
|
||||||
|
from govoplan_mail.backend.db.models import MailServerProfile
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
version = session.get(CampaignVersion, version_id)
|
version = session.get(CampaignVersion, version_id)
|
||||||
self.assertIsNotNone(version)
|
self.assertIsNotNone(version)
|
||||||
assert version is not None
|
assert version is not None
|
||||||
snapshot_payload = version.execution_snapshot or {}
|
snapshot_payload = version.execution_snapshot or {}
|
||||||
self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp")
|
self.assertEqual(snapshot_payload["snapshot_version"], "5")
|
||||||
self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org")
|
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
|
||||||
self.assertIsNone(snapshot_payload.get("smtp", {}).get("password"))
|
self.assertNotIn("smtp", snapshot_payload)
|
||||||
self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap")
|
self.assertNotIn("imap", snapshot_payload)
|
||||||
self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org")
|
self.assertTrue(snapshot_payload["smtp_transport_revision"])
|
||||||
self.assertIsNone(snapshot_payload.get("imap", {}).get("password"))
|
self.assertTrue(snapshot_payload["imap_transport_revision"])
|
||||||
|
|
||||||
snapshot = ExecutionSnapshot.model_validate(snapshot_payload)
|
profile = session.get(MailServerProfile, profile_id)
|
||||||
smtp_runtime = runtime_smtp_config(session, version, snapshot)
|
self.assertIsNotNone(profile)
|
||||||
imap_runtime = runtime_imap_config(session, version, snapshot)
|
assert profile is not None
|
||||||
self.assertEqual(smtp_runtime.host, "mock.smtp")
|
integration = mail_integration()
|
||||||
self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org")
|
summary = integration.campaign_profile_delivery_summary(
|
||||||
self.assertEqual(smtp_runtime.password, "campaign-smtp-secret")
|
session,
|
||||||
self.assertIsNotNone(imap_runtime)
|
tenant_id=profile.tenant_id,
|
||||||
assert imap_runtime is not None
|
campaign_id=version.campaign_id,
|
||||||
self.assertEqual(imap_runtime.host, "mock.imap")
|
profile_id=profile_id,
|
||||||
self.assertEqual(imap_runtime.username, "campaign-imap@example.org")
|
)
|
||||||
self.assertEqual(imap_runtime.password, "campaign-imap-secret")
|
self.assertTrue(summary["smtp_available"])
|
||||||
|
self.assertTrue(summary["imap_available"])
|
||||||
|
self.assertNotIn("mock.smtp", repr(summary))
|
||||||
|
self.assertNotIn("profile-smtp", repr(summary))
|
||||||
|
self.assertNotIn("secret", repr(summary))
|
||||||
|
for name in (
|
||||||
|
"smtp_config_from_profile",
|
||||||
|
"imap_config_from_profile",
|
||||||
|
"send_email_bytes",
|
||||||
|
"send_email_message",
|
||||||
|
):
|
||||||
|
self.assertFalse(hasattr(integration, name), name)
|
||||||
|
|
||||||
def test_health_schema_and_dev_mailbox_gates(self) -> None:
|
def test_health_schema_and_dev_mailbox_gates(self) -> None:
|
||||||
public_health = self.client.get("/health")
|
public_health = self.client.get("/health")
|
||||||
@@ -2885,7 +2943,10 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertFalse(delta_payload["full"])
|
self.assertFalse(delta_payload["full"])
|
||||||
self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id])
|
self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id])
|
||||||
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
|
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
|
||||||
self.assertEqual(delta_payload["jobs"][0]["last_error"], "SMTP outcome needs reconciliation")
|
self.assertEqual(
|
||||||
|
delta_payload["jobs"][0]["last_error"],
|
||||||
|
"SMTP delivery outcome requires operator reconciliation.",
|
||||||
|
)
|
||||||
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
|
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
|
||||||
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
|
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
|
||||||
|
|
||||||
@@ -3095,28 +3156,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_campaign_create_validate_build_and_mock_send(self) -> None:
|
def test_campaign_create_validate_build_and_mock_send(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="API smoke delivery", include_imap=True)
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
|
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
|
||||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "smtp.example.invalid",
|
|
||||||
"port": 587,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
},
|
|
||||||
"imap": {
|
|
||||||
"enabled": True,
|
|
||||||
"host": "imap.example.invalid",
|
|
||||||
"port": 993,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "tls",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3188,20 +3234,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
|
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Managed attachments delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
|
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
|
||||||
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "smtp.example.invalid",
|
|
||||||
"port": 587,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3401,20 +3440,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
|
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Unlinked attachments delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
|
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
|
||||||
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "smtp.example.invalid",
|
|
||||||
"port": 587,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3522,20 +3554,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
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"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Frozen attachment delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"},
|
"campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "mock.smtp",
|
|
||||||
"port": 2525,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3689,11 +3714,12 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Bulk review delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
||||||
"fields": [], "global_values": {},
|
"fields": [], "global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Warning test", "text": "Body"},
|
"template": {"subject": "Warning test", "text": "Body"},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
@@ -3741,12 +3767,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Inactive recipients delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Hello", "text": "Active recipient only"},
|
"template": {"subject": "Hello", "text": "Active recipient only"},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
@@ -3801,12 +3828,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_recipient_address_fields_and_merge_modes(self) -> None:
|
def test_recipient_address_fields_and_merge_modes(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Recipient merge delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
|
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": [{"email": "global-from@example.org", "type": "to"}],
|
"from": [{"email": "global-from@example.org", "type": "to"}],
|
||||||
"allow_individual_from": True,
|
"allow_individual_from": True,
|
||||||
@@ -3867,12 +3895,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_multiple_from_addresses_are_rejected(self) -> None:
|
def test_multiple_from_addresses_are_rejected(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Multiple sender validation delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
|
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": [
|
"from": [
|
||||||
{"email": "first@example.org", "type": "to"},
|
{"email": "first@example.org", "type": "to"},
|
||||||
@@ -3892,12 +3921,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
|
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Duplicate ZIP validation delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
|
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "ZIP validation", "text": "Body"},
|
"template": {"subject": "ZIP validation", "text": "Body"},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
@@ -3937,6 +3967,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
|
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="ZIP attachment delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
|
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
|
||||||
@@ -3946,7 +3977,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
{"name": "global_zip_password", "type": "password", "required": True},
|
{"name": "global_zip_password", "type": "password", "required": True},
|
||||||
],
|
],
|
||||||
"global_values": {"global_zip_password": "campaign-secret"},
|
"global_values": {"global_zip_password": "campaign-secret"},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -4133,14 +4164,15 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None:
|
def test_execution_snapshot_detects_profile_drift_and_freezes_job_manifest(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
campaign_id, version_id = self._create_built_delivery_campaign(
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
||||||
headers,
|
headers,
|
||||||
external_id="snapshot-freeze",
|
external_id="snapshot-freeze",
|
||||||
)
|
)
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion
|
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
|
||||||
|
from govoplan_mail.backend.db.models import MailServerProfile
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
version = session.get(CampaignVersion, version_id)
|
version = session.get(CampaignVersion, version_id)
|
||||||
@@ -4148,23 +4180,24 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
assert version is not None
|
assert version is not None
|
||||||
snapshot = version.execution_snapshot
|
snapshot = version.execution_snapshot
|
||||||
self.assertIsInstance(snapshot, dict)
|
self.assertIsInstance(snapshot, dict)
|
||||||
self.assertEqual(snapshot["snapshot_version"], "3")
|
self.assertEqual(snapshot["snapshot_version"], "5")
|
||||||
self.assertEqual(snapshot["job_count"], 1)
|
self.assertEqual(snapshot["job_count"], 1)
|
||||||
self.assertEqual(snapshot["queueable_job_count"], 1)
|
self.assertEqual(snapshot["queueable_job_count"], 1)
|
||||||
self.assertTrue(snapshot["job_manifest_sha256"])
|
self.assertTrue(snapshot["job_manifest_sha256"])
|
||||||
self.assertTrue(snapshot["smtp_config_fingerprint"])
|
self.assertTrue(snapshot["smtp_transport_revision"])
|
||||||
self.assertIsNone(snapshot["smtp"].get("password"))
|
self.assertNotIn("smtp", snapshot)
|
||||||
|
self.assertNotIn("imap", snapshot)
|
||||||
self.assertTrue(version.execution_snapshot_hash)
|
self.assertTrue(version.execution_snapshot_hash)
|
||||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||||
self.assertTrue(job.eml_sha256)
|
self.assertTrue(job.eml_sha256)
|
||||||
self.assertTrue(job.message_id_header)
|
self.assertTrue(job.message_id_header)
|
||||||
|
|
||||||
# Simulate accidental/config-drift mutation after the build. Sending
|
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
|
||||||
# must still use the immutable mock SMTP snapshot, not raw_json.
|
self.assertIsNotNone(profile)
|
||||||
mutated = json.loads(json.dumps(version.raw_json))
|
assert profile is not None
|
||||||
mutated["server"]["smtp"]["host"] = "smtp.example.invalid"
|
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"}
|
||||||
version.raw_json = mutated
|
profile.smtp_transport_revision = "manually-rotated-transport-revision"
|
||||||
session.add(version)
|
session.add(profile)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
sent = self.client.post(
|
sent = self.client.post(
|
||||||
@@ -4179,12 +4212,17 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(sent.status_code, 200, sent.text)
|
self.assertEqual(sent.status_code, 200, sent.text)
|
||||||
self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text)
|
self.assertEqual(sent.json()["result"]["sent_count"], 0)
|
||||||
self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text)
|
self.assertEqual(sent.json()["result"]["failed_count"], 1)
|
||||||
|
self.assertIn("changed after this campaign was built", sent.json()["result"]["results"][0]["message"])
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||||
self.assertEqual(job.send_status, "smtp_accepted")
|
self.assertEqual(job.send_status, "failed_permanent")
|
||||||
|
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
|
||||||
|
self.assertIn("changed after this campaign was built", attempt.error_message)
|
||||||
|
self.assertNotIn("smtp.example.invalid", attempt.error_message)
|
||||||
|
self.assertIsNone(attempt.smtp_response)
|
||||||
|
|
||||||
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
@@ -4256,21 +4294,14 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Partial refusal delivery")
|
||||||
|
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
|
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "mock.smtp",
|
|
||||||
"port": 2525,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -5761,6 +5792,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
|
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
|
||||||
owner_headers, _ = self._login()
|
owner_headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(owner_headers, name="ACL campaign delivery")
|
||||||
access_role = self._create_role(
|
access_role = self._create_role(
|
||||||
owner_headers,
|
owner_headers,
|
||||||
slug="campaign-collaborator",
|
slug="campaign-collaborator",
|
||||||
@@ -5786,7 +5818,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
|
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
|
||||||
"template": {"subject": "ACL", "text": "ACL"},
|
"template": {"subject": "ACL", "text": "ACL"},
|
||||||
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
||||||
@@ -5984,7 +6016,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
json={"campaign_json": inline_json},
|
json={"campaign_json": inline_json},
|
||||||
)
|
)
|
||||||
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
|
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
|
||||||
self.assertIn("Campaign-local inline mail settings", blocked_inline.json()["detail"])
|
self.assertIn("may only reference", blocked_inline.json()["detail"])
|
||||||
|
|
||||||
reusable_json = detail.json()["raw_json"]
|
reusable_json = detail.json()["raw_json"]
|
||||||
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
|
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
|
||||||
@@ -6105,7 +6137,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||||
self.assertEqual(blocked.status_code, 422, blocked.text)
|
self.assertEqual(blocked.status_code, 422, blocked.text)
|
||||||
self.assertIn("locked", blocked.json()["detail"])
|
self.assertIn("may only reference", blocked.json()["detail"])
|
||||||
|
|
||||||
campaign_json["server"] = {"mail_profile_id": profile_id}
|
campaign_json["server"] = {"mail_profile_id": profile_id}
|
||||||
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
|
for path in (
|
||||||
|
"/api/v1/platform/status",
|
||||||
|
"/api/v1/platform/modules",
|
||||||
|
"/api/v1/platform/public-modules",
|
||||||
|
):
|
||||||
first = client.get(path)
|
first = client.get(path)
|
||||||
self.assertEqual(200, first.status_code, first.text)
|
self.assertEqual(200, first.status_code, first.text)
|
||||||
etag = first.headers.get("etag")
|
etag = first.headers.get("etag")
|
||||||
|
|||||||
85
tests/test_documentation_topic_contract.py
Normal file
85
tests/test_documentation_topic_contract.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationTopic,
|
||||||
|
ModuleManifest,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_topic(
|
||||||
|
*,
|
||||||
|
conditions: tuple[DocumentationCondition, ...],
|
||||||
|
documentation_types: tuple[str, ...] = ("user",),
|
||||||
|
kind: str = "workflow",
|
||||||
|
) -> DocumentationTopic:
|
||||||
|
return DocumentationTopic(
|
||||||
|
id="example.workflow.task",
|
||||||
|
title="Complete a task",
|
||||||
|
summary="Complete the example task.",
|
||||||
|
documentation_types=documentation_types, # type: ignore[arg-type]
|
||||||
|
conditions=conditions,
|
||||||
|
metadata={"kind": kind},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentationTopicContractTests(unittest.TestCase):
|
||||||
|
def test_user_workflow_requires_scope_conditions(self) -> None:
|
||||||
|
topic = workflow_topic(conditions=())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
user_workflow_scope_condition_issues(topic),
|
||||||
|
("user workflow topics must declare at least one scope-conditioned alternative",),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RegistryError, "scope-conditioned alternative"):
|
||||||
|
registry_for(topic).validate()
|
||||||
|
|
||||||
|
def test_every_condition_alternative_must_be_scope_conditioned(self) -> None:
|
||||||
|
topic = workflow_topic(
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||||
|
DocumentationCondition(required_modules=("example",)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, r"unscoped alternative\(s\): 2"):
|
||||||
|
registry_for(topic).validate()
|
||||||
|
|
||||||
|
def test_scoped_user_workflow_and_non_user_topics_are_accepted(self) -> None:
|
||||||
|
scoped = workflow_topic(
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||||
|
DocumentationCondition(any_scopes=("example:item:write", "example:item:admin")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
admin_workflow = workflow_topic(
|
||||||
|
conditions=(),
|
||||||
|
documentation_types=("admin",),
|
||||||
|
)
|
||||||
|
user_reference = workflow_topic(conditions=(), kind="reference")
|
||||||
|
|
||||||
|
self.assertEqual(user_workflow_scope_condition_issues(scoped), ())
|
||||||
|
self.assertEqual(user_workflow_scope_condition_issues(admin_workflow), ())
|
||||||
|
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||||
|
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.0.0",
|
||||||
|
documentation=topics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -21,6 +21,18 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||||
|
|
||||||
|
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
|
||||||
|
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
|
||||||
|
self.assertEqual(
|
||||||
|
Settings(
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS="14"
|
||||||
|
).scheduling_cancellation_notice_days,
|
||||||
|
14,
|
||||||
|
)
|
||||||
|
for invalid in ("0", "91"):
|
||||||
|
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||||
|
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
|
||||||
|
|
||||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
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")
|
||||||
|
|
||||||
@@ -113,6 +125,7 @@ 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("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||||
|
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||||
@@ -122,6 +135,7 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
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)
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||||
|
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'
|
|||||||
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
|
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
|
||||||
os.environ.setdefault("CELERY_ENABLED", "false")
|
os.environ.setdefault("CELERY_ENABLED", "false")
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
@@ -99,9 +99,9 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
sign_module_package_catalog,
|
sign_module_package_catalog,
|
||||||
validate_module_package_catalog,
|
validate_module_package_catalog,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
||||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
|
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -112,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
|
|||||||
from govoplan_core.security.permissions import scope_grants
|
from govoplan_core.security.permissions import scope_grants
|
||||||
from govoplan_core.server.app import create_app
|
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.server.platform import create_platform_router
|
||||||
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.server.route_validation import RouteCollisionError
|
from govoplan_core.server.route_validation import RouteCollisionError
|
||||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||||
@@ -338,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
||||||
if manifest.frontend.package_name is not None:
|
if manifest.frontend.package_name is not None:
|
||||||
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
||||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes):
|
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
|
||||||
self.assertTrue(route.path.startswith("/"))
|
self.assertTrue(route.path.startswith("/"))
|
||||||
self.assertTrue(route.component.strip())
|
self.assertTrue(route.component.strip())
|
||||||
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
||||||
@@ -359,6 +360,88 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
|
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
|
||||||
registry.validate()
|
registry.validate()
|
||||||
|
|
||||||
|
def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None:
|
||||||
|
for template, message in (
|
||||||
|
(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="invalid-system-default",
|
||||||
|
name="Invalid",
|
||||||
|
description="Invalid system default.",
|
||||||
|
permissions=("system:*",),
|
||||||
|
level="system",
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
"must be tenant-level",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="invalid-unmanaged-default",
|
||||||
|
name="Invalid",
|
||||||
|
description="Invalid unmanaged default.",
|
||||||
|
permissions=("tenant:*",),
|
||||||
|
managed=False,
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
"must be managed",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.subTest(template=template.slug):
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
role_templates=(template,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RegistryError, message):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="unsafe-default",
|
||||||
|
name="Unsafe default",
|
||||||
|
description="Must not grant broad authority automatically.",
|
||||||
|
permissions=("tenant:*",),
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "must use explicit permissions"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
for module_id in ("first", "second"):
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id=module_id,
|
||||||
|
name=module_id.title(),
|
||||||
|
version="test",
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="shared-reader",
|
||||||
|
name="Shared reader",
|
||||||
|
description="A colliding role template.",
|
||||||
|
permissions=(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
|
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
registry.register(ModuleManifest(
|
registry.register(ModuleManifest(
|
||||||
@@ -375,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
||||||
registry.validate()
|
registry.validate()
|
||||||
|
|
||||||
|
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
package_name="@govoplan/example-webui",
|
||||||
|
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/example/public/:token",
|
||||||
|
component="ExamplePublicPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="private",
|
||||||
|
name="Private",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="private",
|
||||||
|
package_name="@govoplan/private-webui",
|
||||||
|
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/v1/platform/public-modules")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
|
||||||
|
public_module = response.json()["modules"][0]
|
||||||
|
self.assertNotIn("dependencies", public_module)
|
||||||
|
self.assertNotIn("nav", public_module["frontend"])
|
||||||
|
self.assertNotIn("routes", public_module["frontend"])
|
||||||
|
self.assertEqual(
|
||||||
|
["/example/public/:token"],
|
||||||
|
[item["path"] for item in public_module["frontend"]["public_routes"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
for module_id in ("first", "second"):
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id=module_id,
|
||||||
|
name=module_id.title(),
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=module_id,
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/shared/public/:token",
|
||||||
|
component=f"{module_id.title()}PublicPage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
||||||
first = APIRouter()
|
first = APIRouter()
|
||||||
second = APIRouter()
|
second = APIRouter()
|
||||||
@@ -3130,18 +3281,30 @@ finally:
|
|||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["files"]["requires_interfaces"])
|
}, modules["files"]["requires_interfaces"])
|
||||||
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
||||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
|
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
||||||
|
self.assertIn({
|
||||||
|
"name": "campaigns.access",
|
||||||
|
"optional": True,
|
||||||
|
"version_min": "0.1.0",
|
||||||
|
"version_max_exclusive": "0.2.0",
|
||||||
|
}, modules["mail"]["requires_interfaces"])
|
||||||
self.assertIn({
|
self.assertIn({
|
||||||
"name": "files.campaign_attachments",
|
"name": "files.campaign_attachments",
|
||||||
"optional": True,
|
"optional": True,
|
||||||
"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.assertIn({
|
||||||
|
"name": "mail.campaign_delivery",
|
||||||
|
"optional": True,
|
||||||
|
"version_min": "0.2.0",
|
||||||
|
"version_max_exclusive": "0.3.0",
|
||||||
|
}, modules["campaigns"]["requires_interfaces"])
|
||||||
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
|
self.assertEqual(["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.8", modules["files"]["version"])
|
self.assertEqual("0.1.9", modules["files"]["version"])
|
||||||
self.assertIn("@v0.1.8", modules["files"]["python_ref"])
|
self.assertIn("@v0.1.9", 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))
|
||||||
|
|||||||
99
tests/test_people_search_contract.py
Normal file
99
tests/test_people_search_contract.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.people import (
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||||
|
PeopleSearchGroup,
|
||||||
|
PersonSearchCandidate,
|
||||||
|
people_search_providers,
|
||||||
|
person_selection_key,
|
||||||
|
search_visible_people,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class FakePeopleSearchProvider:
|
||||||
|
def __init__(self, *groups: PeopleSearchGroup) -> None:
|
||||||
|
self.groups = groups
|
||||||
|
self.calls: list[tuple[object, object, str, int]] = []
|
||||||
|
|
||||||
|
def search_people(self, session: object, principal: object, *, query: str, limit: int = 25):
|
||||||
|
self.calls.append((session, principal, query, limit))
|
||||||
|
return self.groups
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleSearchContractTests(unittest.TestCase):
|
||||||
|
def test_collects_optional_providers_in_stable_group_order(self) -> None:
|
||||||
|
duplicate = PersonSearchCandidate(
|
||||||
|
selection_key="account:1",
|
||||||
|
kind="account",
|
||||||
|
reference_id="1",
|
||||||
|
display_name="Ada Lovelace",
|
||||||
|
)
|
||||||
|
access = FakePeopleSearchProvider(PeopleSearchGroup(key="accounts", label="Accounts", candidates=(duplicate, duplicate)))
|
||||||
|
addresses = FakePeopleSearchProvider(
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key="contacts",
|
||||||
|
label="Contacts",
|
||||||
|
candidates=(
|
||||||
|
PersonSearchCandidate(
|
||||||
|
selection_key="contact:2:ada@example.test",
|
||||||
|
kind="contact",
|
||||||
|
reference_id="2",
|
||||||
|
display_name="Ada Lovelace",
|
||||||
|
email="ada@example.test",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="1.0.0",
|
||||||
|
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: access},
|
||||||
|
))
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="addresses",
|
||||||
|
name="Addresses",
|
||||||
|
version="1.0.0",
|
||||||
|
capability_factories={CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda _context: addresses},
|
||||||
|
))
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
|
||||||
|
session = object()
|
||||||
|
principal = object()
|
||||||
|
groups = search_visible_people(registry, session, principal, query=" ada ", limit=200)
|
||||||
|
|
||||||
|
self.assertEqual([group.key for group in groups], ["accounts", "contacts"])
|
||||||
|
self.assertEqual([len(group.candidates) for group in groups], [1, 1])
|
||||||
|
self.assertEqual(access.calls, [(session, principal, "ada", 100)])
|
||||||
|
self.assertEqual(addresses.calls, [(session, principal, "ada", 100)])
|
||||||
|
self.assertEqual(people_search_providers(None), ())
|
||||||
|
|
||||||
|
def test_omits_empty_groups_and_ignores_unrelated_capabilities(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(ModuleManifest(
|
||||||
|
id="access",
|
||||||
|
name="Access",
|
||||||
|
version="1.0.0",
|
||||||
|
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: object()},
|
||||||
|
))
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
|
||||||
|
self.assertEqual(search_visible_people(registry, object(), object(), query="ada"), ())
|
||||||
|
|
||||||
|
def test_selection_key_is_stable_and_validated(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
person_selection_key("Account", "account-1", email=" Ada@Example.Test "),
|
||||||
|
"account:account-1:ada@example.test",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
person_selection_key("", "account-1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,7 +3,36 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
|
from govoplan_core.core.poll import (
|
||||||
|
CAPABILITY_POLL_SCHEDULING,
|
||||||
|
PollAnswerRef,
|
||||||
|
PollOptionUpdateCommand,
|
||||||
|
PollResponseRef,
|
||||||
|
PollResponseRetirementCommand,
|
||||||
|
PollResponseRetirementProvider,
|
||||||
|
poll_response_retirement_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RetirementProvider:
|
||||||
|
def retire_responses(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, capability: object | None) -> None:
|
||||||
|
self.capability_value = capability
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLL_SCHEDULING
|
||||||
|
and self.capability_value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.capability_value
|
||||||
|
|
||||||
|
|
||||||
class PollContractTests(unittest.TestCase):
|
class PollContractTests(unittest.TestCase):
|
||||||
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(command.label, "Tuesday 10:00")
|
self.assertEqual(command.label, "Tuesday 10:00")
|
||||||
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
||||||
|
|
||||||
|
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
|
||||||
|
command = PollResponseRetirementCommand(
|
||||||
|
respondent_ids=("account-1", "alice@example.test"),
|
||||||
|
invitation_id="invitation-1",
|
||||||
|
reason="scheduling_participant_removed",
|
||||||
|
idempotency_key="scheduling:request-1:participant-1:removed",
|
||||||
|
metadata={"source_module": "scheduling"},
|
||||||
|
)
|
||||||
|
provider = _RetirementProvider()
|
||||||
|
|
||||||
|
self.assertEqual(command.respondent_ids[0], "account-1")
|
||||||
|
self.assertIsInstance(provider, PollResponseRetirementProvider)
|
||||||
|
self.assertIs(
|
||||||
|
poll_response_retirement_provider(_Registry(provider)),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
|
||||||
|
self.assertIsNone(poll_response_retirement_provider(None))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
84
tests/test_poll_participation_contract.py
Normal file
84
tests/test_poll_participation_contract.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.poll_participation import (
|
||||||
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||||
|
PollParticipationGatewayProvider,
|
||||||
|
PollParticipationPolicy,
|
||||||
|
participation_token_fingerprint,
|
||||||
|
poll_participation_gateway_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _CompleteGateway:
|
||||||
|
def create_governed_invitation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def resolve_participation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def submit_governed_response(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def resolve_authenticated_participation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def submit_authenticated_response(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def add_option(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def remove_option(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def revoke_invitation(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update_invitation_expiry(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, capability: object | None) -> None:
|
||||||
|
self.capability_value = capability
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||||
|
and self.capability_value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.capability_value
|
||||||
|
|
||||||
|
|
||||||
|
class PollParticipationContractTests(unittest.TestCase):
|
||||||
|
def test_capability_name_and_policy_defaults_remain_stable(self) -> None:
|
||||||
|
policy = PollParticipationPolicy()
|
||||||
|
|
||||||
|
self.assertEqual("poll.participation_gateway", CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||||
|
self.assertEqual(1, policy.version)
|
||||||
|
self.assertTrue(policy.allow_maybe)
|
||||||
|
|
||||||
|
def test_token_fingerprint_is_deterministic_and_non_reversible(self) -> None:
|
||||||
|
fingerprint = participation_token_fingerprint("secret-token")
|
||||||
|
|
||||||
|
self.assertEqual(fingerprint, participation_token_fingerprint("secret-token"))
|
||||||
|
self.assertEqual(64, len(fingerprint))
|
||||||
|
self.assertNotIn("secret-token", fingerprint)
|
||||||
|
|
||||||
|
def test_registry_lookup_accepts_only_the_complete_structural_contract(self) -> None:
|
||||||
|
provider = _CompleteGateway()
|
||||||
|
|
||||||
|
self.assertIsInstance(provider, PollParticipationGatewayProvider)
|
||||||
|
self.assertIs(provider, poll_participation_gateway_provider(_Registry(provider)))
|
||||||
|
self.assertIsNone(poll_participation_gateway_provider(_Registry(object())))
|
||||||
|
self.assertIsNone(poll_participation_gateway_provider(None))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
130
tests/test_throttling.py
Normal file
130
tests/test_throttling.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from govoplan_core.core.throttling import (
|
||||||
|
FixedWindowBucket,
|
||||||
|
FixedWindowThrottle,
|
||||||
|
InMemoryFixedWindowStore,
|
||||||
|
ResilientFixedWindowStore,
|
||||||
|
ThrottleDimension,
|
||||||
|
build_fixed_window_throttle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.value = 100.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.values: dict[str, int] = {}
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
return FixedWindowBucket(self.values.get(key, 0), 60)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
del window_seconds
|
||||||
|
self.values[key] = self.values.get(key, 0) + 1
|
||||||
|
return FixedWindowBucket(self.values[key], 60)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self.values.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingStore(_RecordingStore):
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
del key
|
||||||
|
raise RedisError("offline")
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
del key, window_seconds
|
||||||
|
raise RedisError("offline")
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
del key
|
||||||
|
raise RedisError("offline")
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowThrottleTests(unittest.TestCase):
|
||||||
|
def test_blocks_at_limit_and_expires_without_sleeping(self) -> None:
|
||||||
|
clock = _Clock()
|
||||||
|
store = InMemoryFixedWindowStore(clock=clock)
|
||||||
|
throttle = FixedWindowThrottle(store, window_seconds=60)
|
||||||
|
dimensions = (ThrottleDimension("password", "secret-subject", 3),)
|
||||||
|
|
||||||
|
self.assertTrue(throttle.check(dimensions).allowed)
|
||||||
|
self.assertTrue(throttle.record(dimensions).allowed)
|
||||||
|
self.assertTrue(throttle.record(dimensions).allowed)
|
||||||
|
blocked = throttle.record(dimensions)
|
||||||
|
self.assertFalse(blocked.allowed)
|
||||||
|
self.assertEqual(blocked.retry_after_seconds, 60)
|
||||||
|
self.assertFalse(throttle.check(dimensions).allowed)
|
||||||
|
|
||||||
|
clock.value += 61
|
||||||
|
self.assertTrue(throttle.check(dimensions).allowed)
|
||||||
|
|
||||||
|
def test_subject_is_hashed_and_success_can_reset_it(self) -> None:
|
||||||
|
store = _RecordingStore()
|
||||||
|
throttle = FixedWindowThrottle(store, window_seconds=60)
|
||||||
|
dimensions = (ThrottleDimension("poll-participation-password", "tenant:token-secret", 2),)
|
||||||
|
|
||||||
|
throttle.record(dimensions)
|
||||||
|
|
||||||
|
self.assertEqual(len(store.values), 1)
|
||||||
|
stored_key = next(iter(store.values))
|
||||||
|
self.assertNotIn("tenant:token-secret", stored_key)
|
||||||
|
self.assertIn(":poll-participation-password:", stored_key)
|
||||||
|
throttle.reset(dimensions)
|
||||||
|
self.assertEqual(store.values, {})
|
||||||
|
|
||||||
|
def test_resilient_store_uses_bounded_local_fallback(self) -> None:
|
||||||
|
clock = _Clock()
|
||||||
|
fallback = InMemoryFixedWindowStore(max_entries=2, clock=clock)
|
||||||
|
store = ResilientFixedWindowStore(
|
||||||
|
_FailingStore(),
|
||||||
|
fallback,
|
||||||
|
retry_seconds=30,
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = store.increment("first", window_seconds=60)
|
||||||
|
second = store.increment("first", window_seconds=60)
|
||||||
|
store.increment("second", window_seconds=60)
|
||||||
|
store.increment("third", window_seconds=60)
|
||||||
|
|
||||||
|
self.assertEqual(first.count, 1)
|
||||||
|
self.assertEqual(second.count, 2)
|
||||||
|
self.assertEqual(fallback.read("first").count, 0)
|
||||||
|
self.assertEqual(fallback.read("third").count, 1)
|
||||||
|
|
||||||
|
def test_builder_operates_without_redis(self) -> None:
|
||||||
|
throttle = build_fixed_window_throttle(
|
||||||
|
redis_url=None,
|
||||||
|
window_seconds=30,
|
||||||
|
max_local_entries=10,
|
||||||
|
)
|
||||||
|
dimensions = (ThrottleDimension("test", "subject", 1),)
|
||||||
|
|
||||||
|
self.assertFalse(throttle.record(dimensions).allowed)
|
||||||
|
self.assertFalse(throttle.check(dimensions).allowed)
|
||||||
|
|
||||||
|
def test_rejects_invalid_dimensions(self) -> None:
|
||||||
|
throttle = FixedWindowThrottle(_RecordingStore(), window_seconds=60)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "At least one"):
|
||||||
|
throttle.check(())
|
||||||
|
with self.assertRaisesRegex(ValueError, "positive"):
|
||||||
|
throttle.check((ThrottleDimension("test", "subject", 0),))
|
||||||
|
with self.assertRaisesRegex(ValueError, "namespaces"):
|
||||||
|
throttle.check((ThrottleDimension("Invalid namespace", "subject", 1),))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
143
tests/test_wheel_runtime.py
Normal file
143
tests/test_wheel_runtime.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import sysconfig
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class WheelRuntimeTests(unittest.TestCase):
|
||||||
|
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
|
||||||
|
repository_root = Path(__file__).resolve().parents[1]
|
||||||
|
access_repository = repository_root.parent / "govoplan-access"
|
||||||
|
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
|
||||||
|
temporary_root = Path(directory)
|
||||||
|
wheel_directory = temporary_root / "wheels"
|
||||||
|
wheel_directory.mkdir()
|
||||||
|
self._run(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"wheel",
|
||||||
|
"--no-deps",
|
||||||
|
"--wheel-dir",
|
||||||
|
str(wheel_directory),
|
||||||
|
str(repository_root),
|
||||||
|
cwd=temporary_root,
|
||||||
|
)
|
||||||
|
self._run(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"wheel",
|
||||||
|
"--no-deps",
|
||||||
|
"--wheel-dir",
|
||||||
|
str(wheel_directory),
|
||||||
|
str(access_repository),
|
||||||
|
cwd=temporary_root,
|
||||||
|
)
|
||||||
|
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
|
||||||
|
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
|
||||||
|
self.assertEqual(1, len(core_wheels))
|
||||||
|
self.assertEqual(1, len(access_wheels))
|
||||||
|
|
||||||
|
virtual_environment = temporary_root / "venv"
|
||||||
|
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
|
||||||
|
venv_python = virtual_environment / "bin" / "python"
|
||||||
|
self._run(
|
||||||
|
str(venv_python),
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--no-deps",
|
||||||
|
str(core_wheels[0]),
|
||||||
|
str(access_wheels[0]),
|
||||||
|
cwd=temporary_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
database_path = temporary_root / "wheel-runtime.db"
|
||||||
|
probe = self._run(
|
||||||
|
str(venv_python),
|
||||||
|
"-c",
|
||||||
|
self._probe_script(),
|
||||||
|
str(database_path),
|
||||||
|
cwd=temporary_root,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
# Reuse only third-party test dependencies from the parent
|
||||||
|
# environment. Editable .pth files are not processed from
|
||||||
|
# PYTHONPATH, so Core itself can only come from the wheel.
|
||||||
|
"PYTHONPATH": sysconfig.get_path("purelib"),
|
||||||
|
"GOVOPLAN_CORE_SOURCE_ROOT": "",
|
||||||
|
"GOVOPLAN_ENABLED_MODULES": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = json.loads(probe.stdout)
|
||||||
|
installed_package = Path(result["package_file"])
|
||||||
|
runtime_root = Path(result["runtime_root"])
|
||||||
|
self.assertTrue(installed_package.is_relative_to(virtual_environment))
|
||||||
|
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
|
||||||
|
self.assertNotEqual(repository_root, runtime_root)
|
||||||
|
self.assertTrue((runtime_root / "alembic.ini").is_file())
|
||||||
|
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
|
||||||
|
self.assertIn("4f2a9c8e7b6d", result["heads"])
|
||||||
|
self.assertIn("core_scopes", result["tables"])
|
||||||
|
self.assertIn("core_system_settings", result["tables"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Command failed ({result.returncode}): {' '.join(command)}\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _probe_script() -> str:
|
||||||
|
return """
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
import govoplan_core
|
||||||
|
from govoplan_core.db.migrations import _repo_root, alembic_config
|
||||||
|
|
||||||
|
database_url = f"sqlite:///{Path(sys.argv[1])}"
|
||||||
|
config = alembic_config(database_url=database_url, enabled_modules=())
|
||||||
|
command.upgrade(config, "heads")
|
||||||
|
script = ScriptDirectory.from_config(config)
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
tables = sorted(inspect(connection).get_table_names())
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
print(json.dumps({
|
||||||
|
"package_file": govoplan_core.__file__,
|
||||||
|
"runtime_root": str(_repo_root()),
|
||||||
|
"heads": sorted(script.get_heads()),
|
||||||
|
"tables": tables,
|
||||||
|
}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"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/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/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
|
||||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
||||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
|
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
||||||
@@ -787,13 +787,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/campaign-webui": {
|
"node_modules/@govoplan/campaign-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.10",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#735e874bd03c55c626347f5356301fe221145b98",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.12",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -26,9 +26,11 @@
|
|||||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||||
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
|
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
|
||||||
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
||||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||||
|
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
|
||||||
|
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
|
||||||
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
||||||
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -31,7 +31,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/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
|
||||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
"@govoplan/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",
|
||||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
|
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
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 { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||||
import AppShell from "./layout/AppShell";
|
import 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";
|
||||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||||
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||||
@@ -30,7 +30,9 @@ export default function App() {
|
|||||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||||
|
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
|
const [remotePublicWebModules, setRemotePublicWebModules] = 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 [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);
|
||||||
@@ -38,9 +40,13 @@ export default function App() {
|
|||||||
|
|
||||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||||
|
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||||
|
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||||
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
|
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||||
|
const contextModules = auth ? webModules : publicWebModules;
|
||||||
|
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||||
|
|
||||||
function updateSettings(next: ApiSettings) {
|
function updateSettings(next: ApiSettings) {
|
||||||
@@ -119,6 +125,14 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [settings.apiBaseUrl]);
|
}, [settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchPlatformPublicModules(settings).
|
||||||
|
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
|
||||||
|
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setCheckingSession(true);
|
setCheckingSession(true);
|
||||||
@@ -233,6 +247,18 @@ export default function App() {
|
|||||||
return () => {cancelled = true;};
|
return () => {cancelled = true;};
|
||||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!platformPublicModules?.length) {
|
||||||
|
setRemotePublicWebModules([]);
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}
|
||||||
|
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
|
||||||
|
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
|
||||||
|
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [platformPublicModules, localPublicWebModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
|
|
||||||
@@ -284,7 +310,7 @@ export default function App() {
|
|||||||
if (checkingSession) {
|
if (checkingSession) {
|
||||||
return (
|
return (
|
||||||
<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={publicWebModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<div className="public-landing">
|
<div className="public-landing">
|
||||||
@@ -304,10 +330,21 @@ export default function App() {
|
|||||||
if (!auth) {
|
if (!auth) {
|
||||||
return (
|
return (
|
||||||
<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={publicWebModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||||
|
<Routes>
|
||||||
|
{publicRoutes.map((route) =>
|
||||||
|
<Route
|
||||||
|
key={`public:${route.path}`}
|
||||||
|
path={route.path}
|
||||||
|
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
@@ -342,6 +379,13 @@ export default function App() {
|
|||||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||||
|
{publicRoutes.map((route) =>
|
||||||
|
<Route
|
||||||
|
key={`public:${route.path}`}
|
||||||
|
path={route.path}
|
||||||
|
element={route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{moduleRoutes.map((route) =>
|
{moduleRoutes.map((route) =>
|
||||||
<Route
|
<Route
|
||||||
key={route.path}
|
key={route.path}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ApiSettings } from "../types";
|
import type { ApiSettings } from "../types";
|
||||||
import type { PlatformModuleInfo } from "../types";
|
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||||
|
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
|
||||||
|
|
||||||
export type PlatformStatusResponse = {
|
export type PlatformStatusResponse = {
|
||||||
maintenance_mode: {
|
maintenance_mode: {
|
||||||
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
|
|||||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
|
||||||
|
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
||||||
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ export type MessageDisplayAttachment = {
|
|||||||
|
|
||||||
type MessageDisplayBodyMode = "text" | "html";
|
type MessageDisplayBodyMode = "text" | "html";
|
||||||
|
|
||||||
|
const MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY = [
|
||||||
|
"default-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"font-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"img-src data: cid:",
|
||||||
|
"manifest-src 'none'",
|
||||||
|
"media-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"prefetch-src 'none'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'unsafe-inline'",
|
||||||
|
"worker-src 'none'"
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const URL_ATTRIBUTE_PATTERN = /(\s)(src|href|xlink:href|poster|background|action|formaction|cite|longdesc|ping|data)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
|
||||||
|
const SOURCE_SET_ATTRIBUTE_PATTERN = /\s(?:srcset|imagesrcset)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||||
|
const EVENT_HANDLER_ATTRIBUTE_PATTERN = /\son[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||||
|
const CSS_IMPORT_PATTERN = /@import\s+(?:url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)|"[^"]*"|'[^']*')[^;]*;?/gi;
|
||||||
|
const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
|
||||||
|
const UNSAFE_DOCUMENT_ELEMENT_PATTERN = /<\s*(?:base|link|meta)\b[^>]*>/gi;
|
||||||
|
const SCRIPT_BLOCK_PATTERN = /<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi;
|
||||||
|
const SCRIPT_TAG_PATTERN = /<\s*\/?\s*script\b[^>]*>/gi;
|
||||||
|
|
||||||
type MessageDisplayPanelProps = {
|
type MessageDisplayPanelProps = {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
fields?: MessageDisplayField[];
|
fields?: MessageDisplayField[];
|
||||||
@@ -71,6 +97,7 @@ export default function MessageDisplayPanel({
|
|||||||
const showBodySwitch = hasHtml && hasText;
|
const showBodySwitch = hasHtml && hasText;
|
||||||
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
||||||
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
||||||
|
const safeHtmlDocument = buildSafeMessageHtmlDocument(bodyHtml || htmlBodyFallback);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-display-panel">
|
<div className="message-display-panel">
|
||||||
@@ -104,7 +131,12 @@ export default function MessageDisplayPanel({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{activeBodyMode === "html" ?
|
{activeBodyMode === "html" ?
|
||||||
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
|
<iframe
|
||||||
|
className="message-display-html-frame"
|
||||||
|
title="i18n:govoplan-core.rendered_html_message_body.8638b634"
|
||||||
|
sandbox=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
srcDoc={safeHtmlDocument} /> :
|
||||||
|
|
||||||
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
||||||
}
|
}
|
||||||
@@ -278,3 +310,67 @@ function formatBytes(value?: number | null): string {
|
|||||||
function stripHtml(value: string): string {
|
function stripHtml(value: string): string {
|
||||||
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the isolated document used for untrusted email HTML previews.
|
||||||
|
*
|
||||||
|
* Attribute neutralization keeps common clients from even attempting remote
|
||||||
|
* resource resolution. The document-local CSP is the authoritative backstop
|
||||||
|
* for malformed markup and parser edge cases: no network-capable resource is
|
||||||
|
* permitted, while inline presentation plus embedded CID/raster data images
|
||||||
|
* remain usable inside the already scriptless sandbox.
|
||||||
|
*/
|
||||||
|
export function buildSafeMessageHtmlDocument(value: string): string {
|
||||||
|
const neutralized = neutralizeRemoteMessageContent(value);
|
||||||
|
return [
|
||||||
|
"<!doctype html>",
|
||||||
|
"<html>",
|
||||||
|
"<head>",
|
||||||
|
'<meta charset="utf-8">',
|
||||||
|
`<meta http-equiv="Content-Security-Policy" content="${MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY}">`,
|
||||||
|
'<meta name="referrer" content="no-referrer">',
|
||||||
|
"</head>",
|
||||||
|
`<body>${neutralized}</body>`,
|
||||||
|
"</html>"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function neutralizeRemoteMessageContent(value: string): string {
|
||||||
|
return value.
|
||||||
|
replace(SCRIPT_BLOCK_PATTERN, "").
|
||||||
|
replace(SCRIPT_TAG_PATTERN, "").
|
||||||
|
replace(UNSAFE_DOCUMENT_ELEMENT_PATTERN, "").
|
||||||
|
replace(EVENT_HANDLER_ATTRIBUTE_PATTERN, "").
|
||||||
|
replace(SOURCE_SET_ATTRIBUTE_PATTERN, "").
|
||||||
|
replace(URL_ATTRIBUTE_PATTERN, (_match, spacing: string, attributeName: string, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||||
|
const url = doubleQuoted ?? singleQuoted ?? unquoted ?? "";
|
||||||
|
if (!messagePreviewUrlAllowed(attributeName, url)) return "";
|
||||||
|
return `${spacing}${attributeName.toLowerCase()}="${escapeHtmlAttribute(url.trim())}"`;
|
||||||
|
}).
|
||||||
|
replace(CSS_IMPORT_PATTERN, "").
|
||||||
|
replace(CSS_URL_PATTERN, (_match, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||||
|
const url = (doubleQuoted ?? singleQuoted ?? unquoted ?? "").trim();
|
||||||
|
return isSafeEmbeddedMessageResource(url) ? `url("${escapeCssString(url)}")` : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePreviewUrlAllowed(attributeName: string, value: string): boolean {
|
||||||
|
const attribute = attributeName.toLowerCase();
|
||||||
|
if (["action", "formaction", "ping"].includes(attribute)) return false;
|
||||||
|
const url = value.trim();
|
||||||
|
if ((attribute === "href" || attribute === "xlink:href") && /^#[^\s]*$/.test(url)) return true;
|
||||||
|
return isSafeEmbeddedMessageResource(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeEmbeddedMessageResource(value: string): boolean {
|
||||||
|
if (/^cid:[^\s"'<>]+$/i.test(value)) return true;
|
||||||
|
return /^data:image\/(?:png|gif|jpe?g|webp);base64,[a-z0-9+/=\s]+$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCssString(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\f]/g, "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
|
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const renderedValue = typeof value === "string" ? translateText(value) : value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`metric-card metric-${tone}`}>
|
<div className={`metric-card metric-${tone}`}>
|
||||||
<div className="metric-label">{label}</div>
|
<div className="metric-label">{translateText(label)}</div>
|
||||||
<div className="metric-value">{value}</div>
|
<div className="metric-value">{renderedValue}</div>
|
||||||
{detail && <div className="metric-detail">{detail}</div>}
|
{detail && <div className="metric-detail">{translateText(detail)}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
|
|||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||||
requestNavigation: (action: UnsavedNavigationAction) => void;
|
requestNavigation: (action: UnsavedNavigationAction) => void;
|
||||||
|
/**
|
||||||
|
* Route an explicit Discard button through the same confirmation used for
|
||||||
|
* dirty navigation. The action runs after either saving or discarding.
|
||||||
|
*/
|
||||||
|
requestDiscard: (action: UnsavedNavigationAction) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||||
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
setPendingAction(() => action);
|
setPendingAction(() => action);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
|
||||||
|
requestNavigation(action);
|
||||||
|
}, [requestNavigation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
const active = registrationRef.current;
|
const active = registrationRef.current;
|
||||||
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
registerUnsavedChanges,
|
registerUnsavedChanges,
|
||||||
requestNavigation
|
requestNavigation,
|
||||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
requestDiscard
|
||||||
|
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesContext.Provider value={value}>
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
|||||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
registerUnsavedChanges: () => () => undefined,
|
registerUnsavedChanges: () => () => undefined,
|
||||||
requestNavigation: (action) => action()
|
requestNavigation: (action) => action(),
|
||||||
|
requestDiscard: (action) => action()
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useUnsavedChanges() {
|
export function useUnsavedChanges() {
|
||||||
|
|||||||
517
webui/src/components/people/PeoplePicker.tsx
Normal file
517
webui/src/components/people/PeoplePicker.tsx
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
type FocusEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type ReactNode
|
||||||
|
} from "react";
|
||||||
|
import { Plus, Search, Trash2, UserPlus } from "lucide-react";
|
||||||
|
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
|
import { isValidEmailAddress } from "../../utils/emailAddresses";
|
||||||
|
import Button from "../Button";
|
||||||
|
import FieldLabel from "../help/FieldLabel";
|
||||||
|
import DataGrid, { DataGridEmptyAction, type DataGridColumn } from "../table/DataGrid";
|
||||||
|
import TableActionGroup from "../table/TableActionGroup";
|
||||||
|
import {
|
||||||
|
dedupePeoplePickerItems,
|
||||||
|
externalPeoplePickerItem,
|
||||||
|
peoplePickerDedupeKey,
|
||||||
|
selectionFromPeoplePickerCandidate,
|
||||||
|
type PeoplePickerItem,
|
||||||
|
type PeoplePickerSearch,
|
||||||
|
type PeoplePickerSearchCandidate,
|
||||||
|
type PeoplePickerSearchGroup
|
||||||
|
} from "./peoplePickerTypes";
|
||||||
|
|
||||||
|
export type PeoplePickerProps = {
|
||||||
|
id?: string;
|
||||||
|
label?: ReactNode;
|
||||||
|
help?: ReactNode;
|
||||||
|
selectedLabel?: ReactNode;
|
||||||
|
value: readonly PeoplePickerItem[];
|
||||||
|
onChange: (value: PeoplePickerItem[]) => void;
|
||||||
|
search: PeoplePickerSearch;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
allowManualExternal?: boolean;
|
||||||
|
manualEmailRequired?: boolean;
|
||||||
|
minQueryLength?: number;
|
||||||
|
searchLimit?: number;
|
||||||
|
debounceMs?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IndexedCandidate = {
|
||||||
|
candidate: PeoplePickerSearchCandidate;
|
||||||
|
group: PeoplePickerSearchGroup;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
people: "i18n:govoplan-core.people.b37554f6",
|
||||||
|
searchPeople: "i18n:govoplan-core.search_people_and_contacts.8e028a78",
|
||||||
|
searchPlaceholder: "i18n:govoplan-core.search_by_name_or_email_address.6283d84d",
|
||||||
|
searching: "i18n:govoplan-core.searching.1a6a5ba8",
|
||||||
|
noMatches: "i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688",
|
||||||
|
addExternal: "i18n:govoplan-core.add_external_person.b19c7abc",
|
||||||
|
externalPerson: "i18n:govoplan-core.external_person.35115d8e",
|
||||||
|
selectedPeople: "i18n:govoplan-core.selected_people.49a953a1",
|
||||||
|
noSelection: "i18n:govoplan-core.no_people_selected_yet.9383cc74",
|
||||||
|
source: "i18n:govoplan-core.source.6da13add",
|
||||||
|
addPerson: "i18n:govoplan-core.add_person.3b10561b",
|
||||||
|
removePerson: "i18n:govoplan-core.remove_person.2ea46758",
|
||||||
|
alreadySelected: "i18n:govoplan-core.this_person_is_already_selected.d337154c",
|
||||||
|
enterName: "i18n:govoplan-core.enter_a_name.86c810d9",
|
||||||
|
searchFailed: "i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7",
|
||||||
|
addSomeone: "i18n:govoplan-core.add_someone_to_the_selection.c617dd69"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function translatedNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
|
||||||
|
return typeof node === "string" ? translateText(node) : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenGroups(groups: readonly PeoplePickerSearchGroup[]): IndexedCandidate[] {
|
||||||
|
const flattened: IndexedCandidate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const candidate of group.candidates) {
|
||||||
|
if (!candidate.selection_key || seen.has(candidate.selection_key)) continue;
|
||||||
|
seen.add(candidate.selection_key);
|
||||||
|
flattened.push({ candidate, group, index: flattened.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeoplePicker({
|
||||||
|
id,
|
||||||
|
label = I18N.people,
|
||||||
|
help,
|
||||||
|
selectedLabel = I18N.selectedPeople,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
search,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
allowManualExternal = true,
|
||||||
|
manualEmailRequired = true,
|
||||||
|
minQueryLength = 2,
|
||||||
|
searchLimit = 25,
|
||||||
|
debounceMs = 250,
|
||||||
|
className = ""
|
||||||
|
}: PeoplePickerProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||||
|
const pickerId = id || `people-picker-${generatedId}`;
|
||||||
|
const labelId = `${pickerId}-label`;
|
||||||
|
const inputId = `${pickerId}-search`;
|
||||||
|
const listboxId = `${pickerId}-results`;
|
||||||
|
const statusId = `${pickerId}-status`;
|
||||||
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const manualNameRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const manualEmailRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]);
|
||||||
|
const selectedKeys = useMemo(
|
||||||
|
() => new Set(selectedItems.map(peoplePickerDedupeKey)),
|
||||||
|
[selectedItems]
|
||||||
|
);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [groups, setGroups] = useState<readonly PeoplePickerSearchGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
|
const [manualName, setManualName] = useState("");
|
||||||
|
const [manualEmail, setManualEmail] = useState("");
|
||||||
|
const [manualError, setManualError] = useState("");
|
||||||
|
const normalizedMinQueryLength = Math.max(0, Math.floor(minQueryLength));
|
||||||
|
const normalizedSearchLimit = Math.max(1, Math.min(Math.floor(searchLimit), 100));
|
||||||
|
const flattened = useMemo(() => flattenGroups(groups), [groups]);
|
||||||
|
|
||||||
|
function candidateUnavailable(candidate: PeoplePickerSearchCandidate): boolean {
|
||||||
|
return Boolean(candidate.disabled || selectedKeys.has(peoplePickerDedupeKey(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizedQuery = query.trim();
|
||||||
|
if (disabled || normalizedQuery.length < normalizedMinQueryLength) {
|
||||||
|
setGroups([]);
|
||||||
|
setLoading(false);
|
||||||
|
setHasSearched(false);
|
||||||
|
setSearchError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setSearchError("");
|
||||||
|
void search(normalizedQuery, { limit: normalizedSearchLimit, signal: controller.signal })
|
||||||
|
.then((nextGroups) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setGroups(nextGroups);
|
||||||
|
setHasSearched(true);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||||
|
setGroups([]);
|
||||||
|
setHasSearched(true);
|
||||||
|
setSearchError(I18N.searchFailed);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
}, Math.max(0, Math.floor(debounceMs)));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [debounceMs, disabled, normalizedMinQueryLength, normalizedSearchLimit, query, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstAvailable = flattened.find((item) => !candidateUnavailable(item.candidate));
|
||||||
|
setActiveIndex(firstAvailable?.index ?? -1);
|
||||||
|
}, [flattened, selectedKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (manualOpen) manualNameRef.current?.focus();
|
||||||
|
}, [manualOpen]);
|
||||||
|
|
||||||
|
function focusSearch() {
|
||||||
|
if (disabled) return;
|
||||||
|
setOpen(true);
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCandidate(candidate: PeoplePickerSearchCandidate) {
|
||||||
|
if (disabled || candidateUnavailable(candidate)) return;
|
||||||
|
onChange(dedupePeoplePickerItems([...selectedItems, selectionFromPeoplePickerCandidate(candidate)]));
|
||||||
|
setQuery("");
|
||||||
|
setGroups([]);
|
||||||
|
setHasSearched(false);
|
||||||
|
setOpen(false);
|
||||||
|
window.requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(item: PeoplePickerItem) {
|
||||||
|
if (disabled) return;
|
||||||
|
const removedKey = peoplePickerDedupeKey(item);
|
||||||
|
onChange(selectedItems.filter((candidate) => peoplePickerDedupeKey(candidate) !== removedKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActive(delta: 1 | -1) {
|
||||||
|
const available = flattened.filter((item) => !candidateUnavailable(item.candidate));
|
||||||
|
if (available.length === 0) {
|
||||||
|
setActiveIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentPosition = available.findIndex((item) => item.index === activeIndex);
|
||||||
|
const nextPosition = currentPosition < 0
|
||||||
|
? (delta > 0 ? 0 : available.length - 1)
|
||||||
|
: (currentPosition + delta + available.length) % available.length;
|
||||||
|
setActiveIndex(available[nextPosition].index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
moveActive(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
moveActive(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && activeIndex >= 0 && open) {
|
||||||
|
event.preventDefault();
|
||||||
|
const active = flattened.find((item) => item.index === activeIndex);
|
||||||
|
if (active) addCandidate(active.candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addManualPerson(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = manualName.trim();
|
||||||
|
const email = manualEmail.trim().toLowerCase();
|
||||||
|
if (!name) {
|
||||||
|
setManualError(I18N.enterName);
|
||||||
|
manualNameRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((manualEmailRequired && !email) || (email && !isValidEmailAddress(email))) {
|
||||||
|
setManualError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
|
||||||
|
manualEmailRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = externalPeoplePickerItem(name, email || null);
|
||||||
|
if (selectedKeys.has(peoplePickerDedupeKey(item))) {
|
||||||
|
setManualError(I18N.alreadySelected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(dedupePeoplePickerItems([...selectedItems, item]));
|
||||||
|
setManualName("");
|
||||||
|
setManualEmail("");
|
||||||
|
setManualError("");
|
||||||
|
setManualOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeResultsOnFocusLeave(event: FocusEvent<HTMLElement>) {
|
||||||
|
const nextTarget = event.relatedTarget;
|
||||||
|
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMessage = loading
|
||||||
|
? I18N.searching
|
||||||
|
: searchError
|
||||||
|
? searchError
|
||||||
|
: query.trim() && query.trim().length < normalizedMinQueryLength
|
||||||
|
? i18nMessage("i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad", {
|
||||||
|
value0: normalizedMinQueryLength
|
||||||
|
})
|
||||||
|
: hasSearched && flattened.length === 0
|
||||||
|
? I18N.noMatches
|
||||||
|
: "";
|
||||||
|
const resultsVisible = open && !disabled && query.trim().length >= normalizedMinQueryLength;
|
||||||
|
const columns: DataGridColumn<PeoplePickerItem>[] = [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "i18n:govoplan-core.name.709a2322",
|
||||||
|
minWidth: 180,
|
||||||
|
resizable: true,
|
||||||
|
render: (item) => (
|
||||||
|
<div className="people-picker-selected-person">
|
||||||
|
<strong>{item.display_name}</strong>
|
||||||
|
{item.description && <small>{item.description}</small>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
header: "i18n:govoplan-core.email_address.c94d3175",
|
||||||
|
minWidth: 220,
|
||||||
|
resizable: true,
|
||||||
|
value: (item) => item.email || "—"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
header: I18N.source,
|
||||||
|
minWidth: 130,
|
||||||
|
resizable: true,
|
||||||
|
value: (item) => translateText(item.source_label || kindLabel(item.kind))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "i18n:govoplan-core.actions.c3cd636a",
|
||||||
|
width: 104,
|
||||||
|
sticky: "end",
|
||||||
|
align: "right",
|
||||||
|
render: (item) => (
|
||||||
|
<TableActionGroup
|
||||||
|
minimumSlots={2}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: "add",
|
||||||
|
label: I18N.addPerson,
|
||||||
|
icon: <Plus size={16} aria-hidden="true" />,
|
||||||
|
disabled,
|
||||||
|
onClick: focusSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remove",
|
||||||
|
label: I18N.removePerson,
|
||||||
|
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||||
|
variant: "danger",
|
||||||
|
disabled,
|
||||||
|
onClick: () => removeItem(item)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`people-picker ${className}`.trim()} aria-labelledby={labelId} onBlur={closeResultsOnFocusLeave}>
|
||||||
|
<div className="people-picker-search-field">
|
||||||
|
<label htmlFor={inputId} id={labelId}>
|
||||||
|
<FieldLabel help={help}>{translatedNode(label, translateText)}</FieldLabel>
|
||||||
|
</label>
|
||||||
|
<div className="people-picker-search-control">
|
||||||
|
<Search size={17} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
id={inputId}
|
||||||
|
type="search"
|
||||||
|
role="combobox"
|
||||||
|
value={query}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required && selectedItems.length === 0}
|
||||||
|
placeholder={translateText(I18N.searchPlaceholder)}
|
||||||
|
aria-label={translateText(I18N.searchPeople)}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={resultsVisible}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeIndex >= 0 ? `${pickerId}-option-${activeIndex}` : undefined}
|
||||||
|
aria-describedby={statusId}
|
||||||
|
aria-busy={loading}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
setSearchError("");
|
||||||
|
}}
|
||||||
|
onKeyDown={onSearchKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resultsVisible && (
|
||||||
|
<div id={listboxId} className="people-picker-results" role="listbox" aria-label={translateText(I18N.searchPeople)}>
|
||||||
|
{groups.map((group) => {
|
||||||
|
const candidates = flattened.filter((item) => item.group.key === group.key);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const groupLabelId = `${pickerId}-group-${group.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="people-picker-result-group" role="group" aria-labelledby={groupLabelId}>
|
||||||
|
<div id={groupLabelId} className="people-picker-result-group-label">{translateText(group.label)}</div>
|
||||||
|
{candidates.map(({ candidate, index }) => {
|
||||||
|
const selected = selectedKeys.has(peoplePickerDedupeKey(candidate));
|
||||||
|
const unavailable = candidateUnavailable(candidate);
|
||||||
|
const disabledReason = selected ? I18N.alreadySelected : candidate.disabled_reason;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={`${pickerId}-option-${index}`}
|
||||||
|
key={candidate.selection_key}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
className={`people-picker-result ${activeIndex === index ? "is-active" : ""}`.trim()}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-disabled={unavailable}
|
||||||
|
disabled={unavailable}
|
||||||
|
title={disabledReason ? translateText(disabledReason) : undefined}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onMouseEnter={() => !unavailable && setActiveIndex(index)}
|
||||||
|
onFocus={() => !unavailable && setActiveIndex(index)}
|
||||||
|
onClick={() => addCandidate(candidate)}
|
||||||
|
>
|
||||||
|
<span className="people-picker-result-main">
|
||||||
|
<strong>{candidate.display_name}</strong>
|
||||||
|
{candidate.email && <small>{candidate.email}</small>}
|
||||||
|
</span>
|
||||||
|
<span className="people-picker-result-context">
|
||||||
|
{candidate.description && <small>{candidate.description}</small>}
|
||||||
|
<small>{translateText(candidate.source_label || group.label)}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div id={statusId} className={`people-picker-status ${searchError ? "danger-text" : ""}`.trim()} role={searchError ? "alert" : "status"} aria-live="polite">
|
||||||
|
{statusMessage && translateText(statusMessage)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowManualExternal && (
|
||||||
|
<div className="people-picker-manual">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-expanded={manualOpen}
|
||||||
|
onClick={() => {
|
||||||
|
setManualOpen((current) => !current);
|
||||||
|
setManualError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus size={16} aria-hidden="true" />
|
||||||
|
{translateText(I18N.addExternal)}
|
||||||
|
</Button>
|
||||||
|
{manualOpen && (
|
||||||
|
<form className="people-picker-manual-form" onSubmit={addManualPerson}>
|
||||||
|
<label>
|
||||||
|
<FieldLabel>{translateText("i18n:govoplan-core.name.709a2322")}</FieldLabel>
|
||||||
|
<input
|
||||||
|
ref={manualNameRef}
|
||||||
|
value={manualName}
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualName(event.target.value);
|
||||||
|
setManualError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<FieldLabel>{translateText("i18n:govoplan-core.email_address.c94d3175")}</FieldLabel>
|
||||||
|
<input
|
||||||
|
ref={manualEmailRef}
|
||||||
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
|
value={manualEmail}
|
||||||
|
disabled={disabled}
|
||||||
|
required={manualEmailRequired}
|
||||||
|
autoComplete="email"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualEmail(event.target.value);
|
||||||
|
setManualError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="people-picker-manual-actions">
|
||||||
|
<Button type="button" disabled={disabled} onClick={() => setManualOpen(false)}>
|
||||||
|
{translateText("i18n:govoplan-core.cancel.77dfd213")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={disabled}>
|
||||||
|
{translateText(I18N.addPerson)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{manualError && <p className="form-help danger-text" role="alert">{translateText(manualError)}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="people-picker-selection" aria-labelledby={`${pickerId}-selection-label`}>
|
||||||
|
<span id={`${pickerId}-selection-label`}><FieldLabel>{translatedNode(selectedLabel, translateText)}</FieldLabel></span>
|
||||||
|
<DataGrid
|
||||||
|
id={`${pickerId}-selection`}
|
||||||
|
rows={selectedItems}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={peoplePickerDedupeKey}
|
||||||
|
emptyText={I18N.noSelection}
|
||||||
|
emptyActionColumnId="actions"
|
||||||
|
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={focusSearch} label={I18N.addSomeone} />}
|
||||||
|
initialFit="container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(kind: string): string {
|
||||||
|
if (kind === "account") return "i18n:govoplan-core.accounts.36bae316";
|
||||||
|
if (kind === "contact") return "i18n:govoplan-core.contacts.b0dd615c";
|
||||||
|
if (kind === "external") return I18N.externalPerson;
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type PeoplePickerItemKind = "account" | "contact" | "external" | "function" | (string & {});
|
||||||
|
|
||||||
|
export type PeoplePickerItem = {
|
||||||
|
selection_key: string;
|
||||||
|
kind: PeoplePickerItemKind;
|
||||||
|
reference_id?: string | null;
|
||||||
|
display_name: string;
|
||||||
|
email?: string | null;
|
||||||
|
source_module?: string | null;
|
||||||
|
source_label?: string | null;
|
||||||
|
source_ref?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchCandidate = PeoplePickerItem & {
|
||||||
|
disabled?: boolean;
|
||||||
|
disabled_reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
candidates: readonly PeoplePickerSearchCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchOptions = {
|
||||||
|
limit: number;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearch = (
|
||||||
|
query: string,
|
||||||
|
options: PeoplePickerSearchOptions
|
||||||
|
) => Promise<readonly PeoplePickerSearchGroup[]>;
|
||||||
|
|
||||||
|
export function peoplePickerDedupeKey(item: Pick<PeoplePickerItem, "selection_key" | "email">): string {
|
||||||
|
const email = item.email?.trim().toLowerCase();
|
||||||
|
return email ? `email:${email}` : `selection:${item.selection_key.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupePeoplePickerItems(items: readonly PeoplePickerItem[]): PeoplePickerItem[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: PeoplePickerItem[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const key = peoplePickerDedupeKey(item);
|
||||||
|
if (!item.selection_key.trim() || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function externalPeoplePickerItem(displayName: string, email?: string | null): PeoplePickerItem {
|
||||||
|
const normalizedName = displayName.trim();
|
||||||
|
const normalizedEmail = email?.trim().toLowerCase() || null;
|
||||||
|
const identityPart = normalizedEmail || normalizedName.toLowerCase();
|
||||||
|
return {
|
||||||
|
selection_key: `external:${identityPart}`,
|
||||||
|
kind: "external",
|
||||||
|
reference_id: null,
|
||||||
|
display_name: normalizedName,
|
||||||
|
email: normalizedEmail,
|
||||||
|
source_module: null,
|
||||||
|
source_label: "i18n:govoplan-core.external_person.35115d8e",
|
||||||
|
source_ref: null,
|
||||||
|
source_revision: null,
|
||||||
|
provenance: {},
|
||||||
|
metadata: { manual: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromPeoplePickerCandidate(candidate: PeoplePickerSearchCandidate): PeoplePickerItem {
|
||||||
|
const { disabled: _disabled, disabled_reason: _disabledReason, ...selection } = candidate;
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
@@ -30,18 +30,36 @@ export type DataGridQueryState = {
|
|||||||
filters: Record<string, string>;
|
filters: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DataGridPagination = {
|
type DataGridPaginationBase = {
|
||||||
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
|
|
||||||
mode?: "client" | "server";
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
totalRows?: number;
|
|
||||||
pageSizeOptions?: number[];
|
pageSizeOptions?: number[];
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataGridClientPagination = DataGridPaginationBase & {
|
||||||
|
/**
|
||||||
|
* Client mode filters and sorts before slicing. `rows` must therefore contain
|
||||||
|
* the complete logical result set, never an already paginated backend page.
|
||||||
|
*/
|
||||||
|
mode?: "client";
|
||||||
|
totalRows?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridServerPagination = DataGridPaginationBase & {
|
||||||
|
/**
|
||||||
|
* Server mode treats `rows` as the current backend page. The query callback
|
||||||
|
* must apply every emitted sort/filter to the backend before pagination.
|
||||||
|
*/
|
||||||
|
mode: "server";
|
||||||
|
/** Total rows after the backend has applied the current filters. */
|
||||||
|
totalRows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridPagination = DataGridClientPagination | DataGridServerPagination;
|
||||||
|
|
||||||
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
||||||
|
|
||||||
export type DataGridColumn<T> = {
|
export type DataGridColumn<T> = {
|
||||||
@@ -78,7 +96,7 @@ type DataGridState = {
|
|||||||
bufferWidth?: number;
|
bufferWidth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DataGridProps<T> = {
|
type DataGridBaseProps<T> = {
|
||||||
id: string;
|
id: string;
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: DataGridColumn<T>[];
|
columns: DataGridColumn<T>[];
|
||||||
@@ -98,14 +116,29 @@ type DataGridProps<T> = {
|
|||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
initialFilters?: Record<string, string | string[]>;
|
initialFilters?: Record<string, string | string[]>;
|
||||||
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
||||||
pagination?: DataGridPagination;
|
/**
|
||||||
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
|
* Synchronize a query selected outside the grid, for example by a summary
|
||||||
onQueryChange?: (query: DataGridQueryState) => void;
|
* count shortcut. Server grids should feed `onQueryChange` back into this
|
||||||
|
* value so the header controls and backend query remain in agreement.
|
||||||
|
*/
|
||||||
|
query?: DataGridQueryState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query ownership is deliberately explicit: client grids receive all rows and
|
||||||
|
* DataGrid queries them locally; server grids receive one page and must handle
|
||||||
|
* the complete query through `onQueryChange`.
|
||||||
|
*/
|
||||||
|
export type DataGridProps<T> = DataGridBaseProps<T> & (
|
||||||
|
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
|
||||||
|
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
|
||||||
|
);
|
||||||
|
|
||||||
export type DataGridRowActionsProps = {
|
export type DataGridRowActionsProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
removeDisabled?: boolean;
|
removeDisabled?: boolean;
|
||||||
|
/** Set to false only when row ordering is not part of this table's action set. */
|
||||||
|
reorderable?: boolean;
|
||||||
onAddBelow: () => void;
|
onAddBelow: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onMoveUp?: () => void;
|
onMoveUp?: () => void;
|
||||||
@@ -177,6 +210,7 @@ export default function DataGrid<T>({
|
|||||||
storageKey,
|
storageKey,
|
||||||
initialFilters = {},
|
initialFilters = {},
|
||||||
initialSort,
|
initialSort,
|
||||||
|
query,
|
||||||
pagination,
|
pagination,
|
||||||
onQueryChange
|
onQueryChange
|
||||||
}: DataGridProps<T>) {
|
}: DataGridProps<T>) {
|
||||||
@@ -203,7 +237,15 @@ export default function DataGrid<T>({
|
|||||||
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
||||||
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
||||||
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
|
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
|
||||||
const [state, setState] = useState<DataGridState>(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort));
|
const externalQueryKey = query ? JSON.stringify(query) : "";
|
||||||
|
const normalizedExternalQuery = useMemo(
|
||||||
|
() => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined,
|
||||||
|
[externalQueryKey]
|
||||||
|
);
|
||||||
|
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
|
||||||
|
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
|
||||||
|
normalizedExternalQuery
|
||||||
|
));
|
||||||
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
||||||
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
||||||
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
||||||
@@ -211,14 +253,21 @@ export default function DataGrid<T>({
|
|||||||
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const serverQueryMode = pagination?.mode === "server";
|
||||||
const onQueryChangeRef = useRef(onQueryChange);
|
const onQueryChangeRef = useRef(onQueryChange);
|
||||||
|
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
|
||||||
|
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
|
||||||
const lastLayoutSignatureRef = useRef<string | null>(null);
|
const lastLayoutSignatureRef = useRef<string | null>(null);
|
||||||
const lastContainerWidthRef = useRef<number | undefined>(undefined);
|
const lastContainerWidthRef = useRef<number | undefined>(undefined);
|
||||||
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||||
const serverQueryMode = pagination?.mode === "server";
|
|
||||||
|
|
||||||
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
|
||||||
|
if (!serverQueryMode) lastServerQueryRef.current = null;
|
||||||
|
}, [pagination, serverQueryMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
||||||
}, [columns, effectiveResizeBehavior]);
|
}, [columns, effectiveResizeBehavior]);
|
||||||
@@ -231,6 +280,10 @@ export default function DataGrid<T>({
|
|||||||
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
||||||
}, [normalizedInitialSort]);
|
}, [normalizedInitialSort]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
|
||||||
|
}, [normalizedExternalQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
||||||
@@ -458,10 +511,17 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverQueryMode) return;
|
if (!serverQueryMode) return;
|
||||||
onQueryChangeRef.current?.({
|
const query = {
|
||||||
sort: state.sort ?? null,
|
sort: state.sort ?? null,
|
||||||
filters: { ...(state.filters ?? {}) }
|
filters: { ...(state.filters ?? {}) }
|
||||||
});
|
};
|
||||||
|
const previousQuery = lastServerQueryRef.current;
|
||||||
|
lastServerQueryRef.current = query;
|
||||||
|
const serverPagination = serverPaginationRef.current;
|
||||||
|
if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) {
|
||||||
|
serverPagination.onPageChange(1);
|
||||||
|
}
|
||||||
|
onQueryChangeRef.current?.(query);
|
||||||
}, [serverQueryMode, state.sort, state.filters]);
|
}, [serverQueryMode, state.sort, state.filters]);
|
||||||
|
|
||||||
const filterTypes = useMemo(() => {
|
const filterTypes = useMemo(() => {
|
||||||
@@ -858,6 +918,7 @@ export function DataGridPaginationBar({
|
|||||||
export function DataGridRowActions({
|
export function DataGridRowActions({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
removeDisabled = false,
|
removeDisabled = false,
|
||||||
|
reorderable = true,
|
||||||
onAddBelow,
|
onAddBelow,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
@@ -879,20 +940,18 @@ export function DataGridRowActions({
|
|||||||
disabled,
|
disabled,
|
||||||
onClick: onAddBelow
|
onClick: onAddBelow
|
||||||
},
|
},
|
||||||
{
|
reorderable && {
|
||||||
id: "move-up",
|
id: "move-up",
|
||||||
label: moveUpLabel,
|
label: moveUpLabel,
|
||||||
icon: <ArrowUp size={16} aria-hidden="true" />,
|
icon: <ArrowUp size={16} aria-hidden="true" />,
|
||||||
applicable: Boolean(onMoveUp),
|
disabled: disabled || !onMoveUp,
|
||||||
disabled,
|
|
||||||
onClick: () => onMoveUp?.()
|
onClick: () => onMoveUp?.()
|
||||||
},
|
},
|
||||||
{
|
reorderable && {
|
||||||
id: "move-down",
|
id: "move-down",
|
||||||
label: moveDownLabel,
|
label: moveDownLabel,
|
||||||
icon: <ArrowDown size={16} aria-hidden="true" />,
|
icon: <ArrowDown size={16} aria-hidden="true" />,
|
||||||
applicable: Boolean(onMoveDown),
|
disabled: disabled || !onMoveDown,
|
||||||
disabled,
|
|
||||||
onClick: () => onMoveDown?.()
|
onClick: () => onMoveDown?.()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -910,16 +969,18 @@ export function DataGridRowActions({
|
|||||||
|
|
||||||
export function DataGridEmptyAction({
|
export function DataGridEmptyAction({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
reorderable = true,
|
||||||
onAdd,
|
onAdd,
|
||||||
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
|
||||||
return (
|
return (
|
||||||
<TableActionGroup
|
<TableActionGroup
|
||||||
className="data-grid-row-actions data-grid-empty-row-actions"
|
className="data-grid-row-actions data-grid-empty-row-actions"
|
||||||
|
minimumSlots={reorderable ? 4 : 2}
|
||||||
actions={[{
|
actions={[{
|
||||||
id: "add",
|
id: "add",
|
||||||
label,
|
label,
|
||||||
@@ -1199,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
|
|||||||
return { ...state, sort: initialSort };
|
return { ...state, sort: initialSort };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState {
|
||||||
|
if (!query) return state;
|
||||||
|
const current = {
|
||||||
|
sort: state.sort ?? null,
|
||||||
|
filters: state.filters ?? {}
|
||||||
|
};
|
||||||
|
if (dataGridQueriesEqual(current, query)) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sort: query.sort ? { ...query.sort } : null,
|
||||||
|
filters: { ...query.filters }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatListFilter(values: string[]): string {
|
function formatListFilter(values: string[]): string {
|
||||||
return `list:${JSON.stringify([...new Set(values)])}`;
|
return `list:${JSON.stringify([...new Set(values)])}`;
|
||||||
}
|
}
|
||||||
@@ -1665,6 +1740,16 @@ function compareValues(a: unknown, b: unknown): number {
|
|||||||
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
|
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
|
||||||
|
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||||
|
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||||
|
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
|
||||||
|
for (const key of keys) {
|
||||||
|
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyCell(value: unknown): string {
|
function stringifyCell(value: unknown): string {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ export type TableActionDefinition = {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
/** Set to false when the action does not apply to this row. */
|
/**
|
||||||
|
* @deprecated Use `disabled` for a row-level unavailable state and omit the
|
||||||
|
* action from the array only when it is not part of this table's action set.
|
||||||
|
* A false value is retained as a disabled, visible action for compatibility.
|
||||||
|
*/
|
||||||
applicable?: boolean;
|
applicable?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disabledReason?: ReactNode;
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,12 +22,14 @@ export type TableActionGroupProps = {
|
|||||||
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
||||||
label?: string;
|
label?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Reserve trailing action slots so controls keep the same column position. */
|
||||||
|
minimumSlots?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isApplicableAction(
|
function isDefinedAction(
|
||||||
action: TableActionDefinition | false | null | undefined
|
action: TableActionDefinition | false | null | undefined
|
||||||
): action is TableActionDefinition {
|
): action is TableActionDefinition {
|
||||||
return Boolean(action && action.applicable !== false);
|
return Boolean(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runTableAction(
|
export function runTableAction(
|
||||||
@@ -36,19 +43,24 @@ export function runTableAction(
|
|||||||
/**
|
/**
|
||||||
* The standard row-level action surface for tables and DataGrids.
|
* The standard row-level action surface for tables and DataGrids.
|
||||||
*
|
*
|
||||||
* Callers describe only actions that make sense for the row. An action may be
|
* Callers define the action set for the whole table. An action that cannot be
|
||||||
* disabled when it applies but is temporarily unavailable; actions marked as
|
* used for one row stays visible and disabled; an action that is structurally
|
||||||
* not applicable are omitted entirely. Labels remain available to assistive
|
* irrelevant to the table is omitted from the array. Labels remain available
|
||||||
* technology and as native tooltips while the visible controls stay icon-only.
|
* to assistive technology and as native tooltips while the visible controls
|
||||||
|
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
|
||||||
|
* rows, keeping their first action aligned with ordinary rows.
|
||||||
*/
|
*/
|
||||||
export default function TableActionGroup({
|
export default function TableActionGroup({
|
||||||
actions,
|
actions,
|
||||||
label = "i18n:govoplan-core.actions.c3cd636a",
|
label = "i18n:govoplan-core.actions.c3cd636a",
|
||||||
className = ""
|
className = "",
|
||||||
|
minimumSlots = 0
|
||||||
}: TableActionGroupProps) {
|
}: TableActionGroupProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const applicableActions = actions.filter(isApplicableAction);
|
const definedActions = actions.filter(isDefinedAction);
|
||||||
if (applicableActions.length === 0) return null;
|
if (definedActions.length === 0) return null;
|
||||||
|
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
|
||||||
|
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -56,7 +68,7 @@ export default function TableActionGroup({
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label={translateText(label)}
|
aria-label={translateText(label)}
|
||||||
>
|
>
|
||||||
{applicableActions.map((action) => {
|
{definedActions.map((action) => {
|
||||||
const translatedLabel = translateText(action.label);
|
const translatedLabel = translateText(action.label);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -66,7 +78,8 @@ export default function TableActionGroup({
|
|||||||
className="table-action-button"
|
className="table-action-button"
|
||||||
aria-label={translatedLabel}
|
aria-label={translatedLabel}
|
||||||
title={translatedLabel}
|
title={translatedLabel}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled || action.applicable === false}
|
||||||
|
disabledReason={action.disabledReason}
|
||||||
onClick={(event) => runTableAction(event, action.onClick)}
|
onClick={(event) => runTableAction(event, action.onClick)}
|
||||||
>
|
>
|
||||||
<span className="table-action-icon" aria-hidden="true">
|
<span className="table-action-icon" aria-hidden="true">
|
||||||
@@ -75,6 +88,13 @@ export default function TableActionGroup({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{Array.from({ length: placeholderCount }, (_unused, index) => (
|
||||||
|
<span
|
||||||
|
key={`reserved-action-slot-${index}`}
|
||||||
|
className="table-action-placeholder"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,15 @@ export default function SettingsPage({
|
|||||||
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",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
"admin:policies:read",
|
||||||
|
"admin:policies:write"
|
||||||
|
]);
|
||||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||||
const contributedSections = useMemo(
|
const contributedSections = useMemo(
|
||||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||||
@@ -319,8 +327,8 @@ export default function SettingsPage({
|
|||||||
scopeId={auth.user.id}
|
scopeId={auth.user.id}
|
||||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
|
||||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
|
||||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||||
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
||||||
|
"i18n:govoplan-core.accounts.36bae316": "Accounts",
|
||||||
|
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
|
||||||
|
"i18n:govoplan-core.people.b37554f6": "People",
|
||||||
|
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
|
||||||
|
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
|
||||||
|
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
|
||||||
|
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
|
||||||
|
"i18n:govoplan-core.external_person.35115d8e": "External person",
|
||||||
|
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
|
||||||
|
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
|
||||||
|
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
|
||||||
|
"i18n:govoplan-core.source.6da13add": "Source",
|
||||||
|
"i18n:govoplan-core.add_person.3b10561b": "Add person",
|
||||||
|
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
|
||||||
|
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
|
||||||
|
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
|
||||||
|
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
|
||||||
|
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
|
||||||
|
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
|
||||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||||
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||||
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
||||||
|
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
|
||||||
|
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
|
||||||
|
"i18n:govoplan-core.people.b37554f6": "Personen",
|
||||||
|
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
|
||||||
|
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
|
||||||
|
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
|
||||||
|
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
|
||||||
|
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
|
||||||
|
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
|
||||||
|
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
|
||||||
|
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
|
||||||
|
"i18n:govoplan-core.source.6da13add": "Quelle",
|
||||||
|
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
|
||||||
|
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
|
||||||
|
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
|
||||||
|
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
|
||||||
|
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
|
||||||
|
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
|
||||||
|
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
|
||||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
|
|||||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||||
export { default as PageTitle } from "./components/PageTitle";
|
export { default as PageTitle } from "./components/PageTitle";
|
||||||
export { default as PasswordField } from "./components/PasswordField";
|
export { default as PasswordField } from "./components/PasswordField";
|
||||||
|
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
||||||
|
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
||||||
|
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
|
||||||
|
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
|
||||||
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
||||||
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
||||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||||
@@ -110,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail
|
|||||||
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, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
|
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
|
||||||
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
|
export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
|
||||||
export { default as TableActionGroup } from "./components/table/TableActionGroup";
|
export { default as TableActionGroup } from "./components/table/TableActionGroup";
|
||||||
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,25 @@ import { usePlatformModules } from "../platform/ModuleContext";
|
|||||||
import type { PlatformWebModule } from "../types";
|
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";
|
||||||
|
import type { AuthInfo } from "../types";
|
||||||
|
import { hasAnyScope } from "../utils/permissions";
|
||||||
|
|
||||||
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
|
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
|
||||||
|
|
||||||
export default function HelpMenu() {
|
export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [contextOpen, setContextOpen] = useState(false);
|
const [contextOpen, setContextOpen] = useState(false);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const helpContext = helpContextForPathname(location.pathname);
|
const helpContext = helpContextForPathname(location.pathname, location.search);
|
||||||
const modules = usePlatformModules();
|
const modules = usePlatformModules();
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const docsAvailable = modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
|
||||||
|
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
|
||||||
|
const docsAvailable = configuredDocsAvailable &&
|
||||||
|
modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function openContextHelp(event: KeyboardEvent) {
|
function openContextHelp(event: KeyboardEvent) {
|
||||||
@@ -53,6 +58,7 @@ export default function HelpMenu() {
|
|||||||
|
|
||||||
function openDocs(type: "user" | "admin") {
|
function openDocs(type: "user" | "admin") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setContextOpen(false);
|
||||||
if (!docsAvailable) {
|
if (!docsAvailable) {
|
||||||
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
||||||
return;
|
return;
|
||||||
@@ -87,15 +93,17 @@ export default function HelpMenu() {
|
|||||||
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
|
<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")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
{adminDocsAvailable &&
|
||||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||||
</button>
|
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<hr />
|
<hr />
|
||||||
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a>
|
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a>
|
||||||
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
|
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
|
{contextOpen && <ContextHelpModal context={helpContext} onOpenDocs={() => openDocs("user")} onClose={() => setContextOpen(false)} />}
|
||||||
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
|
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
@@ -106,7 +114,7 @@ function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
|
|||||||
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
|
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
|
function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContext;onOpenDocs: () => void;onClose: () => void;}) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -123,6 +131,7 @@ function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: (
|
|||||||
<div className="help-panel-section">
|
<div className="help-panel-section">
|
||||||
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
|
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
|
||||||
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
|
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
|
||||||
|
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText("i18n:govoplan-core.open_user_documentation.084af515")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>);
|
</Dialog>);
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
<div className="titlebar-spacer" />
|
<div className="titlebar-spacer" />
|
||||||
|
|
||||||
<LanguageMenu />
|
<LanguageMenu />
|
||||||
<HelpMenu />
|
<HelpMenu auth={auth} />
|
||||||
|
|
||||||
{auth && notificationsAvailable &&
|
{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")}>
|
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||||
|
|
||||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
|||||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
||||||
|
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
import { 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, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
|
||||||
import {
|
import {
|
||||||
hasUiCapability as hasUiCapabilityForModules,
|
hasUiCapability as hasUiCapabilityForModules,
|
||||||
moduleInstalled as moduleInstalledForModules,
|
moduleInstalled as moduleInstalledForModules,
|
||||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||||
|
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
|
||||||
routeContributionsForModules as routeContributionsForModuleList,
|
routeContributionsForModules as routeContributionsForModuleList,
|
||||||
uiCapabilities as uiCapabilitiesForModules,
|
uiCapabilities as uiCapabilitiesForModules,
|
||||||
uiCapability as uiCapabilityForModules } from
|
uiCapability as uiCapabilityForModules } from
|
||||||
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||||
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
...(module.uiCapabilities ?? {}),
|
...(module.uiCapabilities ?? {}),
|
||||||
...runtimeUiCapabilitiesForModule(module, info)
|
...runtimeUiCapabilitiesForModule(module, info)
|
||||||
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterPublicRoutes(
|
||||||
|
module: PlatformWebModule,
|
||||||
|
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
|
||||||
|
) {
|
||||||
|
if (!routes?.length) return [];
|
||||||
|
const allowedPaths = new Set(routes.map((route) => route.path));
|
||||||
|
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||||
|
return {
|
||||||
|
id: info.id,
|
||||||
|
name: info.name,
|
||||||
|
version: info.version,
|
||||||
|
dependencies: [],
|
||||||
|
optional_dependencies: [],
|
||||||
|
enabled: true,
|
||||||
|
runtime_ui_capabilities: [],
|
||||||
|
nav: [],
|
||||||
|
frontend: {
|
||||||
|
...info.frontend,
|
||||||
|
routes: [],
|
||||||
|
nav: [],
|
||||||
|
settings_routes: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInstalledPublicWebModules(
|
||||||
|
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
||||||
|
): PlatformWebModule[] {
|
||||||
|
if (!platformModules?.length) return [];
|
||||||
|
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||||
|
return platformModules.flatMap((info) => {
|
||||||
|
const local = localById.get(info.id);
|
||||||
|
if (!local) return [];
|
||||||
|
const resolved = applyServerMetadata(local, publicModuleInfo(info));
|
||||||
|
return resolved.publicRoutes?.length ? [resolved] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRemotePublicWebModules(
|
||||||
|
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
||||||
|
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
|
||||||
|
): Promise<PlatformWebModule[]> {
|
||||||
|
if (!platformModules?.length) return [];
|
||||||
|
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
||||||
|
return loaded.filter((module) => module.publicRoutes?.length);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||||
if (!platformModules?.length) return localModules;
|
if (!platformModules?.length) return localModules;
|
||||||
|
|
||||||
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
|||||||
return routeContributionsForModuleList(modules);
|
return routeContributionsForModuleList(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
|
||||||
|
return publicRouteContributionsForModuleList(modules);
|
||||||
|
}
|
||||||
|
|
||||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||||
flatMap((capability) => capability.widgets ?? []).
|
flatMap((capability) => capability.widgets ?? []).
|
||||||
|
|||||||
@@ -1516,6 +1516,166 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Principal-aware account/contact picker with structured external entry. */
|
||||||
|
.people-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-search-field,
|
||||||
|
.people-picker-selection,
|
||||||
|
.people-picker-manual {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-search-control {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.people-picker-search-control > svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.people-picker-search-control input {
|
||||||
|
padding-left: 38px;
|
||||||
|
}
|
||||||
|
.people-picker-search-control:focus-within > svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.people-picker-results {
|
||||||
|
display: grid;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border: var(--border-line-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group + .people-picker-result-group {
|
||||||
|
margin-top: 5px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group-label {
|
||||||
|
color: var(--text-label);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
padding: 5px 9px 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.people-picker-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.people-picker-result:hover:not(:disabled),
|
||||||
|
.people-picker-result.is-active:not(:disabled),
|
||||||
|
.people-picker-result:focus-visible:not(:disabled) {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
box-shadow: var(--primary-inset-ring);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.people-picker-result:disabled {
|
||||||
|
background: var(--control-disabled-bg);
|
||||||
|
color: var(--control-disabled-text);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.people-picker-result-main,
|
||||||
|
.people-picker-result-context,
|
||||||
|
.people-picker-selected-person {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-result-main strong,
|
||||||
|
.people-picker-result-main small,
|
||||||
|
.people-picker-result-context small,
|
||||||
|
.people-picker-selected-person strong,
|
||||||
|
.people-picker-selected-person small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.people-picker-result-main small,
|
||||||
|
.people-picker-result-context,
|
||||||
|
.people-picker-selected-person small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-result-context {
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.people-picker-status {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-manual {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form > label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-manual-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form > .form-help {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.people-picker-selection > .field-label,
|
||||||
|
.people-picker-selection > span > .field-label {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.people-picker-result {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.people-picker-result-context {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Bootstrap-like switch controls without importing Bootstrap. */
|
/* Bootstrap-like switch controls without importing Bootstrap. */
|
||||||
.toggle-switch-row {
|
.toggle-switch-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -94,7 +94,8 @@
|
|||||||
padding: 18px 34px 16px;
|
padding: 18px 34px 16px;
|
||||||
}
|
}
|
||||||
.workspace-heading .mono-small { margin-top: 8px; }
|
.workspace-heading .mono-small { margin-top: 8px; }
|
||||||
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||||
|
.panel { overflow: hidden; }
|
||||||
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
||||||
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
||||||
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||||
|
|||||||
@@ -686,6 +686,15 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-action-placeholder {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 36px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.data-grid-empty-message {
|
.data-grid-empty-message {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
|
|||||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformPublicRouteContext = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo | null;
|
||||||
|
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
||||||
|
|
||||||
export type AdminSectionRenderContext = PlatformRouteContext & {
|
export type AdminSectionRenderContext = PlatformRouteContext & {
|
||||||
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
|
|||||||
render: (context: PlatformRouteContext) => ReactNode;
|
render: (context: PlatformRouteContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformPublicRouteContribution = {
|
||||||
|
path: string;
|
||||||
|
order?: number;
|
||||||
|
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||||
|
|
||||||
export type PlatformTranslationDictionary = Record<string, string>;
|
export type PlatformTranslationDictionary = Record<string, string>;
|
||||||
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
|
|||||||
remoteAssetContractVersion?: string | null;
|
remoteAssetContractVersion?: string | null;
|
||||||
navItems?: PlatformNavItem[];
|
navItems?: PlatformNavItem[];
|
||||||
routes?: PlatformRouteContribution[];
|
routes?: PlatformRouteContribution[];
|
||||||
|
publicRoutes?: PlatformPublicRouteContribution[];
|
||||||
translations?: PlatformTranslations;
|
translations?: PlatformTranslations;
|
||||||
uiCapabilities?: PlatformUiCapabilities;
|
uiCapabilities?: PlatformUiCapabilities;
|
||||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||||
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
asset_manifest_integrity?: string | null;
|
asset_manifest_integrity?: string | null;
|
||||||
asset_manifest_contract_version?: string | null;
|
asset_manifest_contract_version?: string | null;
|
||||||
routes: PlatformFrontendRouteInfo[];
|
routes: PlatformFrontendRouteInfo[];
|
||||||
|
public_routes: Array<{
|
||||||
|
path: string;
|
||||||
|
component: string;
|
||||||
|
order: number;
|
||||||
|
}>;
|
||||||
nav: Array<{
|
nav: Array<{
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
settings_routes: PlatformFrontendRouteInfo[];
|
settings_routes: PlatformFrontendRouteInfo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformPublicModuleInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
frontend: Pick<
|
||||||
|
PlatformFrontendModuleInfo,
|
||||||
|
| "module_id"
|
||||||
|
| "package_name"
|
||||||
|
| "asset_manifest"
|
||||||
|
| "asset_manifest_signature"
|
||||||
|
| "asset_manifest_public_key_id"
|
||||||
|
| "asset_manifest_integrity"
|
||||||
|
| "asset_manifest_contract_version"
|
||||||
|
| "public_routes"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformModuleInfo = {
|
export type PlatformModuleInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -31,16 +31,24 @@ const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
|
|||||||
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
|
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
|
||||||
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
|
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
|
||||||
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
|
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
|
||||||
|
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" },
|
||||||
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
|
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
|
||||||
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
|
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
|
||||||
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
|
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
|
||||||
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
|
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function helpContextForPathname(pathname: string): HelpContext {
|
export function helpContextForPathname(pathname: string, search = ""): HelpContext {
|
||||||
const route = pathname || "/";
|
const route = pathname || "/";
|
||||||
const segments = route.split("/").filter(Boolean);
|
const segments = route.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (segments[0] === "settings") {
|
||||||
|
const section = new URLSearchParams(search).get("section") || "";
|
||||||
|
if (section === "mail-profiles") {
|
||||||
|
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (segments[0] === "campaigns" && segments[1]) {
|
if (segments[0] === "campaigns" && segments[1]) {
|
||||||
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
|
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
|
||||||
if (segments[2] === "wizard") {
|
if (segments[2] === "wizard") {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
|
|||||||
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import Button from "../src/components/Button";
|
import Button from "../src/components/Button";
|
||||||
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid";
|
||||||
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
|
|||||||
);
|
);
|
||||||
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
||||||
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
||||||
|
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
|
||||||
|
|
||||||
const contextActions = renderToStaticMarkup(
|
const contextActions = renderToStaticMarkup(
|
||||||
<TableActionGroup
|
<TableActionGroup
|
||||||
actions={[
|
actions={[
|
||||||
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
||||||
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
|
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
|
||||||
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop }
|
{ id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
|
||||||
]}
|
]}
|
||||||
|
minimumSlots={4}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply");
|
assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
|
||||||
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
|
assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
|
||||||
|
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
|
||||||
|
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
|
||||||
|
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
|
||||||
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
|
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
|
||||||
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
|
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
|
||||||
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
||||||
@@ -58,11 +63,69 @@ assertEqual(propagationStopped, true, "table actions do not trigger clickable ro
|
|||||||
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
||||||
|
|
||||||
const noReorderActions = renderToStaticMarkup(
|
const noReorderActions = renderToStaticMarkup(
|
||||||
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
<DataGridRowActions reorderable={false} onAddBelow={noop} onRemove={noop} />
|
||||||
);
|
);
|
||||||
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
|
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
|
||||||
|
|
||||||
|
const unavailableReorderActions = renderToStaticMarkup(
|
||||||
|
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
||||||
|
);
|
||||||
|
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
|
||||||
|
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
|
||||||
|
|
||||||
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
|
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
|
||||||
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
||||||
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||||
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
|
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
|
||||||
|
|
||||||
|
type FilterRow = { id: string; name: string };
|
||||||
|
const filterRows: FilterRow[] = [
|
||||||
|
{ id: "first-non-match", name: "Ordinary row" },
|
||||||
|
{ id: "second-non-match", name: "Another row" },
|
||||||
|
{ id: "first-match", name: "Target alpha" },
|
||||||
|
{ id: "third-non-match", name: "Unrelated row" },
|
||||||
|
{ id: "second-match", name: "Target beta" },
|
||||||
|
{ id: "third-match", name: "Target gamma" }
|
||||||
|
];
|
||||||
|
const filterColumns: DataGridColumn<FilterRow>[] = [
|
||||||
|
{ id: "name", header: "Name", filterable: true, value: (row) => row.name }
|
||||||
|
];
|
||||||
|
const fullResultFilterMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="full-result-filter-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
initialFilters={{ name: "target" }}
|
||||||
|
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target alpha"), true, "client filtering finds matches beyond the unfiltered first page");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target beta"), true, "client filtering happens before the first page is sliced");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target gamma"), false, "client pagination still limits the filtered page size");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("1\u20132"), true, "pagination counts describe the filtered result set");
|
||||||
|
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?3/.test(fullResultFilterMarkup), true, "the filtered total includes matches from every source row");
|
||||||
|
|
||||||
|
const clearedFilterMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="cleared-full-result-filter-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(clearedFilterMarkup.includes("Ordinary row"), true, "clearing filters restores the first unfiltered row");
|
||||||
|
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?6/.test(clearedFilterMarkup), true, "clearing filters restores the full pagination total");
|
||||||
|
|
||||||
|
const externalShortcutMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="external-query-shortcut-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
query={{ sort: null, filters: { name: "target" } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query");
|
||||||
|
assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");
|
||||||
|
|||||||
18
webui/tests/help-context.test.ts
Normal file
18
webui/tests/help-context.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { helpContextForPathname } from "../src/utils/helpContext";
|
||||||
|
|
||||||
|
function assertEqual(actual: string, expected: string, message: string): void {
|
||||||
|
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(helpContextForPathname("/files").id, "files.list", "Files context");
|
||||||
|
assertEqual(helpContextForPathname("/mail").id, "mail.list", "Mail context");
|
||||||
|
assertEqual(
|
||||||
|
helpContextForPathname("/settings", "?section=mail-profiles").id,
|
||||||
|
"mail.profiles",
|
||||||
|
"Mail profile settings context"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
helpContextForPathname("/campaigns/campaign-1/attachments").id,
|
||||||
|
"campaign.attachments",
|
||||||
|
"Campaign attachment context"
|
||||||
|
);
|
||||||
@@ -15,7 +15,7 @@ function assertDeepEqual(actual: unknown, expected: unknown, message = "values s
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import CredentialPanel from "../src/components/CredentialPanel";
|
import CredentialPanel from "../src/components/CredentialPanel";
|
||||||
import PasswordField from "../src/components/PasswordField";
|
import PasswordField from "../src/components/PasswordField";
|
||||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
import MessageDisplayPanel, { buildSafeMessageHtmlDocument } from "../src/components/MessageDisplayPanel";
|
||||||
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
@@ -245,3 +245,44 @@ assert(messageDisplay.includes("agenda.pdf"), "direct attachment is rendered");
|
|||||||
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
||||||
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
|
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
|
||||||
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
||||||
|
|
||||||
|
const safeMessageDocument = buildSafeMessageHtmlDocument(`
|
||||||
|
<base href="https://tracking.example/">
|
||||||
|
<meta http-equiv="refresh" content="0;url=https://tracking.example/refresh">
|
||||||
|
<link rel="stylesheet" href="https://tracking.example/mail.css">
|
||||||
|
<style>
|
||||||
|
@import "https://tracking.example/import.css";
|
||||||
|
.remote { background-image: url(https://tracking.example/background.png); }
|
||||||
|
.embedded { background-image: url('cid:background@example.org'); }
|
||||||
|
</style>
|
||||||
|
<a href="https://tracking.example/click" ping="https://tracking.example/ping">Remote link</a>
|
||||||
|
<a href="#local-section">Local link</a>
|
||||||
|
<img src="https://tracking.example/pixel.gif" srcset="https://tracking.example/retina.gif 2x" onerror="fetch('https://tracking.example/error')">
|
||||||
|
<img src="cid:logo@example.org">
|
||||||
|
<img src="data:image/png;base64,iVBORw0KGgo=">
|
||||||
|
<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">
|
||||||
|
<script>fetch("https://tracking.example/script")</script>
|
||||||
|
`);
|
||||||
|
assert(safeMessageDocument.startsWith("<!doctype html><html><head>"), "message HTML is wrapped in an isolated document");
|
||||||
|
assert(safeMessageDocument.includes("default-src 'none'"), "message iframe CSP denies resources by default");
|
||||||
|
assert(safeMessageDocument.includes("img-src data: cid:"), "message iframe CSP permits only embedded image sources");
|
||||||
|
assert(safeMessageDocument.includes("connect-src 'none'"), "message iframe CSP blocks active network connections");
|
||||||
|
assert(safeMessageDocument.includes("form-action 'none'"), "message iframe CSP blocks form submissions");
|
||||||
|
assert(safeMessageDocument.includes('src="cid:logo@example.org"'), "CID image references remain available");
|
||||||
|
assert(safeMessageDocument.includes('src="data:image/png;base64,iVBORw0KGgo="'), "conservative raster data images remain available");
|
||||||
|
assert(safeMessageDocument.includes('href="#local-section"'), "same-document fragment links remain available");
|
||||||
|
assert(!safeMessageDocument.includes("tracking.example"), "remote message URLs are neutralized before preview");
|
||||||
|
assert(!safeMessageDocument.includes("data:image/svg+xml"), "active SVG data resources are rejected");
|
||||||
|
assert(!safeMessageDocument.includes("onerror="), "event-handler attributes are removed");
|
||||||
|
assert(!safeMessageDocument.includes("<script"), "script elements are removed");
|
||||||
|
|
||||||
|
const privateMessageDisplay = renderToStaticMarkup(
|
||||||
|
<MessageDisplayPanel
|
||||||
|
title="Untrusted message"
|
||||||
|
bodyHtml='<img src="https://tracking.example/pixel.gif"><img src="cid:logo@example.org">'
|
||||||
|
preferredBodyMode="html"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert(privateMessageDisplay.includes('sandbox=""'), "message HTML remains inside a scriptless iframe sandbox");
|
||||||
|
assert(privateMessageDisplay.includes('referrerPolicy="no-referrer"'), "message iframe suppresses referrer data");
|
||||||
|
assert(!privateMessageDisplay.includes("tracking.example"), "rendered iframe source contains no remote tracking URL");
|
||||||
|
|||||||
26
webui/tests/metric-card.test.tsx
Normal file
26
webui/tests/metric-card.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import MetricCard from "../src/components/MetricCard";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const translatedMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<MetricCard
|
||||||
|
label="i18n:govoplan-core.installed_modules.32b3e799"
|
||||||
|
value="i18n:govoplan-core.core_only.d46ce7d9"
|
||||||
|
detail="i18n:govoplan-core.install_the_campaign_module_to_show_campaign_act.3085f23e"
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(translatedMarkup.includes('class="metric-card metric-info"'), "the visual tone is preserved");
|
||||||
|
assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated");
|
||||||
|
assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated");
|
||||||
|
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
|
||||||
|
|
||||||
|
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
|
||||||
|
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");
|
||||||
68
webui/tests/people-picker.test.tsx
Normal file
68
webui/tests/people-picker.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import PeoplePicker from "../src/components/people/PeoplePicker";
|
||||||
|
import {
|
||||||
|
dedupePeoplePickerItems,
|
||||||
|
externalPeoplePickerItem,
|
||||||
|
selectionFromPeoplePickerCandidate,
|
||||||
|
type PeoplePickerSearch
|
||||||
|
} from "../src/components/people/peoplePickerTypes";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
selection_key: "account:account-1",
|
||||||
|
kind: "account" as const,
|
||||||
|
reference_id: "account-1",
|
||||||
|
display_name: "Ada Lovelace",
|
||||||
|
email: "Ada@Example.Test",
|
||||||
|
source_module: "access",
|
||||||
|
source_label: "Accounts"
|
||||||
|
};
|
||||||
|
const contact = {
|
||||||
|
selection_key: "contact:contact-1:ada@example.test",
|
||||||
|
kind: "contact" as const,
|
||||||
|
reference_id: "contact-1",
|
||||||
|
display_name: "Ada Contact",
|
||||||
|
email: "ada@example.test",
|
||||||
|
source_module: "addresses",
|
||||||
|
source_label: "Contacts"
|
||||||
|
};
|
||||||
|
|
||||||
|
const deduped = dedupePeoplePickerItems([account, contact]);
|
||||||
|
assert(deduped.length === 1, "the same email is selected only once across directory sources");
|
||||||
|
assert(deduped[0].reference_id === "account-1", "selection order decides which discoverable reference is retained");
|
||||||
|
|
||||||
|
const external = externalPeoplePickerItem(" Grace Hopper ", " GRACE@EXAMPLE.TEST ");
|
||||||
|
assert(external.display_name === "Grace Hopper", "manual names are normalized");
|
||||||
|
assert(external.email === "grace@example.test", "manual email addresses are normalized");
|
||||||
|
assert(external.selection_key === "external:grace@example.test", "manual entries receive stable opaque selection keys");
|
||||||
|
|
||||||
|
const selected = selectionFromPeoplePickerCandidate({ ...contact, disabled: true, disabled_reason: "Policy" });
|
||||||
|
assert(!("disabled" in selected), "result-only disabled state is not persisted in selections");
|
||||||
|
assert(!("disabled_reason" in selected), "result-only policy wording is not persisted in selections");
|
||||||
|
|
||||||
|
let searchCalled = false;
|
||||||
|
const search: PeoplePickerSearch = async () => {
|
||||||
|
searchCalled = true;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<PeoplePicker id="reviewers" value={[account]} onChange={() => undefined} search={search} help="Choose a reviewer." />
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(!searchCalled, "directory search is server-driven after user input, not during static rendering");
|
||||||
|
assert(markup.includes('role="combobox"'), "search exposes combobox semantics");
|
||||||
|
assert(markup.includes('aria-autocomplete="list"'), "search declares list autocomplete behavior");
|
||||||
|
assert(markup.includes('aria-controls="reviewers-results"'), "search and grouped result list are associated");
|
||||||
|
assert(markup.includes("inline-help"), "non-obvious picker fields use central inline help");
|
||||||
|
assert(markup.includes('role="table"'), "selected people are rendered through the central DataGrid");
|
||||||
|
assert(markup.includes("Ada Lovelace"), "selected structured rows retain the display name");
|
||||||
|
assert(markup.includes("Ada@Example.Test"), "selected structured rows retain the email address");
|
||||||
|
assert(markup.includes('aria-label="Add person"'), "row add uses an accessible central table action");
|
||||||
|
assert(markup.includes('aria-label="Remove person"'), "row removal uses an accessible central table action");
|
||||||
|
assert(markup.includes("Add external person"), "manual external entry remains a separate structured action");
|
||||||
@@ -23,12 +23,17 @@
|
|||||||
"tests/explorer-tree.test.tsx",
|
"tests/explorer-tree.test.tsx",
|
||||||
"tests/icon-button.test.tsx",
|
"tests/icon-button.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
|
"tests/metric-card.test.tsx",
|
||||||
|
"tests/people-picker.test.tsx",
|
||||||
"tests/resource-access-explanation.test.tsx",
|
"tests/resource-access-explanation.test.tsx",
|
||||||
"tests/selection-list.test.tsx",
|
"tests/selection-list.test.tsx",
|
||||||
"src/components/CredentialPanel.tsx",
|
"src/components/CredentialPanel.tsx",
|
||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
"src/components/PasswordField.tsx",
|
"src/components/PasswordField.tsx",
|
||||||
"src/components/MessageDisplayPanel.tsx",
|
"src/components/MessageDisplayPanel.tsx",
|
||||||
|
"src/components/MetricCard.tsx",
|
||||||
|
"src/components/people/PeoplePicker.tsx",
|
||||||
|
"src/components/people/peoplePickerTypes.ts",
|
||||||
"src/components/ResourceAccessExplanation.tsx",
|
"src/components/ResourceAccessExplanation.tsx",
|
||||||
"src/components/SelectionList.tsx",
|
"src/components/SelectionList.tsx",
|
||||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||||
@@ -42,6 +47,7 @@
|
|||||||
"src/components/IconButton.tsx",
|
"src/components/IconButton.tsx",
|
||||||
"src/components/admin/AdminIconButton.tsx",
|
"src/components/admin/AdminIconButton.tsx",
|
||||||
"src/components/ToggleSwitch.tsx",
|
"src/components/ToggleSwitch.tsx",
|
||||||
|
"src/components/table/DataGrid.tsx",
|
||||||
"src/components/table/TableActionGroup.tsx"
|
"src/components/table/TableActionGroup.tsx"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"tests/module-capabilities.test.ts",
|
"tests/module-capabilities.test.ts",
|
||||||
"tests/privacy-policy.test.ts",
|
"tests/privacy-policy.test.ts",
|
||||||
|
"tests/help-context.test.ts",
|
||||||
"src/platform/moduleLogic.ts",
|
"src/platform/moduleLogic.ts",
|
||||||
|
"src/utils/helpContext.ts",
|
||||||
"src/features/privacy/policyLogic.ts",
|
"src/features/privacy/policyLogic.ts",
|
||||||
"src/types.ts"
|
"src/types.ts"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user