Compare commits
18 Commits
fa9119bea7
...
v0.1.11
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
||||
|
||||
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
|
||||
only when every authenticated tenant member must receive that narrow baseline
|
||||
while the contributing module is installed. Access derives the explicit grant
|
||||
from the active manifest set during authorization without mutating the request
|
||||
transaction. It may materialize a non-assignable role row for administration,
|
||||
but no per-user assignment is required and role edits cannot remove the
|
||||
baseline.
|
||||
This is not a shortcut for feature authorization: keep the template narrow and
|
||||
continue to enforce each domain action's own permission and resource policy.
|
||||
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
|
||||
templates are rejected by registry validation.
|
||||
|
||||
Backend nav metadata must use icon-name strings, not frontend components:
|
||||
|
||||
```python
|
||||
|
||||
@@ -57,6 +57,11 @@ The trust layer should provide:
|
||||
- key rotation and epoch tracking
|
||||
- recovery policy hooks
|
||||
|
||||
Recovery must be organizationally governed. A server-held universal plaintext
|
||||
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
|
||||
grant needs an explicit assurance profile, authority/quorum, audit trail, and
|
||||
user-visible consequence.
|
||||
|
||||
## Role And Function Postboxes
|
||||
|
||||
Role-bound access needs special handling. A postbox can be bound to an
|
||||
@@ -75,6 +80,30 @@ rewrapping service:
|
||||
Key epochs are required when role membership changes. Older messages may remain
|
||||
readable according to policy, but new access must use the current epoch.
|
||||
|
||||
The function-bound container exists independently of membership. It may remain
|
||||
vacant and continue to receive ciphertext without falling back to an unrelated
|
||||
personal mailbox. Zero, one, or several incumbents are valid states. Each
|
||||
incumbent receives an independently auditable, device-bound wrapped-key path;
|
||||
the postbox is never copied into their account ownership.
|
||||
|
||||
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
|
||||
encryption permits the normal rotation path to rewrap per-message data keys
|
||||
rather than rewrite large ciphertext objects; a security policy may require
|
||||
full content re-encryption for selected compromise or cryptographic-profile
|
||||
events. The history available to a new incumbent must be selected policy (all
|
||||
retained history, a bounded historical window, or assignment-time content) and
|
||||
recorded with the grant.
|
||||
|
||||
Delegation is a time-bounded represented-function grant, not a copy or
|
||||
substitution of the postbox. Expiry or withdrawal stops future key release and
|
||||
actions. It cannot revoke plaintext already decrypted, printed, exported, or
|
||||
captured outside the platform. Multiple simultaneous incumbents and delegates
|
||||
remain distinguishable in key-fetch and action evidence.
|
||||
|
||||
Postbox content and signed manifests are immutable. Correction or replacement
|
||||
creates a linked new object/version; it never silently substitutes ciphertext
|
||||
or evidence that another actor may already have inspected.
|
||||
|
||||
## External Recipients
|
||||
|
||||
External recipients may need one-time or time-limited access without a full
|
||||
|
||||
21
docs/THROTTLING.md
Normal file
21
docs/THROTTLING.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Shared fixed-window throttling
|
||||
|
||||
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||
become store keys. A configured Redis instance provides atomic counters shared
|
||||
across API workers; development, a missing Redis configuration, and temporary
|
||||
Redis outages use a bounded process-local fallback. When Redis fails, local
|
||||
attempts are still mirrored so losing the distributed store does not reset the
|
||||
active worker's protection window.
|
||||
|
||||
Callers define one or more `ThrottleDimension` values with a controlled
|
||||
namespace, a subject and a positive limit. They must call `check` before an
|
||||
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
|
||||
dimension after successful verification. A blocked decision includes a
|
||||
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
|
||||
|
||||
The first consumer is Scheduling's anonymous participation password challenge.
|
||||
Its namespace is `poll-participation-password`; its subject combines tenant,
|
||||
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
|
||||
login throttle predates this primitive and should be migrated onto it in a
|
||||
separate compatibility-preserving slice.
|
||||
@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
||||
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
|
||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
|
||||
@@ -191,9 +195,10 @@ explicitly retains the exception.
|
||||
Decision: Scheduling requests provide a concrete reference application of the
|
||||
universal placement and component rules.
|
||||
|
||||
- The `Scheduling requests` header owns one `Add` action.
|
||||
- Its left panel is used for the creation view only, not as a permanent second
|
||||
creation affordance.
|
||||
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||
requests for me`; it is list context, not a second creation affordance.
|
||||
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
|
||||
the shared view/create/edit surface in the right main panel.
|
||||
- Basic information, Calendar integration, candidate slots, and participants
|
||||
use the central `Card` component as four logical sections.
|
||||
- Candidate slots and participants use the central `DataGrid`, including its
|
||||
@@ -207,6 +212,50 @@ universal placement and component rules.
|
||||
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||
not Scheduling-local component variants.
|
||||
|
||||
### DUE-011: Field Help, Discard, And Table Action Contracts
|
||||
|
||||
Decision: the central components own these interactions; modules compose them
|
||||
instead of reproducing their behavior.
|
||||
|
||||
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
|
||||
compositions use `FieldLabel` explicitly when the meaning or limitation is
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||
the same shared unsaved-changes dialog. A browser tab/window unload remains a
|
||||
browser-controlled confirmation because browsers do not permit a custom
|
||||
modal at that boundary.
|
||||
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
|
||||
`disabledReason` for row state; omit an action only when that action does not
|
||||
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||
|
||||
#### FieldLabel Omission Register
|
||||
|
||||
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||
listed here. Module repositories keep an equivalent register in their durable
|
||||
UI documentation until a central cross-repository audit is available.
|
||||
|
||||
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||
| --- | --- | --- |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
|
||||
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
|
||||
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
|
||||
|
||||
#### Alert Exception Register
|
||||
|
||||
No `window.alert` or global `alert` exception is authorized.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
| Phase | Scope | Output |
|
||||
@@ -277,6 +326,14 @@ Every new or changed admin/configuration surface should answer:
|
||||
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||
structured row per person respectively?
|
||||
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
|
||||
recorded with its rationale and accessible-name source?
|
||||
- Do explicit Discard and dirty navigation use the shared unsaved-changes
|
||||
registration/dialog rather than a page-local confirmation?
|
||||
- Does every row retain the table's action set in the same order, disabling
|
||||
unavailable actions and reserving the same empty-row slots?
|
||||
- Is feedback rendered with a central dialog/alert component, with no
|
||||
unauthorized `window.alert` or global `alert` call?
|
||||
- If automation is involved, can the user see the trigger, system actor,
|
||||
observed effects, and failure/manual-intervention state?
|
||||
- Are technical details available without being the first thing the user sees?
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
],
|
||||
"recorded_at": "2026-07-11T01:39:45Z",
|
||||
"release": "0.1.7",
|
||||
"track": "release",
|
||||
"squash_policy": "reviewed-manual"
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
@@ -165,8 +165,132 @@
|
||||
],
|
||||
"recorded_at": "2026-07-20T02:45:41Z",
|
||||
"release": "0.1.8",
|
||||
"track": "release",
|
||||
"squash_policy": "reviewed-manual"
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "4d5e6f7a9203"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"4d5e6f7a9203"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T02:42:27Z",
|
||||
"release": "0.1.11",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.9"
|
||||
version = "0.1.11"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -340,6 +340,7 @@ CELERY_ENABLED=true
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
# Deployment-wide connector egress policy. Enable private networks only when
|
||||
# this installation intentionally integrates with internal services.
|
||||
@@ -405,6 +406,7 @@ REDIS_URL=redis://127.0.0.1:56379/0
|
||||
CELERY_ENABLED=true
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
|
||||
@@ -45,6 +45,7 @@ class RoleTemplate:
|
||||
level: PermissionLevel = "tenant"
|
||||
managed: bool = True
|
||||
protected: bool = False
|
||||
default_authenticated: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -69,6 +70,15 @@ class FrontendRoute:
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicFrontendRoute:
|
||||
"""Explicitly allowlisted route that can render without authentication."""
|
||||
|
||||
path: str
|
||||
component: str
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendModule:
|
||||
module_id: str
|
||||
@@ -79,6 +89,7 @@ class FrontendModule:
|
||||
asset_manifest_integrity: str | None = None
|
||||
asset_manifest_contract_version: str = "1"
|
||||
routes: tuple[FrontendRoute, ...] = ()
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
|
||||
@@ -236,6 +247,35 @@ class DocumentationTopic:
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
Topic conditions are alternatives. Every alternative therefore needs an
|
||||
explicit scope constraint; otherwise one unscoped alternative would make
|
||||
the workflow visible regardless of the actor's task authority.
|
||||
"""
|
||||
|
||||
raw_kind = topic.metadata.get("kind")
|
||||
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||
return ()
|
||||
if not topic.conditions:
|
||||
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||
|
||||
unscoped_alternatives = tuple(
|
||||
index
|
||||
for index, condition in enumerate(topic.conditions, start=1)
|
||||
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||
)
|
||||
if not unscoped_alternatives:
|
||||
return ()
|
||||
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
|
||||
return (
|
||||
"every user workflow condition alternative must declare required_scopes or any_scopes; "
|
||||
f"unscoped alternative(s): {alternatives}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationContext:
|
||||
registry: object
|
||||
|
||||
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
|
||||
|
||||
# Identity search is deliberately absent here. ``identity.search`` is an
|
||||
# instance-wide canonical-identity directory and has no tenant/principal
|
||||
# visibility contract. Ordinary task pickers must use principal-aware search
|
||||
# providers instead.
|
||||
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
)
|
||||
|
||||
|
||||
class PeopleSearchError(ValueError):
|
||||
"""Stable, non-diagnostic error raised by people-search providers."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonSearchCandidate:
|
||||
"""A policy-filtered selection target returned by a directory provider."""
|
||||
|
||||
selection_key: str
|
||||
kind: str
|
||||
reference_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
source_module: str | None = None
|
||||
source_label: str | None = None
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
description: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeopleSearchGroup:
|
||||
key: str
|
||||
label: str
|
||||
candidates: tuple[PersonSearchCandidate, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PeopleSearchProvider(Protocol):
|
||||
"""Server-side, principal-aware people/delivery-target search.
|
||||
|
||||
Implementations must derive visibility from ``principal`` and may only
|
||||
return records the principal can discover in its active tenant and policy
|
||||
context. Callers remain responsible for authorizing the task for which the
|
||||
picker is used (for example, creating a scheduling request).
|
||||
"""
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> Sequence[PeopleSearchGroup]:
|
||||
...
|
||||
|
||||
|
||||
def people_search_providers(
|
||||
registry: object | None,
|
||||
*,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchProvider, ...]:
|
||||
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
|
||||
return ()
|
||||
|
||||
providers: list[PeopleSearchProvider] = []
|
||||
for capability_name in capability_names:
|
||||
if not registry.has_capability(capability_name):
|
||||
continue
|
||||
capability = registry.capability(capability_name)
|
||||
if isinstance(capability, PeopleSearchProvider):
|
||||
providers.append(capability)
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def search_visible_people(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
"""Collect grouped results without importing optional feature modules.
|
||||
|
||||
``limit`` is enforced per provider so one directory cannot starve another
|
||||
result group. Providers are expected to return at most that many candidates
|
||||
in total. Duplicate group keys are merged and candidate selection keys are
|
||||
de-duplicated while preserving provider order.
|
||||
"""
|
||||
|
||||
normalized_query = str(query or "").strip()
|
||||
normalized_limit = max(1, min(int(limit), 100))
|
||||
ordered_group_keys: list[str] = []
|
||||
labels: dict[str, str] = {}
|
||||
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
|
||||
candidate_keys_by_group: dict[str, set[str]] = {}
|
||||
|
||||
for provider in people_search_providers(registry, capability_names=capability_names):
|
||||
groups = provider.search_people(
|
||||
session,
|
||||
principal,
|
||||
query=normalized_query,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
provider_candidate_count = 0
|
||||
for group in groups:
|
||||
if not group.key:
|
||||
continue
|
||||
if group.key not in candidates_by_group:
|
||||
ordered_group_keys.append(group.key)
|
||||
labels[group.key] = group.label
|
||||
candidates_by_group[group.key] = []
|
||||
candidate_keys_by_group[group.key] = set()
|
||||
for candidate in group.candidates:
|
||||
if provider_candidate_count >= normalized_limit:
|
||||
break
|
||||
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
|
||||
continue
|
||||
candidate_keys_by_group[group.key].add(candidate.selection_key)
|
||||
candidates_by_group[group.key].append(candidate)
|
||||
provider_candidate_count += 1
|
||||
|
||||
return tuple(
|
||||
PeopleSearchGroup(
|
||||
key=group_key,
|
||||
label=labels[group_key],
|
||||
candidates=tuple(candidates_by_group[group_key]),
|
||||
)
|
||||
for group_key in ordered_group_keys
|
||||
if candidates_by_group[group_key]
|
||||
)
|
||||
|
||||
|
||||
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
|
||||
normalized_kind = str(kind).strip().casefold()
|
||||
normalized_reference = str(reference_id).strip()
|
||||
normalized_email = str(email or "").strip().casefold()
|
||||
if not normalized_kind or not normalized_reference:
|
||||
raise ValueError("Person selection keys require a kind and reference id.")
|
||||
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
|
||||
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
|
||||
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
|
||||
"PeopleSearchError",
|
||||
"PeopleSearchGroup",
|
||||
"PeopleSearchProvider",
|
||||
"PersonSearchCandidate",
|
||||
"people_search_providers",
|
||||
"person_selection_key",
|
||||
"search_visible_people",
|
||||
]
|
||||
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionOrderCommand:
|
||||
"""Complete active option order supplied by an owning module.
|
||||
|
||||
Providers must reject partial, duplicate, or unknown option collections.
|
||||
An exact repeat is an idempotent no-op. Reordering changes positions only;
|
||||
option identities and their existing answers remain valid.
|
||||
"""
|
||||
|
||||
option_ids: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationCommand:
|
||||
respondent_id: str | None = None
|
||||
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementCommand:
|
||||
"""Retire live responses while preserving their auditable history.
|
||||
|
||||
Owning modules provide every server-trusted identity that can refer to the
|
||||
participant. Providers must soft-delete matching live responses, retain
|
||||
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||
"""
|
||||
|
||||
respondent_ids: tuple[str, ...] = ()
|
||||
invitation_id: str | None = None
|
||||
reason: str = "participant_removed"
|
||||
idempotency_key: str = ""
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionRef:
|
||||
id: str
|
||||
@@ -138,6 +166,16 @@ class PollResponseRef:
|
||||
answers: tuple[PollAnswerRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementRef:
|
||||
"""Outcome of an auditable response-retirement request."""
|
||||
|
||||
response_ids: tuple[str, ...] = ()
|
||||
retired_at: datetime | None = None
|
||||
newly_retired_count: int = 0
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollSchedulingProvider(Protocol):
|
||||
def create_poll(
|
||||
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
|
||||
|
||||
...
|
||||
|
||||
def reorder_options(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionOrderCommand,
|
||||
) -> PollRef:
|
||||
"""Atomically replace the complete active option order."""
|
||||
|
||||
...
|
||||
|
||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||
...
|
||||
|
||||
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollResponseRetirementProvider(Protocol):
|
||||
"""Optional extension for owning modules that remove participants."""
|
||||
|
||||
def retire_responses(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollResponseRetirementCommand,
|
||||
) -> PollResponseRetirementRef:
|
||||
...
|
||||
|
||||
|
||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
|
||||
) -> PollResponseSubmissionProvider | None:
|
||||
provider = poll_scheduling_provider(registry)
|
||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||
|
||||
|
||||
def poll_response_retirement_provider(
|
||||
registry: object | None,
|
||||
) -> PollResponseRetirementProvider | None:
|
||||
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, PollResponseRetirementProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
272
src/govoplan_core/core/poll_participation.py
Normal file
272
src/govoplan_core/core/poll_participation.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.poll import (
|
||||
PollAnswerRequest,
|
||||
PollInvitationRef,
|
||||
PollOptionRequest,
|
||||
PollResponseRef,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
|
||||
# Policy-attestation identifier, not a credential or credential default.
|
||||
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
|
||||
PARTICIPATION_POLICY_VERSION = 1
|
||||
|
||||
|
||||
def participation_token_fingerprint(token: str) -> str:
|
||||
"""Return a non-reversible identifier suitable for audit/throttle keys."""
|
||||
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseGatewayRef:
|
||||
"""Stable identity of the module resource governing a participation link."""
|
||||
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationPolicy:
|
||||
"""Generic response rules snapshotted onto one signed invitation.
|
||||
|
||||
``single_choice`` treats every non-``unavailable`` availability answer as
|
||||
a selection. Capacity is reserved only by ``available`` answers (and by
|
||||
selected answers for non-availability polls), so ``maybe`` never consumes
|
||||
a place.
|
||||
"""
|
||||
|
||||
version: int = PARTICIPATION_POLICY_VERSION
|
||||
single_choice: bool = False
|
||||
allow_maybe: bool = True
|
||||
max_participants_per_option: int | None = None
|
||||
allow_comments: bool = False
|
||||
participant_email_required: bool = False
|
||||
anonymous_password_required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedInvitationCommand:
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseCommand:
|
||||
"""Submission already authorized by the named in-process gateway.
|
||||
|
||||
Passwords never cross this boundary. A gateway that owns a password
|
||||
verifier reports the completed check through ``verified_requirements``.
|
||||
Poll independently re-enforces the remaining snapshotted rules while
|
||||
holding its Poll-row lock.
|
||||
"""
|
||||
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
participant_email: str | None = None
|
||||
participant_is_authenticated: bool = False
|
||||
answers: tuple[PollAnswerRequest, ...] = ()
|
||||
comment: str | None = None
|
||||
verified_requirements: frozenset[str] = frozenset()
|
||||
idempotency_key: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseRef:
|
||||
response: PollResponseRef
|
||||
participant_email: str | None = None
|
||||
comment: str | None = None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionMutationRef:
|
||||
id: str
|
||||
position: int
|
||||
replayed: bool = False
|
||||
invalidated_response_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationRevocationRef:
|
||||
id: str
|
||||
revoked_at: datetime
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationExpiryRef:
|
||||
id: str
|
||||
expires_at: datetime | None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationContextRef:
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
response: PollGovernedResponseRef | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollParticipationGatewayProvider(Protocol):
|
||||
def create_governed_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollGovernedInvitationCommand,
|
||||
) -> PollInvitationRef:
|
||||
...
|
||||
|
||||
def resolve_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str | None = None,
|
||||
participant_email: str | None = None,
|
||||
participant_is_authenticated: bool = False,
|
||||
verified_requirements: frozenset[str] = frozenset(),
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve and prefill one valid invitation for the exact gateway."""
|
||||
|
||||
...
|
||||
|
||||
def submit_governed_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
...
|
||||
|
||||
def resolve_authenticated_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve one governed invitation without retaining its public token."""
|
||||
|
||||
...
|
||||
|
||||
def submit_authenticated_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
"""Submit atomically for the exact authenticated invitation identity."""
|
||||
|
||||
...
|
||||
|
||||
def add_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionRequest,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def remove_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
option_id: str,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def revoke_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
) -> PollInvitationRevocationRef:
|
||||
...
|
||||
|
||||
def update_invitation_expiry(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
expires_at: datetime | None,
|
||||
) -> PollInvitationExpiryRef:
|
||||
"""Expire or extend a non-revoked governed invitation in place."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
def poll_participation_gateway_provider(
|
||||
registry: object | None,
|
||||
) -> PollParticipationGatewayProvider | None:
|
||||
"""Resolve the governed Poll gateway without importing its implementation."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
||||
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
||||
"PARTICIPATION_POLICY_VERSION",
|
||||
"PollGovernedInvitationCommand",
|
||||
"PollGovernedResponseCommand",
|
||||
"PollGovernedResponseRef",
|
||||
"PollInvitationExpiryRef",
|
||||
"PollInvitationRevocationRef",
|
||||
"PollOptionMutationRef",
|
||||
"PollParticipationContextRef",
|
||||
"PollParticipationGatewayProvider",
|
||||
"PollParticipationPolicy",
|
||||
"PollResponseGatewayRef",
|
||||
"participation_token_fingerprint",
|
||||
"poll_participation_gateway_provider",
|
||||
]
|
||||
@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
PublicFrontendRoute,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
|
||||
@@ -194,6 +196,7 @@ class PlatformRegistry:
|
||||
available_capabilities=available_capabilities,
|
||||
)
|
||||
permissions = _collect_manifest_permissions(ordered)
|
||||
_validate_public_frontend_route_uniqueness(ordered)
|
||||
_validate_interface_closure(ordered)
|
||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||
|
||||
@@ -320,9 +323,34 @@ def _validate_role_template_scopes(
|
||||
*,
|
||||
known_scopes: set[str],
|
||||
) -> None:
|
||||
seen_templates: dict[tuple[str, str], str] = {}
|
||||
for manifest in manifests:
|
||||
for template in manifest.role_templates:
|
||||
template_key = (template.level, template.slug)
|
||||
previous_module = seen_templates.get(template_key)
|
||||
if previous_module is not None:
|
||||
raise RegistryError(
|
||||
f"Duplicate {template.level} role template slug {template.slug!r} "
|
||||
f"in modules {previous_module!r} and {manifest.id!r}"
|
||||
)
|
||||
seen_templates[template_key] = manifest.id
|
||||
if template.default_authenticated and template.level != "tenant":
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} must be tenant-level"
|
||||
)
|
||||
if template.default_authenticated and not template.managed:
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} must be managed"
|
||||
)
|
||||
for scope in template.permissions:
|
||||
if template.default_authenticated and (
|
||||
scope in {"*", "tenant:*", "system:*"}
|
||||
or _WILDCARD_RE.match(scope)
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} "
|
||||
"must use explicit permissions, not wildcard scopes"
|
||||
)
|
||||
if _role_template_scope_known(scope, known_scopes):
|
||||
continue
|
||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||
@@ -344,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_manifest_frontend(manifest)
|
||||
for item in manifest.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
for topic in manifest.documentation:
|
||||
for issue in user_workflow_scope_condition_issues(topic):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||
@@ -403,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
@@ -416,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||
|
||||
|
||||
def _validate_public_frontend_route_uniqueness(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
) -> None:
|
||||
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
|
||||
for manifest in manifests:
|
||||
if manifest.frontend is None:
|
||||
continue
|
||||
for route in manifest.frontend.public_routes:
|
||||
previous = owners.get(route.path)
|
||||
if previous is not None:
|
||||
previous_module, _previous_route = previous
|
||||
raise RegistryError(
|
||||
f"Duplicate public frontend route {route.path!r} in modules "
|
||||
f"{previous_module!r} and {manifest.id!r}"
|
||||
)
|
||||
owners[route.path] = (manifest.id, route)
|
||||
|
||||
|
||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
for manifest in manifests:
|
||||
|
||||
349
src/govoplan_core/core/throttling.py
Normal file
349
src/govoplan_core/core/throttling.py
Normal file
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||
_REDIS_INCREMENT_SCRIPT = """
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {count, ttl}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FixedWindowBucket:
|
||||
count: int = 0
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThrottleDimension:
|
||||
"""One independently limited dimension without putting its value in keys."""
|
||||
|
||||
namespace: str
|
||||
subject: str
|
||||
limit: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThrottleDecision:
|
||||
allowed: bool
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
class FixedWindowStore(Protocol):
|
||||
def read(self, key: str) -> FixedWindowBucket: ...
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
|
||||
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryFixedWindowStore:
|
||||
"""Bounded process-local store for development and distributed-store loss."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_entries: int = 10_000,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._entries: dict[str, tuple[int, float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_entries = max(2, max_entries)
|
||||
self._clock = clock
|
||||
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
return FixedWindowBucket()
|
||||
count, expires_at = entry
|
||||
return FixedWindowBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
self._make_room(now=now, incoming_key=key)
|
||||
count = 1
|
||||
expires_at = now + window_seconds
|
||||
else:
|
||||
count = entry[0] + 1
|
||||
expires_at = entry[1]
|
||||
self._entries[key] = (count, expires_at)
|
||||
return FixedWindowBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry[1] <= now:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||
return
|
||||
for key, (_count, expires_at) in tuple(self._entries.items()):
|
||||
if expires_at <= now:
|
||||
self._entries.pop(key, None)
|
||||
while len(self._entries) >= self._max_entries:
|
||||
self._entries.pop(next(iter(self._entries)))
|
||||
|
||||
|
||||
class RedisFixedWindowStore:
|
||||
"""Atomic Redis fixed-window counters shared across API workers."""
|
||||
|
||||
def __init__(self, redis_url: str) -> None:
|
||||
self._client = Redis.from_url(
|
||||
redis_url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=0.25,
|
||||
socket_timeout=0.25,
|
||||
health_check_interval=30,
|
||||
)
|
||||
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
pipeline = self._client.pipeline(transaction=False)
|
||||
pipeline.get(key)
|
||||
pipeline.ttl(key)
|
||||
raw_count, raw_ttl = pipeline.execute()
|
||||
return FixedWindowBucket(
|
||||
count=int(raw_count or 0),
|
||||
retry_after_seconds=max(0, int(raw_ttl or 0)),
|
||||
)
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
result = self._client.eval(
|
||||
_REDIS_INCREMENT_SCRIPT,
|
||||
1,
|
||||
key,
|
||||
window_seconds,
|
||||
)
|
||||
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||
raise RedisError("Unexpected fixed-window throttle response from Redis")
|
||||
return FixedWindowBucket(
|
||||
count=int(result[0]),
|
||||
retry_after_seconds=max(1, int(result[1])),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._client.delete(key)
|
||||
|
||||
|
||||
class ResilientFixedWindowStore:
|
||||
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
primary: FixedWindowStore | None,
|
||||
fallback: FixedWindowStore,
|
||||
*,
|
||||
retry_seconds: int = 30,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._primary = primary
|
||||
self._fallback = fallback
|
||||
self._retry_seconds = max(1, retry_seconds)
|
||||
self._clock = clock
|
||||
self._primary_unavailable_until = 0.0
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
def read(self, key: str) -> FixedWindowBucket:
|
||||
fallback_result = self._fallback.read(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return fallback_result
|
||||
try:
|
||||
return _stricter_bucket(primary.read(key), fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||
fallback_result = self._fallback.increment(
|
||||
key,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return fallback_result
|
||||
try:
|
||||
primary_result = primary.increment(
|
||||
key,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
return _stricter_bucket(primary_result, fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._fallback.delete(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return
|
||||
try:
|
||||
primary.delete(key)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
|
||||
def _available_primary(self) -> FixedWindowStore | None:
|
||||
if self._primary is None:
|
||||
return None
|
||||
with self._state_lock:
|
||||
if self._clock() < self._primary_unavailable_until:
|
||||
return None
|
||||
return self._primary
|
||||
|
||||
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||
should_log = False
|
||||
with self._state_lock:
|
||||
now = self._clock()
|
||||
if now >= self._primary_unavailable_until:
|
||||
should_log = True
|
||||
self._primary_unavailable_until = now + self._retry_seconds
|
||||
if should_log:
|
||||
logger.warning(
|
||||
"Distributed throttling is unavailable; using the process-local fallback (%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _stricter_bucket(
|
||||
first: FixedWindowBucket,
|
||||
second: FixedWindowBucket,
|
||||
) -> FixedWindowBucket:
|
||||
return FixedWindowBucket(
|
||||
count=max(first.count, second.count),
|
||||
retry_after_seconds=max(
|
||||
first.retry_after_seconds,
|
||||
second.retry_after_seconds,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FixedWindowThrottle:
|
||||
def __init__(
|
||||
self,
|
||||
store: FixedWindowStore,
|
||||
*,
|
||||
window_seconds: int,
|
||||
key_prefix: str = "govoplan:throttle:v1",
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._window_seconds = max(1, window_seconds)
|
||||
self._key_prefix = key_prefix.rstrip(":")
|
||||
|
||||
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||
return self._decision(tuple(dimensions), increment=False)
|
||||
|
||||
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||
return self._decision(tuple(dimensions), increment=True)
|
||||
|
||||
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
|
||||
for dimension in tuple(dimensions):
|
||||
self._store.delete(self._key(dimension))
|
||||
|
||||
def _decision(
|
||||
self,
|
||||
dimensions: tuple[ThrottleDimension, ...],
|
||||
*,
|
||||
increment: bool,
|
||||
) -> ThrottleDecision:
|
||||
if not dimensions:
|
||||
raise ValueError("At least one throttle dimension is required")
|
||||
blocked_retry_after = 0
|
||||
for dimension in dimensions:
|
||||
if dimension.limit < 1:
|
||||
raise ValueError("Throttle limits must be positive")
|
||||
key = self._key(dimension)
|
||||
state = (
|
||||
self._store.increment(key, window_seconds=self._window_seconds)
|
||||
if increment
|
||||
else self._store.read(key)
|
||||
)
|
||||
if state.count >= dimension.limit:
|
||||
blocked_retry_after = max(
|
||||
blocked_retry_after,
|
||||
max(1, state.retry_after_seconds),
|
||||
)
|
||||
return ThrottleDecision(
|
||||
allowed=blocked_retry_after == 0,
|
||||
retry_after_seconds=blocked_retry_after,
|
||||
)
|
||||
|
||||
def _key(self, dimension: ThrottleDimension) -> str:
|
||||
namespace = dimension.namespace.strip().casefold()
|
||||
if not _NAMESPACE_PATTERN.fullmatch(namespace):
|
||||
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
|
||||
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
|
||||
return f"{self._key_prefix}:{namespace}:{subject_digest}"
|
||||
|
||||
|
||||
def build_fixed_window_throttle(
|
||||
*,
|
||||
redis_url: str | None,
|
||||
window_seconds: int,
|
||||
redis_retry_seconds: int = 30,
|
||||
max_local_entries: int = 10_000,
|
||||
key_prefix: str = "govoplan:throttle:v1",
|
||||
) -> FixedWindowThrottle:
|
||||
primary = (
|
||||
RedisFixedWindowStore(redis_url)
|
||||
if redis_url is not None and redis_url.strip()
|
||||
else None
|
||||
)
|
||||
store = ResilientFixedWindowStore(
|
||||
primary,
|
||||
InMemoryFixedWindowStore(max_entries=max_local_entries),
|
||||
retry_seconds=redis_retry_seconds,
|
||||
)
|
||||
return FixedWindowThrottle(
|
||||
store,
|
||||
window_seconds=window_seconds,
|
||||
key_prefix=key_prefix,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FixedWindowBucket",
|
||||
"FixedWindowStore",
|
||||
"FixedWindowThrottle",
|
||||
"InMemoryFixedWindowStore",
|
||||
"RedisFixedWindowStore",
|
||||
"ResilientFixedWindowStore",
|
||||
"ThrottleDecision",
|
||||
"ThrottleDimension",
|
||||
"build_fixed_window_throttle",
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||
if frontend is None:
|
||||
return None
|
||||
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
}
|
||||
|
||||
|
||||
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
|
||||
"""Return only assets and routes explicitly approved for signed-out use."""
|
||||
|
||||
return {
|
||||
"module_id": frontend.module_id,
|
||||
"package_name": frontend.package_name,
|
||||
"asset_manifest": frontend.asset_manifest,
|
||||
"asset_manifest_signature": frontend.asset_manifest_signature,
|
||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||
capabilities: list[str] = []
|
||||
if manifest_id == "mail":
|
||||
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/public-modules")
|
||||
def public_modules(request: Request):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"modules": [
|
||||
{
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"frontend": _public_frontend_payload(manifest.frontend),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
if manifest.frontend is not None
|
||||
and manifest.frontend.public_routes
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/permissions")
|
||||
def permissions(request: Request):
|
||||
registry = _registry(request)
|
||||
|
||||
@@ -76,6 +76,12 @@ class Settings(BaseSettings):
|
||||
ge=0,
|
||||
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||
)
|
||||
scheduling_cancellation_notice_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=90,
|
||||
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
|
||||
)
|
||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||
|
||||
# Development bootstrap only. Do not use this in production.
|
||||
|
||||
@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
|
||||
for path in (
|
||||
"/api/v1/platform/status",
|
||||
"/api/v1/platform/modules",
|
||||
"/api/v1/platform/public-modules",
|
||||
):
|
||||
first = client.get(path)
|
||||
self.assertEqual(200, first.status_code, first.text)
|
||||
etag = first.headers.get("etag")
|
||||
|
||||
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):
|
||||
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||
|
||||
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
|
||||
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
|
||||
self.assertEqual(
|
||||
Settings(
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS="14"
|
||||
).scheduling_cancellation_notice_days,
|
||||
14,
|
||||
)
|
||||
for invalid in ("0", "91"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
|
||||
|
||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||
|
||||
@@ -113,6 +125,7 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||
@@ -122,6 +135,7 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||
|
||||
@@ -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("CELERY_ENABLED", "false")
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
@@ -99,9 +99,9 @@ from govoplan_core.core.module_package_catalog import (
|
||||
sign_module_package_catalog,
|
||||
validate_module_package_catalog,
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -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.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import RouteCollisionError
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
@@ -338,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
||||
if manifest.frontend.package_name is not None:
|
||||
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes):
|
||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
|
||||
self.assertTrue(route.path.startswith("/"))
|
||||
self.assertTrue(route.component.strip())
|
||||
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
||||
@@ -359,6 +360,88 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
|
||||
registry.validate()
|
||||
|
||||
def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None:
|
||||
for template, message in (
|
||||
(
|
||||
RoleTemplate(
|
||||
slug="invalid-system-default",
|
||||
name="Invalid",
|
||||
description="Invalid system default.",
|
||||
permissions=("system:*",),
|
||||
level="system",
|
||||
default_authenticated=True,
|
||||
),
|
||||
"must be tenant-level",
|
||||
),
|
||||
(
|
||||
RoleTemplate(
|
||||
slug="invalid-unmanaged-default",
|
||||
name="Invalid",
|
||||
description="Invalid unmanaged default.",
|
||||
permissions=("tenant:*",),
|
||||
managed=False,
|
||||
default_authenticated=True,
|
||||
),
|
||||
"must be managed",
|
||||
),
|
||||
):
|
||||
with self.subTest(template=template.slug):
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
role_templates=(template,),
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, message):
|
||||
registry.validate()
|
||||
|
||||
def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="unsafe-default",
|
||||
name="Unsafe default",
|
||||
description="Must not grant broad authority automatically.",
|
||||
permissions=("tenant:*",),
|
||||
default_authenticated=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "must use explicit permissions"):
|
||||
registry.validate()
|
||||
|
||||
def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for module_id in ("first", "second"):
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id=module_id,
|
||||
name=module_id.title(),
|
||||
version="test",
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="shared-reader",
|
||||
name="Shared reader",
|
||||
description="A colliding role template.",
|
||||
permissions=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
@@ -375,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
||||
registry.validate()
|
||||
|
||||
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
|
||||
public_routes=(
|
||||
PublicFrontendRoute(
|
||||
path="/example/public/:token",
|
||||
component="ExamplePublicPage",
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="private",
|
||||
name="Private",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="private",
|
||||
package_name="@govoplan/private-webui",
|
||||
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
|
||||
),
|
||||
))
|
||||
registry.validate()
|
||||
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/public-modules")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
|
||||
public_module = response.json()["modules"][0]
|
||||
self.assertNotIn("dependencies", public_module)
|
||||
self.assertNotIn("nav", public_module["frontend"])
|
||||
self.assertNotIn("routes", public_module["frontend"])
|
||||
self.assertEqual(
|
||||
["/example/public/:token"],
|
||||
[item["path"] for item in public_module["frontend"]["public_routes"]],
|
||||
)
|
||||
|
||||
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for module_id in ("first", "second"):
|
||||
registry.register(ModuleManifest(
|
||||
id=module_id,
|
||||
name=module_id.title(),
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id=module_id,
|
||||
public_routes=(
|
||||
PublicFrontendRoute(
|
||||
path="/shared/public/:token",
|
||||
component=f"{module_id.title()}PublicPage",
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
|
||||
registry.validate()
|
||||
|
||||
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
||||
first = APIRouter()
|
||||
second = APIRouter()
|
||||
|
||||
99
tests/test_people_search_contract.py
Normal file
99
tests/test_people_search_contract.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.people import (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
PeopleSearchGroup,
|
||||
PersonSearchCandidate,
|
||||
people_search_providers,
|
||||
person_selection_key,
|
||||
search_visible_people,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class FakePeopleSearchProvider:
|
||||
def __init__(self, *groups: PeopleSearchGroup) -> None:
|
||||
self.groups = groups
|
||||
self.calls: list[tuple[object, object, str, int]] = []
|
||||
|
||||
def search_people(self, session: object, principal: object, *, query: str, limit: int = 25):
|
||||
self.calls.append((session, principal, query, limit))
|
||||
return self.groups
|
||||
|
||||
|
||||
class PeopleSearchContractTests(unittest.TestCase):
|
||||
def test_collects_optional_providers_in_stable_group_order(self) -> None:
|
||||
duplicate = PersonSearchCandidate(
|
||||
selection_key="account:1",
|
||||
kind="account",
|
||||
reference_id="1",
|
||||
display_name="Ada Lovelace",
|
||||
)
|
||||
access = FakePeopleSearchProvider(PeopleSearchGroup(key="accounts", label="Accounts", candidates=(duplicate, duplicate)))
|
||||
addresses = FakePeopleSearchProvider(
|
||||
PeopleSearchGroup(
|
||||
key="contacts",
|
||||
label="Contacts",
|
||||
candidates=(
|
||||
PersonSearchCandidate(
|
||||
selection_key="contact:2:ada@example.test",
|
||||
kind="contact",
|
||||
reference_id="2",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: access},
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="addresses",
|
||||
name="Addresses",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda _context: addresses},
|
||||
))
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
session = object()
|
||||
principal = object()
|
||||
groups = search_visible_people(registry, session, principal, query=" ada ", limit=200)
|
||||
|
||||
self.assertEqual([group.key for group in groups], ["accounts", "contacts"])
|
||||
self.assertEqual([len(group.candidates) for group in groups], [1, 1])
|
||||
self.assertEqual(access.calls, [(session, principal, "ada", 100)])
|
||||
self.assertEqual(addresses.calls, [(session, principal, "ada", 100)])
|
||||
self.assertEqual(people_search_providers(None), ())
|
||||
|
||||
def test_omits_empty_groups_and_ignores_unrelated_capabilities(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: object()},
|
||||
))
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertEqual(search_visible_people(registry, object(), object(), query="ada"), ())
|
||||
|
||||
def test_selection_key_is_stable_and_validated(self) -> None:
|
||||
self.assertEqual(
|
||||
person_selection_key("Account", "account-1", email=" Ada@Example.Test "),
|
||||
"account:account-1:ada@example.test",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
person_selection_key("", "account-1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,7 +3,36 @@ from __future__ import annotations
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
|
||||
from govoplan_core.core.poll import (
|
||||
CAPABILITY_POLL_SCHEDULING,
|
||||
PollAnswerRef,
|
||||
PollOptionUpdateCommand,
|
||||
PollResponseRef,
|
||||
PollResponseRetirementCommand,
|
||||
PollResponseRetirementProvider,
|
||||
poll_response_retirement_provider,
|
||||
)
|
||||
|
||||
|
||||
class _RetirementProvider:
|
||||
def retire_responses(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object | None) -> None:
|
||||
self.capability_value = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLL_SCHEDULING
|
||||
and self.capability_value is not None
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.capability_value
|
||||
|
||||
|
||||
class PollContractTests(unittest.TestCase):
|
||||
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
|
||||
self.assertEqual(command.label, "Tuesday 10:00")
|
||||
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
||||
|
||||
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
|
||||
command = PollResponseRetirementCommand(
|
||||
respondent_ids=("account-1", "alice@example.test"),
|
||||
invitation_id="invitation-1",
|
||||
reason="scheduling_participant_removed",
|
||||
idempotency_key="scheduling:request-1:participant-1:removed",
|
||||
metadata={"source_module": "scheduling"},
|
||||
)
|
||||
provider = _RetirementProvider()
|
||||
|
||||
self.assertEqual(command.respondent_ids[0], "account-1")
|
||||
self.assertIsInstance(provider, PollResponseRetirementProvider)
|
||||
self.assertIs(
|
||||
poll_response_retirement_provider(_Registry(provider)),
|
||||
provider,
|
||||
)
|
||||
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
|
||||
self.assertIsNone(poll_response_retirement_provider(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
84
tests/test_poll_participation_contract.py
Normal file
84
tests/test_poll_participation_contract.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.poll_participation import (
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||
PollParticipationGatewayProvider,
|
||||
PollParticipationPolicy,
|
||||
participation_token_fingerprint,
|
||||
poll_participation_gateway_provider,
|
||||
)
|
||||
|
||||
|
||||
class _CompleteGateway:
|
||||
def create_governed_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_governed_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_authenticated_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_authenticated_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def add_option(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def remove_option(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def revoke_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_invitation_expiry(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object | None) -> None:
|
||||
self.capability_value = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||
and self.capability_value is not None
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.capability_value
|
||||
|
||||
|
||||
class PollParticipationContractTests(unittest.TestCase):
|
||||
def test_capability_name_and_policy_defaults_remain_stable(self) -> None:
|
||||
policy = PollParticipationPolicy()
|
||||
|
||||
self.assertEqual("poll.participation_gateway", CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||
self.assertEqual(1, policy.version)
|
||||
self.assertTrue(policy.allow_maybe)
|
||||
|
||||
def test_token_fingerprint_is_deterministic_and_non_reversible(self) -> None:
|
||||
fingerprint = participation_token_fingerprint("secret-token")
|
||||
|
||||
self.assertEqual(fingerprint, participation_token_fingerprint("secret-token"))
|
||||
self.assertEqual(64, len(fingerprint))
|
||||
self.assertNotIn("secret-token", fingerprint)
|
||||
|
||||
def test_registry_lookup_accepts_only_the_complete_structural_contract(self) -> None:
|
||||
provider = _CompleteGateway()
|
||||
|
||||
self.assertIsInstance(provider, PollParticipationGatewayProvider)
|
||||
self.assertIs(provider, poll_participation_gateway_provider(_Registry(provider)))
|
||||
self.assertIsNone(poll_participation_gateway_provider(_Registry(object())))
|
||||
self.assertIsNone(poll_participation_gateway_provider(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -26,9 +26,10 @@
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
|
||||
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
|
||||
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
||||
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
@@ -30,7 +30,9 @@ export default function App() {
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||
const [backendReachable, setBackendReachable] = useState(true);
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
@@ -38,9 +40,13 @@ export default function App() {
|
||||
|
||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
|
||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||
const contextModules = auth ? webModules : publicWebModules;
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
function updateSettings(next: ApiSettings) {
|
||||
@@ -119,6 +125,14 @@ export default function App() {
|
||||
};
|
||||
}, [settings.apiBaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPlatformPublicModules(settings).
|
||||
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
|
||||
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
|
||||
return () => {cancelled = true;};
|
||||
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
@@ -233,6 +247,18 @@ export default function App() {
|
||||
return () => {cancelled = true;};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!platformPublicModules?.length) {
|
||||
setRemotePublicWebModules([]);
|
||||
return () => {cancelled = true;};
|
||||
}
|
||||
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
|
||||
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
|
||||
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
|
||||
return () => {cancelled = true;};
|
||||
}, [platformPublicModules, localPublicWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
|
||||
@@ -284,7 +310,7 @@ export default function App() {
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<div className="public-landing">
|
||||
@@ -304,10 +330,21 @@ export default function App() {
|
||||
if (!auth) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
<Routes>
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
@@ -342,6 +379,13 @@ export default function App() {
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
{moduleRoutes.map((route) =>
|
||||
<Route
|
||||
key={route.path}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { PlatformModuleInfo } from "../types";
|
||||
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
|
||||
|
||||
export type PlatformStatusResponse = {
|
||||
maintenance_mode: {
|
||||
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
|
||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
|
||||
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
||||
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: boolean;
|
||||
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
|
||||
requestNavigation: (action: UnsavedNavigationAction) => void;
|
||||
/**
|
||||
* Route an explicit Discard button through the same confirmation used for
|
||||
* dirty navigation. The action runs after either saving or discarding.
|
||||
*/
|
||||
requestDiscard: (action: UnsavedNavigationAction) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
setPendingAction(() => action);
|
||||
}, []);
|
||||
|
||||
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
|
||||
requestNavigation(action);
|
||||
}, [requestNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
const active = registrationRef.current;
|
||||
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const value = useMemo<UnsavedChangesContextValue>(() => ({
|
||||
hasUnsavedChanges,
|
||||
registerUnsavedChanges,
|
||||
requestNavigation
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
|
||||
requestNavigation,
|
||||
requestDiscard
|
||||
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
|
||||
hasUnsavedChanges: false,
|
||||
registerUnsavedChanges: () => () => undefined,
|
||||
requestNavigation: (action) => action()
|
||||
requestNavigation: (action) => action(),
|
||||
requestDiscard: (action) => action()
|
||||
};
|
||||
|
||||
export function useUnsavedChanges() {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -106,6 +106,8 @@ type DataGridProps<T> = {
|
||||
export type DataGridRowActionsProps = {
|
||||
disabled?: boolean;
|
||||
removeDisabled?: boolean;
|
||||
/** Set to false only when row ordering is not part of this table's action set. */
|
||||
reorderable?: boolean;
|
||||
onAddBelow: () => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp?: () => void;
|
||||
@@ -858,6 +860,7 @@ export function DataGridPaginationBar({
|
||||
export function DataGridRowActions({
|
||||
disabled = false,
|
||||
removeDisabled = false,
|
||||
reorderable = true,
|
||||
onAddBelow,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
@@ -879,20 +882,18 @@ export function DataGridRowActions({
|
||||
disabled,
|
||||
onClick: onAddBelow
|
||||
},
|
||||
{
|
||||
reorderable && {
|
||||
id: "move-up",
|
||||
label: moveUpLabel,
|
||||
icon: <ArrowUp size={16} aria-hidden="true" />,
|
||||
applicable: Boolean(onMoveUp),
|
||||
disabled,
|
||||
disabled: disabled || !onMoveUp,
|
||||
onClick: () => onMoveUp?.()
|
||||
},
|
||||
{
|
||||
reorderable && {
|
||||
id: "move-down",
|
||||
label: moveDownLabel,
|
||||
icon: <ArrowDown size={16} aria-hidden="true" />,
|
||||
applicable: Boolean(onMoveDown),
|
||||
disabled,
|
||||
disabled: disabled || !onMoveDown,
|
||||
onClick: () => onMoveDown?.()
|
||||
},
|
||||
{
|
||||
@@ -910,16 +911,18 @@ export function DataGridRowActions({
|
||||
|
||||
export function DataGridEmptyAction({
|
||||
disabled = false,
|
||||
reorderable = true,
|
||||
onAdd,
|
||||
label = "i18n:govoplan-core.add_first_row.c82c15f2"
|
||||
|
||||
|
||||
|
||||
|
||||
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
|
||||
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
|
||||
return (
|
||||
<TableActionGroup
|
||||
className="data-grid-row-actions data-grid-empty-row-actions"
|
||||
minimumSlots={reorderable ? 4 : 2}
|
||||
actions={[{
|
||||
id: "add",
|
||||
label,
|
||||
|
||||
@@ -7,9 +7,14 @@ export type TableActionDefinition = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
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;
|
||||
disabled?: boolean;
|
||||
disabledReason?: ReactNode;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
|
||||
@@ -17,12 +22,14 @@ export type TableActionGroupProps = {
|
||||
actions: readonly (TableActionDefinition | false | null | undefined)[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
/** Reserve trailing action slots so controls keep the same column position. */
|
||||
minimumSlots?: number;
|
||||
};
|
||||
|
||||
function isApplicableAction(
|
||||
function isDefinedAction(
|
||||
action: TableActionDefinition | false | null | undefined
|
||||
): action is TableActionDefinition {
|
||||
return Boolean(action && action.applicable !== false);
|
||||
return Boolean(action);
|
||||
}
|
||||
|
||||
export function runTableAction(
|
||||
@@ -36,19 +43,24 @@ export function runTableAction(
|
||||
/**
|
||||
* The standard row-level action surface for tables and DataGrids.
|
||||
*
|
||||
* Callers describe only actions that make sense for the row. An action may be
|
||||
* disabled when it applies but is temporarily unavailable; actions marked as
|
||||
* not applicable are omitted entirely. Labels remain available to assistive
|
||||
* technology and as native tooltips while the visible controls stay icon-only.
|
||||
* Callers define the action set for the whole table. An action that cannot be
|
||||
* used for one row stays visible and disabled; an action that is structurally
|
||||
* irrelevant to the table is omitted from the array. Labels remain available
|
||||
* to assistive technology and as native tooltips while the visible controls
|
||||
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
|
||||
* rows, keeping their first action aligned with ordinary rows.
|
||||
*/
|
||||
export default function TableActionGroup({
|
||||
actions,
|
||||
label = "i18n:govoplan-core.actions.c3cd636a",
|
||||
className = ""
|
||||
className = "",
|
||||
minimumSlots = 0
|
||||
}: TableActionGroupProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const applicableActions = actions.filter(isApplicableAction);
|
||||
if (applicableActions.length === 0) return null;
|
||||
const definedActions = actions.filter(isDefinedAction);
|
||||
if (definedActions.length === 0) return null;
|
||||
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
|
||||
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -56,7 +68,7 @@ export default function TableActionGroup({
|
||||
role="group"
|
||||
aria-label={translateText(label)}
|
||||
>
|
||||
{applicableActions.map((action) => {
|
||||
{definedActions.map((action) => {
|
||||
const translatedLabel = translateText(action.label);
|
||||
return (
|
||||
<Button
|
||||
@@ -66,7 +78,8 @@ export default function TableActionGroup({
|
||||
className="table-action-button"
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={action.disabled}
|
||||
disabled={action.disabled || action.applicable === false}
|
||||
disabledReason={action.disabledReason}
|
||||
onClick={(event) => runTableAction(event, action.onClick)}
|
||||
>
|
||||
<span className="table-action-icon" aria-hidden="true">
|
||||
@@ -75,6 +88,13 @@ export default function TableActionGroup({
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: placeholderCount }, (_unused, index) => (
|
||||
<span
|
||||
key={`reserved-action-slot-${index}`}
|
||||
className="table-action-placeholder"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,15 @@ export default function SettingsPage({
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
|
||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
||||
"mail_servers:read",
|
||||
"mail_servers:write",
|
||||
"mail_servers:manage_credentials",
|
||||
"mail:profile:write_own",
|
||||
"mail:secret:manage_own",
|
||||
"admin:policies:read",
|
||||
"admin:policies:write"
|
||||
]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const contributedSections = useMemo(
|
||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||
@@ -319,8 +327,8 @@ export default function SettingsPage({
|
||||
scopeId={auth.user.id}
|
||||
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
|
||||
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
|
||||
canWriteProfiles={hasScope(auth, "mail_servers:write")}
|
||||
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")}
|
||||
canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
|
||||
canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
|
||||
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
|
||||
|
||||
}
|
||||
|
||||
@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
||||
"i18n:govoplan-core.accounts.36bae316": "Accounts",
|
||||
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
|
||||
"i18n:govoplan-core.people.b37554f6": "People",
|
||||
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
|
||||
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
|
||||
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
|
||||
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
|
||||
"i18n:govoplan-core.external_person.35115d8e": "External person",
|
||||
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
|
||||
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
|
||||
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
|
||||
"i18n:govoplan-core.source.6da13add": "Source",
|
||||
"i18n:govoplan-core.add_person.3b10561b": "Add person",
|
||||
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
|
||||
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
|
||||
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
|
||||
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
|
||||
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
|
||||
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
|
||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
||||
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
|
||||
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
|
||||
"i18n:govoplan-core.people.b37554f6": "Personen",
|
||||
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
|
||||
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
|
||||
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
|
||||
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
|
||||
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
|
||||
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
|
||||
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
|
||||
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
|
||||
"i18n:govoplan-core.source.6da13add": "Quelle",
|
||||
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
|
||||
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
|
||||
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
|
||||
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
|
||||
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
|
||||
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
|
||||
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
|
||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||
|
||||
@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
|
||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||
export { default as PageTitle } from "./components/PageTitle";
|
||||
export { default as PasswordField } from "./components/PasswordField";
|
||||
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
||||
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
||||
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
|
||||
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
|
||||
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
||||
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||
|
||||
@@ -9,20 +9,25 @@ import { usePlatformModules } from "../platform/ModuleContext";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../types";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
|
||||
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
|
||||
|
||||
export default function HelpMenu() {
|
||||
export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const helpContext = helpContextForPathname(location.pathname);
|
||||
const helpContext = helpContextForPathname(location.pathname, location.search);
|
||||
const modules = usePlatformModules();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const docsAvailable = modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
||||
const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
|
||||
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
|
||||
const docsAvailable = configuredDocsAvailable &&
|
||||
modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
|
||||
|
||||
useEffect(() => {
|
||||
function openContextHelp(event: KeyboardEvent) {
|
||||
@@ -53,6 +58,7 @@ export default function HelpMenu() {
|
||||
|
||||
function openDocs(type: "user" | "admin") {
|
||||
setOpen(false);
|
||||
setContextOpen(false);
|
||||
if (!docsAvailable) {
|
||||
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
@@ -87,15 +93,17 @@ export default function HelpMenu() {
|
||||
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||
</button>
|
||||
{adminDocsAvailable &&
|
||||
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||
</button>
|
||||
}
|
||||
<hr />
|
||||
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a>
|
||||
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
|
||||
</div>
|
||||
}
|
||||
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
|
||||
{contextOpen && <ContextHelpModal context={helpContext} onOpenDocs={() => openDocs("user")} onClose={() => setContextOpen(false)} />}
|
||||
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
|
||||
</div>);
|
||||
|
||||
@@ -106,7 +114,7 @@ function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
|
||||
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
|
||||
function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContext;onOpenDocs: () => void;onClose: () => void;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
<Dialog
|
||||
@@ -123,6 +131,7 @@ function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: (
|
||||
<div className="help-panel-section">
|
||||
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
|
||||
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
|
||||
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText("i18n:govoplan-core.open_user_documentation.084af515")}</Button>
|
||||
</div>
|
||||
</Dialog>);
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
<LanguageMenu />
|
||||
<HelpMenu />
|
||||
<HelpMenu auth={auth} />
|
||||
|
||||
{auth && notificationsAvailable &&
|
||||
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||
for (const module of modules) {
|
||||
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
||||
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
|
||||
import {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
|
||||
routeContributionsForModules as routeContributionsForModuleList,
|
||||
uiCapabilities as uiCapabilitiesForModules,
|
||||
uiCapability as uiCapabilityForModules } from
|
||||
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
};
|
||||
}
|
||||
|
||||
function filterPublicRoutes(
|
||||
module: PlatformWebModule,
|
||||
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
|
||||
) {
|
||||
if (!routes?.length) return [];
|
||||
const allowedPaths = new Set(routes.map((route) => route.path));
|
||||
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
|
||||
}
|
||||
|
||||
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||
return {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
version: info.version,
|
||||
dependencies: [],
|
||||
optional_dependencies: [],
|
||||
enabled: true,
|
||||
runtime_ui_capabilities: [],
|
||||
nav: [],
|
||||
frontend: {
|
||||
...info.frontend,
|
||||
routes: [],
|
||||
nav: [],
|
||||
settings_routes: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInstalledPublicWebModules(
|
||||
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
||||
): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return [];
|
||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||
return platformModules.flatMap((info) => {
|
||||
const local = localById.get(info.id);
|
||||
if (!local) return [];
|
||||
const resolved = applyServerMetadata(local, publicModuleInfo(info));
|
||||
return resolved.publicRoutes?.length ? [resolved] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadRemotePublicWebModules(
|
||||
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
||||
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
|
||||
): Promise<PlatformWebModule[]> {
|
||||
if (!platformModules?.length) return [];
|
||||
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
||||
return loaded.filter((module) => module.publicRoutes?.length);
|
||||
}
|
||||
|
||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return localModules;
|
||||
|
||||
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return routeContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return publicRouteContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||
flatMap((capability) => capability.widgets ?? []).
|
||||
|
||||
@@ -1516,6 +1516,166 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Principal-aware account/contact picker with structured external entry. */
|
||||
.people-picker {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-search-field,
|
||||
.people-picker-selection,
|
||||
.people-picker-manual {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-search-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.people-picker-search-control > svg {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
z-index: 1;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.people-picker-search-control input {
|
||||
padding-left: 38px;
|
||||
}
|
||||
.people-picker-search-control:focus-within > svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
.people-picker-results {
|
||||
display: grid;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding: 5px;
|
||||
}
|
||||
.people-picker-result-group {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.people-picker-result-group + .people-picker-result-group {
|
||||
margin-top: 5px;
|
||||
border-top: var(--border-line);
|
||||
padding-top: 5px;
|
||||
}
|
||||
.people-picker-result-group-label {
|
||||
color: var(--text-label);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
padding: 5px 9px 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.people-picker-result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
}
|
||||
.people-picker-result:hover:not(:disabled),
|
||||
.people-picker-result.is-active:not(:disabled),
|
||||
.people-picker-result:focus-visible:not(:disabled) {
|
||||
background: var(--primary-soft);
|
||||
box-shadow: var(--primary-inset-ring);
|
||||
outline: none;
|
||||
}
|
||||
.people-picker-result:disabled {
|
||||
background: var(--control-disabled-bg);
|
||||
color: var(--control-disabled-text);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.people-picker-result-main,
|
||||
.people-picker-result-context,
|
||||
.people-picker-selected-person {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-result-main strong,
|
||||
.people-picker-result-main small,
|
||||
.people-picker-result-context small,
|
||||
.people-picker-selected-person strong,
|
||||
.people-picker-selected-person small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.people-picker-result-main small,
|
||||
.people-picker-result-context,
|
||||
.people-picker-selected-person small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.people-picker-result-context {
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
.people-picker-status {
|
||||
min-height: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.people-picker-manual {
|
||||
justify-items: start;
|
||||
}
|
||||
.people-picker-manual-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
.people-picker-manual-form > label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-manual-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.people-picker-manual-form > .form-help {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
}
|
||||
.people-picker-selection > .field-label,
|
||||
.people-picker-selection > span > .field-label {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.people-picker-result {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.people-picker-result-context {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
.people-picker-manual-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bootstrap-like switch controls without importing Bootstrap. */
|
||||
.toggle-switch-row {
|
||||
display: flex;
|
||||
|
||||
@@ -94,7 +94,8 @@
|
||||
padding: 18px 34px 16px;
|
||||
}
|
||||
.workspace-heading .mono-small { margin-top: 8px; }
|
||||
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||
.panel { overflow: hidden; }
|
||||
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
||||
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
||||
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||
|
||||
@@ -686,6 +686,15 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.table-action-placeholder {
|
||||
display: block;
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.data-grid-empty-message {
|
||||
color: var(--muted);
|
||||
min-height: 64px;
|
||||
|
||||
@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
|
||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContext = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
||||
|
||||
export type AdminSectionRenderContext = PlatformRouteContext & {
|
||||
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContribution = {
|
||||
path: string;
|
||||
order?: number;
|
||||
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
|
||||
export type PlatformTranslationDictionary = Record<string, string>;
|
||||
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
|
||||
remoteAssetContractVersion?: string | null;
|
||||
navItems?: PlatformNavItem[];
|
||||
routes?: PlatformRouteContribution[];
|
||||
publicRoutes?: PlatformPublicRouteContribution[];
|
||||
translations?: PlatformTranslations;
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
|
||||
asset_manifest_integrity?: string | null;
|
||||
asset_manifest_contract_version?: string | null;
|
||||
routes: PlatformFrontendRouteInfo[];
|
||||
public_routes: Array<{
|
||||
path: string;
|
||||
component: string;
|
||||
order: number;
|
||||
}>;
|
||||
nav: Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
|
||||
settings_routes: PlatformFrontendRouteInfo[];
|
||||
};
|
||||
|
||||
export type PlatformPublicModuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
frontend: Pick<
|
||||
PlatformFrontendModuleInfo,
|
||||
| "module_id"
|
||||
| "package_name"
|
||||
| "asset_manifest"
|
||||
| "asset_manifest_signature"
|
||||
| "asset_manifest_public_key_id"
|
||||
| "asset_manifest_integrity"
|
||||
| "asset_manifest_contract_version"
|
||||
| "public_routes"
|
||||
>;
|
||||
};
|
||||
|
||||
export type PlatformModuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -31,16 +31,24 @@ const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
|
||||
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
|
||||
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
|
||||
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" },
|
||||
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
|
||||
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
|
||||
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
|
||||
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
|
||||
};
|
||||
|
||||
export function helpContextForPathname(pathname: string): HelpContext {
|
||||
export function helpContextForPathname(pathname: string, search = ""): HelpContext {
|
||||
const route = pathname || "/";
|
||||
const segments = route.split("/").filter(Boolean);
|
||||
|
||||
if (segments[0] === "settings") {
|
||||
const section = new URLSearchParams(search).get("section") || "";
|
||||
if (section === "mail-profiles") {
|
||||
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[0] === "campaigns" && segments[1]) {
|
||||
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
|
||||
if (segments[2] === "wizard") {
|
||||
|
||||
@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
|
||||
);
|
||||
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
|
||||
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
|
||||
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
|
||||
|
||||
const contextActions = renderToStaticMarkup(
|
||||
<TableActionGroup
|
||||
actions={[
|
||||
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
|
||||
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
|
||||
{ id: "remove", label: "Remove", icon: <span>R</span>, 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(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
|
||||
assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
|
||||
assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
|
||||
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
|
||||
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
|
||||
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
|
||||
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
|
||||
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
|
||||
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
|
||||
@@ -58,10 +63,16 @@ assertEqual(propagationStopped, true, "table actions do not trigger clickable ro
|
||||
assertEqual(actionCalled, true, "table actions still invoke their handler");
|
||||
|
||||
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");
|
||||
|
||||
const unavailableReorderActions = renderToStaticMarkup(
|
||||
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
|
||||
);
|
||||
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
|
||||
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
|
||||
|
||||
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
|
||||
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
||||
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||
|
||||
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"
|
||||
);
|
||||
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,15 @@
|
||||
"tests/explorer-tree.test.tsx",
|
||||
"tests/icon-button.test.tsx",
|
||||
"tests/mail-components.test.tsx",
|
||||
"tests/people-picker.test.tsx",
|
||||
"tests/resource-access-explanation.test.tsx",
|
||||
"tests/selection-list.test.tsx",
|
||||
"src/components/CredentialPanel.tsx",
|
||||
"src/components/email/EmailAddressInput.tsx",
|
||||
"src/components/PasswordField.tsx",
|
||||
"src/components/MessageDisplayPanel.tsx",
|
||||
"src/components/people/PeoplePicker.tsx",
|
||||
"src/components/people/peoplePickerTypes.ts",
|
||||
"src/components/ResourceAccessExplanation.tsx",
|
||||
"src/components/SelectionList.tsx",
|
||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"include": [
|
||||
"tests/module-capabilities.test.ts",
|
||||
"tests/privacy-policy.test.ts",
|
||||
"tests/help-context.test.ts",
|
||||
"src/platform/moduleLogic.ts",
|
||||
"src/utils/helpContext.ts",
|
||||
"src/features/privacy/policyLogic.ts",
|
||||
"src/types.ts"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user