Compare commits
23 Commits
fa9119bea7
...
v0.1.12
| Author | SHA1 | Date | |
|---|---|---|---|
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 | |||
| 987ca894ed | |||
| 4caa326878 | |||
| 8c4c4456c6 | |||
| 6abe292ac8 | |||
| fea2807754 | |||
| 22646c614c | |||
| 17376332a2 | |||
| 0946bc84a9 | |||
| a18499cbb5 | |||
| 36d7b73bb5 | |||
| b89a2d15f1 | |||
| 7f923afdad | |||
| a7683c5d4a | |||
| 41ad057f7e | |||
| bf0729eb59 | |||
| c4b90181e0 | |||
| 55ed194a99 | |||
| b3b0cf0fca |
@@ -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,256 @@
|
|||||||
],
|
],
|
||||||
"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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 1
|
"version": 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.9"
|
version = "0.1.12"
|
||||||
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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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()
|
||||||
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.12",
|
||||||
"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.12",
|
||||||
"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,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.12",
|
||||||
"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.12",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.12",
|
||||||
"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.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{adminDocsAvailable &&
|
||||||
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
<hr />
|
<hr />
|
||||||
<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"
|
||||||
|
);
|
||||||
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