18 Commits

Author SHA1 Message Date
987ca894ed chore(release): record reviewed 0.1.11 migration heads 2026-07-22 04:42:30 +02:00
4caa326878 chore(core): bump version to 0.1.11 2026-07-22 03:41:24 +02:00
8c4c4456c6 feat(core): define auditable poll response retirement 2026-07-22 03:31:05 +02:00
6abe292ac8 feat(core): define governed poll participation contract 2026-07-22 03:21:03 +02:00
fea2807754 feat(core): define atomic poll option ordering 2026-07-22 03:20:28 +02:00
22646c614c feat(core): add bounded people picker foundation 2026-07-22 03:01:56 +02:00
17376332a2 feat(core): configure bounded scheduling cancellation notices 2026-07-22 02:58:09 +02:00
0946bc84a9 feat(core): support explicit public module routes 2026-07-22 02:58:03 +02:00
a18499cbb5 feat(core): require scoped user workflows 2026-07-22 01:55:24 +02:00
36d7b73bb5 fix(webui): allow card content to overflow 2026-07-22 01:49:24 +02:00
b89a2d15f1 chore(core): bump version to 0.1.10 2026-07-21 20:47:54 +02:00
7f923afdad docs(core): refine function-bound postbox encryption 2026-07-21 20:47:54 +02:00
a7683c5d4a feat(core): add resilient fixed-window throttling 2026-07-21 20:47:54 +02:00
41ad057f7e feat(webui): standardize discard and table actions 2026-07-21 20:47:54 +02:00
bf0729eb59 feat(core): add authenticated baseline role templates 2026-07-21 20:47:54 +02:00
c4b90181e0 fix(webui): localize contextual Mail help 2026-07-21 19:15:56 +02:00
55ed194a99 fix(webui): respect configured documentation access 2026-07-21 19:01:00 +02:00
b3b0cf0fca feat(webui): open contextual configured handbooks 2026-07-21 18:42:31 +02:00
51 changed files with 3061 additions and 74 deletions

View File

@@ -453,6 +453,18 @@ The manifest should declare:
- navigation metadata using serializable icon names - navigation metadata using serializable icon names
- uninstall guard providers for data, migration, worker, or scheduler vetoes - uninstall guard providers for data, migration, worker, or scheduler vetoes
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
only when every authenticated tenant member must receive that narrow baseline
while the contributing module is installed. Access derives the explicit grant
from the active manifest set during authorization without mutating the request
transaction. It may materialize a non-assignable role row for administration,
but no per-user assignment is required and role edits cannot remove the
baseline.
This is not a shortcut for feature authorization: keep the template narrow and
continue to enforce each domain action's own permission and resource policy.
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
templates are rejected by registry validation.
Backend nav metadata must use icon-name strings, not frontend components: Backend nav metadata must use icon-name strings, not frontend components:
```python ```python

View File

@@ -57,6 +57,11 @@ The trust layer should provide:
- key rotation and epoch tracking - key rotation and epoch tracking
- recovery policy hooks - recovery policy hooks
Recovery must be organizationally governed. A server-held universal plaintext
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
grant needs an explicit assurance profile, authority/quorum, audit trail, and
user-visible consequence.
## Role And Function Postboxes ## Role And Function Postboxes
Role-bound access needs special handling. A postbox can be bound to an Role-bound access needs special handling. A postbox can be bound to an
@@ -75,6 +80,30 @@ rewrapping service:
Key epochs are required when role membership changes. Older messages may remain Key epochs are required when role membership changes. Older messages may remain
readable according to policy, but new access must use the current epoch. readable according to policy, but new access must use the current epoch.
The function-bound container exists independently of membership. It may remain
vacant and continue to receive ciphertext without falling back to an unrelated
personal mailbox. Zero, one, or several incumbents are valid states. Each
incumbent receives an independently auditable, device-bound wrapped-key path;
the postbox is never copied into their account ownership.
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
encryption permits the normal rotation path to rewrap per-message data keys
rather than rewrite large ciphertext objects; a security policy may require
full content re-encryption for selected compromise or cryptographic-profile
events. The history available to a new incumbent must be selected policy (all
retained history, a bounded historical window, or assignment-time content) and
recorded with the grant.
Delegation is a time-bounded represented-function grant, not a copy or
substitution of the postbox. Expiry or withdrawal stops future key release and
actions. It cannot revoke plaintext already decrypted, printed, exported, or
captured outside the platform. Multiple simultaneous incumbents and delegates
remain distinguishable in key-fetch and action evidence.
Postbox content and signed manifests are immutable. Correction or replacement
creates a linked new object/version; it never silently substitutes ciphertext
or evidence that another actor may already have inspected.
## External Recipients ## External Recipients
External recipients may need one-time or time-limited access without a full External recipients may need one-time or time-limited access without a full

21
docs/THROTTLING.md Normal file
View 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.

View File

@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs | | UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces | | UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors | | UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
@@ -191,9 +195,10 @@ explicitly retains the exception.
Decision: Scheduling requests provide a concrete reference application of the Decision: Scheduling requests provide a concrete reference application of the
universal placement and component rules. universal placement and component rules.
- The `Scheduling requests` header owns one `Add` action. - The persistent left panel stacks `My scheduling requests` and `Scheduling
- Its left panel is used for the creation view only, not as a permanent second requests for me`; it is list context, not a second creation affordance.
creation affordance. - The left panel's `Scheduling requests` header owns one `Add` action. It opens
the shared view/create/edit surface in the right main panel.
- Basic information, Calendar integration, candidate slots, and participants - Basic information, Calendar integration, candidate slots, and participants
use the central `Card` component as four logical sections. use the central `Card` component as four logical sections.
- Candidate slots and participants use the central `DataGrid`, including its - Candidate slots and participants use the central `DataGrid`, including its
@@ -207,6 +212,50 @@ universal placement and component rules.
Equivalent list/create/edit surfaces use the same underlying rules. These are Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants. not Scheduling-local component variants.
### DUE-011: Field Help, Discard, And Table Action Contracts
Decision: the central components own these interactions; modules compose them
instead of reproducing their behavior.
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
compositions use `FieldLabel` explicitly when the meaning or limitation is
not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
the same shared unsaved-changes dialog. A browser tab/window unload remains a
browser-controlled confirmation because browsers do not permit a custom
modal at that boundary.
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
listed here. Module repositories keep an equivalent register in their durable
UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
#### Alert Exception Register
No `window.alert` or global `alert` exception is authorized.
## Implementation Sequence ## Implementation Sequence
| Phase | Scope | Output | | Phase | Scope | Output |
@@ -277,6 +326,14 @@ Every new or changed admin/configuration surface should answer:
- Are logical sections, tabular collections, boolean settings, and repeatable - Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively? structured row per person respectively?
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
recorded with its rationale and accessible-name source?
- Do explicit Discard and dirty navigation use the shared unsaved-changes
registration/dialog rather than a page-local confirmation?
- Does every row retain the table's action set in the same order, disabling
unavailable actions and reserving the same empty-row slots?
- Is feedback rendered with a central dialog/alert component, with no
unauthorized `window.alert` or global `alert` call?
- If automation is involved, can the user see the trigger, system actor, - If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state? observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees? - Are technical details available without being the first thing the user sees?

View File

@@ -81,8 +81,8 @@
], ],
"recorded_at": "2026-07-11T01:39:45Z", "recorded_at": "2026-07-11T01:39:45Z",
"release": "0.1.7", "release": "0.1.7",
"track": "release", "squash_policy": "reviewed-manual",
"squash_policy": "reviewed-manual" "track": "release"
}, },
{ {
"heads": [ "heads": [
@@ -165,8 +165,132 @@
], ],
"recorded_at": "2026-07-20T02:45:41Z", "recorded_at": "2026-07-20T02:45:41Z",
"release": "0.1.8", "release": "0.1.8",
"track": "release", "squash_policy": "reviewed-manual",
"squash_policy": "reviewed-manual" "track": "release"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "4d5e6f7a9203"
},
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"4d5e6f7a9203"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T02:42:27Z",
"release": "0.1.11",
"squash_policy": "reviewed-manual",
"track": "release"
} }
], ],
"version": 1 "version": 1

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.9" version = "0.1.11"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View File

@@ -340,6 +340,7 @@ CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0 REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90 CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
# Deployment-wide connector egress policy. Enable private networks only when # Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services. # this installation intentionally integrates with internal services.
@@ -405,6 +406,7 @@ REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90 CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216 GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216

View File

@@ -45,6 +45,7 @@ class RoleTemplate:
level: PermissionLevel = "tenant" level: PermissionLevel = "tenant"
managed: bool = True managed: bool = True
protected: bool = False protected: bool = False
default_authenticated: bool = False
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -69,6 +70,15 @@ class FrontendRoute:
order: int = 100 order: int = 100
@dataclass(frozen=True, slots=True)
class PublicFrontendRoute:
"""Explicitly allowlisted route that can render without authentication."""
path: str
component: str
order: int = 100
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class FrontendModule: class FrontendModule:
module_id: str module_id: str
@@ -79,6 +89,7 @@ class FrontendModule:
asset_manifest_integrity: str | None = None asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1" asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = () routes: tuple[FrontendRoute, ...] = ()
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = () nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = () settings_routes: tuple[FrontendRoute, ...] = ()
@@ -236,6 +247,35 @@ class DocumentationTopic:
metadata: Mapping[str, Any] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict)
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
"""Return fail-closed authoring issues for a user-facing workflow topic.
Topic conditions are alternatives. Every alternative therefore needs an
explicit scope constraint; otherwise one unscoped alternative would make
the workflow visible regardless of the actor's task authority.
"""
raw_kind = topic.metadata.get("kind")
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
if kind != "workflow" or "user" not in topic.documentation_types:
return ()
if not topic.conditions:
return ("user workflow topics must declare at least one scope-conditioned alternative",)
unscoped_alternatives = tuple(
index
for index, condition in enumerate(topic.conditions, start=1)
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
)
if not unscoped_alternatives:
return ()
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
return (
"every user workflow condition alternative must declare required_scopes or any_scopes; "
f"unscoped alternative(s): {alternatives}",
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DocumentationContext: class DocumentationContext:
registry: object registry: object

View 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",
]

View File

@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
metadata: Mapping[str, object] = field(default_factory=dict) metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionOrderCommand:
"""Complete active option order supplied by an owning module.
Providers must reject partial, duplicate, or unknown option collections.
An exact repeat is an idempotent no-op. Reordering changes positions only;
option identities and their existing answers remain valid.
"""
option_ids: tuple[str, ...]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PollInvitationCommand: class PollInvitationCommand:
respondent_id: str | None = None respondent_id: str | None = None
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
metadata: Mapping[str, object] = field(default_factory=dict) metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollResponseRetirementCommand:
"""Retire live responses while preserving their auditable history.
Owning modules provide every server-trusted identity that can refer to the
participant. Providers must soft-delete matching live responses, retain
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
"""
respondent_ids: tuple[str, ...] = ()
invitation_id: str | None = None
reason: str = "participant_removed"
idempotency_key: str = ""
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PollOptionRef: class PollOptionRef:
id: str id: str
@@ -138,6 +166,16 @@ class PollResponseRef:
answers: tuple[PollAnswerRef, ...] = () answers: tuple[PollAnswerRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PollResponseRetirementRef:
"""Outcome of an auditable response-retirement request."""
response_ids: tuple[str, ...] = ()
retired_at: datetime | None = None
newly_retired_count: int = 0
replayed: bool = False
@runtime_checkable @runtime_checkable
class PollSchedulingProvider(Protocol): class PollSchedulingProvider(Protocol):
def create_poll( def create_poll(
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
... ...
def reorder_options(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionOrderCommand,
) -> PollRef:
"""Atomically replace the complete active option order."""
...
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef: def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
... ...
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
... ...
@runtime_checkable
class PollResponseRetirementProvider(Protocol):
"""Optional extension for owning modules that remove participants."""
def retire_responses(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollResponseRetirementCommand,
) -> PollResponseRetirementRef:
...
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None: def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"): if registry is None or not hasattr(registry, "has_capability"):
return None return None
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
) -> PollResponseSubmissionProvider | None: ) -> PollResponseSubmissionProvider | None:
provider = poll_scheduling_provider(registry) provider = poll_scheduling_provider(registry)
return provider if isinstance(provider, PollResponseSubmissionProvider) else None return provider if isinstance(provider, PollResponseSubmissionProvider) else None
def poll_response_retirement_provider(
registry: object | None,
) -> PollResponseRetirementProvider | None:
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
return None
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
return (
capability
if isinstance(capability, PollResponseRetirementProvider)
else None
)

View 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",
]

View File

@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
PublicFrontendRoute,
ResourceAclProvider, ResourceAclProvider,
RoleTemplate, RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION, SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION, SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryProvider, TenantSummaryProvider,
user_workflow_scope_condition_issues,
) )
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
@@ -194,6 +196,7 @@ class PlatformRegistry:
available_capabilities=available_capabilities, available_capabilities=available_capabilities,
) )
permissions = _collect_manifest_permissions(ordered) permissions = _collect_manifest_permissions(ordered)
_validate_public_frontend_route_uniqueness(ordered)
_validate_interface_closure(ordered) _validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions)) _validate_role_template_scopes(ordered, known_scopes=set(permissions))
@@ -320,9 +323,34 @@ def _validate_role_template_scopes(
*, *,
known_scopes: set[str], known_scopes: set[str],
) -> None: ) -> None:
seen_templates: dict[tuple[str, str], str] = {}
for manifest in manifests: for manifest in manifests:
for template in manifest.role_templates: for template in manifest.role_templates:
template_key = (template.level, template.slug)
previous_module = seen_templates.get(template_key)
if previous_module is not None:
raise RegistryError(
f"Duplicate {template.level} role template slug {template.slug!r} "
f"in modules {previous_module!r} and {manifest.id!r}"
)
seen_templates[template_key] = manifest.id
if template.default_authenticated and template.level != "tenant":
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be tenant-level"
)
if template.default_authenticated and not template.managed:
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be managed"
)
for scope in template.permissions: for scope in template.permissions:
if template.default_authenticated and (
scope in {"*", "tenant:*", "system:*"}
or _WILDCARD_RE.match(scope)
):
raise RegistryError(
f"Default authenticated role template {template.slug!r} "
"must use explicit permissions, not wildcard scopes"
)
if _role_template_scope_known(scope, known_scopes): if _role_template_scope_known(scope, known_scopes):
continue continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}") raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
@@ -344,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_frontend(manifest) _validate_manifest_frontend(manifest)
for item in manifest.nav_items: for item in manifest.nav_items:
_validate_nav_item(manifest.id, item) _validate_nav_item(manifest.id, item)
for topic in manifest.documentation:
for issue in user_workflow_scope_condition_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
def _validate_manifest_identity(manifest: ModuleManifest) -> None: def _validate_manifest_identity(manifest: ModuleManifest) -> None:
@@ -403,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
) )
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name): if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}") raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes): for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component) _validate_frontend_route(manifest.id, route.path, route.component)
for item in frontend.nav_items: for item in frontend.nav_items:
_validate_nav_item(manifest.id, item) _validate_nav_item(manifest.id, item)
@@ -416,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component") raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_public_frontend_route_uniqueness(
manifests: tuple[ModuleManifest, ...],
) -> None:
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
for manifest in manifests:
if manifest.frontend is None:
continue
for route in manifest.frontend.public_routes:
previous = owners.get(route.path)
if previous is not None:
previous_module, _previous_route = previous
raise RegistryError(
f"Duplicate public frontend route {route.path!r} in modules "
f"{previous_module!r} and {manifest.id!r}"
)
owners[route.path] = (manifest.id, route)
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None: def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list) providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests: for manifest in manifests:

View 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",
]

View File

@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.core.maintenance import saved_maintenance_mode from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_core.i18n import system_i18n_payload from govoplan_core.i18n import system_i18n_payload
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
} }
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"order": route.order,
}
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None: def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
if frontend is None: if frontend is None:
return None return None
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
"asset_manifest_integrity": frontend.asset_manifest_integrity, "asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version, "asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"routes": [_frontend_route_payload(route) for route in frontend.routes], "routes": [_frontend_route_payload(route) for route in frontend.routes],
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
"nav": [_nav_item_payload(item) for item in frontend.nav_items], "nav": [_nav_item_payload(item) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes], "settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
} }
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
"""Return only assets and routes explicitly approved for signed-out use."""
return {
"module_id": frontend.module_id,
"package_name": frontend.package_name,
"asset_manifest": frontend.asset_manifest,
"asset_manifest_signature": frontend.asset_manifest_signature,
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
}
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]: def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
capabilities: list[str] = [] capabilities: list[str] = []
if manifest_id == "mail": if manifest_id == "mail":
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
] ]
} }
@router.get("/public-modules")
def public_modules(request: Request):
registry = _registry(request)
return {
"modules": [
{
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
if manifest.frontend is not None
and manifest.frontend.public_routes
]
}
@router.get("/permissions") @router.get("/permissions")
def permissions(request: Request): def permissions(request: Request):
registry = _registry(request) registry = _registry(request)

View File

@@ -76,6 +76,12 @@ class Settings(BaseSettings):
ge=0, ge=0,
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS", alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
) )
scheduling_cancellation_notice_days: int = Field(
default=30,
ge=1,
le=90,
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
)
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR") mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production. # Development bootstrap only. Do not use this in production.

View File

@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
) )
with TestClient(app) as client: with TestClient(app) as client:
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"): for path in (
"/api/v1/platform/status",
"/api/v1/platform/modules",
"/api/v1/platform/public-modules",
):
first = client.get(path) first = client.get(path)
self.assertEqual(200, first.status_code, first.text) self.assertEqual(200, first.status_code, first.text)
etag = first.headers.get("etag") etag = first.headers.get("etag")

View 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()

View File

@@ -21,6 +21,18 @@ class InstallConfigTests(unittest.TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1") Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
self.assertEqual(
Settings(
SCHEDULING_CANCELLATION_NOTICE_DAYS="14"
).scheduling_cancellation_notice_days,
14,
)
for invalid in ("0", "91"):
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None: def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
result = validate_runtime_configuration({}, profile="self-hosted") result = validate_runtime_configuration({}, profile="self-hosted")
@@ -113,6 +125,7 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted) self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
self.assertIn("MASTER_KEY_B64=<generate", self_hosted) self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted) self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted) self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted) self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted) self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
@@ -122,6 +135,7 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like) self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like) self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like) self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like) self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like) self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like) self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)

View File

@@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false") os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
os.environ.setdefault("CELERY_ENABLED", "false") os.environ.setdefault("CELERY_ENABLED", "false")
from fastapi import APIRouter from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
@@ -99,9 +99,9 @@ from govoplan_core.core.module_package_catalog import (
sign_module_package_catalog, sign_module_package_catalog,
validate_module_package_catalog, validate_module_package_catalog,
) )
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
from govoplan_core.core.module_guards import drop_table_retirement_provider from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -112,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.security.permissions import scope_grants from govoplan_core.security.permissions import scope_grants
from govoplan_core.server.app import create_app from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import RouteCollisionError from govoplan_core.server.route_validation import RouteCollisionError
from govoplan_core.tenancy.scope import Tenant, create_scope_tables from govoplan_core.tenancy.scope import Tenant, create_scope_tables
@@ -338,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(manifest.id, manifest.frontend.module_id) self.assertEqual(manifest.id, manifest.frontend.module_id)
if manifest.frontend.package_name is not None: if manifest.frontend.package_name is not None:
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE) self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes): for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
self.assertTrue(route.path.startswith("/")) self.assertTrue(route.path.startswith("/"))
self.assertTrue(route.component.strip()) self.assertTrue(route.component.strip())
for item in (*manifest.nav_items, *manifest.frontend.nav_items): for item in (*manifest.nav_items, *manifest.frontend.nav_items):
@@ -359,6 +360,88 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"): with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
registry.validate() registry.validate()
def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None:
for template, message in (
(
RoleTemplate(
slug="invalid-system-default",
name="Invalid",
description="Invalid system default.",
permissions=("system:*",),
level="system",
default_authenticated=True,
),
"must be tenant-level",
),
(
RoleTemplate(
slug="invalid-unmanaged-default",
name="Invalid",
description="Invalid unmanaged default.",
permissions=("tenant:*",),
managed=False,
default_authenticated=True,
),
"must be managed",
),
):
with self.subTest(template=template.slug):
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="test",
role_templates=(template,),
)
)
with self.assertRaisesRegex(RegistryError, message):
registry.validate()
def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="test",
role_templates=(
RoleTemplate(
slug="unsafe-default",
name="Unsafe default",
description="Must not grant broad authority automatically.",
permissions=("tenant:*",),
default_authenticated=True,
),
),
)
)
with self.assertRaisesRegex(RegistryError, "must use explicit permissions"):
registry.validate()
def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None:
registry = PlatformRegistry()
for module_id in ("first", "second"):
registry.register(
ModuleManifest(
id=module_id,
name=module_id.title(),
version="test",
role_templates=(
RoleTemplate(
slug="shared-reader",
name="Shared reader",
description="A colliding role template.",
permissions=(),
),
),
)
)
with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"):
registry.validate()
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None: def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(ModuleManifest( registry.register(ModuleManifest(
@@ -375,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "Frontend route"): with self.assertRaisesRegex(RegistryError, "Frontend route"):
registry.validate() registry.validate()
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="test",
frontend=FrontendModule(
module_id="example",
package_name="@govoplan/example-webui",
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
public_routes=(
PublicFrontendRoute(
path="/example/public/:token",
component="ExamplePublicPage",
),
),
),
))
registry.register(ModuleManifest(
id="private",
name="Private",
version="test",
frontend=FrontendModule(
module_id="private",
package_name="@govoplan/private-webui",
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
),
))
registry.validate()
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_platform_router(), prefix="/api/v1")
with TestClient(app) as client:
response = client.get("/api/v1/platform/public-modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
public_module = response.json()["modules"][0]
self.assertNotIn("dependencies", public_module)
self.assertNotIn("nav", public_module["frontend"])
self.assertNotIn("routes", public_module["frontend"])
self.assertEqual(
["/example/public/:token"],
[item["path"] for item in public_module["frontend"]["public_routes"]],
)
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
registry = PlatformRegistry()
for module_id in ("first", "second"):
registry.register(ModuleManifest(
id=module_id,
name=module_id.title(),
version="test",
frontend=FrontendModule(
module_id=module_id,
public_routes=(
PublicFrontendRoute(
path="/shared/public/:token",
component=f"{module_id.title()}PublicPage",
),
),
),
))
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
registry.validate()
def test_server_startup_rejects_duplicate_configured_routes(self) -> None: def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
first = APIRouter() first = APIRouter()
second = APIRouter() second = APIRouter()

View 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()

View File

@@ -3,7 +3,36 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef from govoplan_core.core.poll import (
CAPABILITY_POLL_SCHEDULING,
PollAnswerRef,
PollOptionUpdateCommand,
PollResponseRef,
PollResponseRetirementCommand,
PollResponseRetirementProvider,
poll_response_retirement_provider,
)
class _RetirementProvider:
def retire_responses(self, *args, **kwargs):
raise NotImplementedError
class _Registry:
def __init__(self, capability: object | None) -> None:
self.capability_value = capability
def has_capability(self, name: str) -> bool:
return (
name == CAPABILITY_POLL_SCHEDULING
and self.capability_value is not None
)
def capability(self, name: str) -> object:
if not self.has_capability(name):
raise KeyError(name)
return self.capability_value
class PollContractTests(unittest.TestCase): class PollContractTests(unittest.TestCase):
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
self.assertEqual(command.label, "Tuesday 10:00") self.assertEqual(command.label, "Tuesday 10:00")
self.assertEqual(command.value, {"slot_id": "slot-1"}) self.assertEqual(command.value, {"slot_id": "slot-1"})
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
command = PollResponseRetirementCommand(
respondent_ids=("account-1", "alice@example.test"),
invitation_id="invitation-1",
reason="scheduling_participant_removed",
idempotency_key="scheduling:request-1:participant-1:removed",
metadata={"source_module": "scheduling"},
)
provider = _RetirementProvider()
self.assertEqual(command.respondent_ids[0], "account-1")
self.assertIsInstance(provider, PollResponseRetirementProvider)
self.assertIs(
poll_response_retirement_provider(_Registry(provider)),
provider,
)
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
self.assertIsNone(poll_response_retirement_provider(None))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View 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
View 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()

View File

@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.9", "version": "0.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.9", "version": "0.1.11",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",

View File

@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.9", "version": "0.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.9", "version": "0.1.11",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.9", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "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:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js", "test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js", "test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs", "test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js", "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js", "test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js" "test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.9", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",

View File

@@ -1,14 +1,14 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform"; import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client"; import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types"; import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell"; import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary"; import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules"; import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents"; import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard"; import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
@@ -30,7 +30,9 @@ export default function App() {
const [auth, setAuth] = useState<AuthInfo | null>(null); const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true); const [checkingSession, setCheckingSession] = useState(true);
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null); const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]); const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null }); const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true); const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null); const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
@@ -38,9 +40,13 @@ export default function App() {
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]); const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]); const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]); const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]); const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]); const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]); const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
function updateSettings(next: ApiSettings) { function updateSettings(next: ApiSettings) {
@@ -119,6 +125,14 @@ export default function App() {
}; };
}, [settings.apiBaseUrl]); }, [settings.apiBaseUrl]);
useEffect(() => {
let cancelled = false;
fetchPlatformPublicModules(settings).
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setCheckingSession(true); setCheckingSession(true);
@@ -233,6 +247,18 @@ export default function App() {
return () => {cancelled = true;}; return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]); }, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
useEffect(() => {
let cancelled = false;
if (!platformPublicModules?.length) {
setRemotePublicWebModules([]);
return () => {cancelled = true;};
}
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
return () => {cancelled = true;};
}, [platformPublicModules, localPublicWebModules]);
useEffect(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
@@ -284,7 +310,7 @@ export default function App() {
if (checkingSession) { if (checkingSession) {
return ( return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing"> <div className="public-landing">
@@ -304,10 +330,21 @@ export default function App() {
if (!auth) { if (!auth) {
return ( return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}> <PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={publicWebModules}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} /> <Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<Routes>
{publicRoutes.map((route) =>
<Route
key={`public:${route.path}`}
path={route.path}
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
/>
)}
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
</Routes>
</Suspense>
</AppShell> </AppShell>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</PlatformModulesProvider> </PlatformModulesProvider>
@@ -342,6 +379,13 @@ export default function App() {
<Routes key={(auth.active_tenant ?? auth.tenant).id}> <Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />} {!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
{publicRoutes.map((route) =>
<Route
key={`public:${route.path}`}
path={route.path}
element={route.render({ settings, auth, onAuthChange: updateAuth })}
/>
)}
{moduleRoutes.map((route) => {moduleRoutes.map((route) =>
<Route <Route
key={route.path} key={route.path}

View File

@@ -1,8 +1,9 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
import type { PlatformModuleInfo } from "../types"; import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] }; export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
export type PlatformStatusResponse = { export type PlatformStatusResponse = {
maintenance_mode: { maintenance_mode: {
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules"); return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
} }
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
}
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> { export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status"); return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
} }

View File

@@ -26,6 +26,11 @@ type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void; registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void; requestNavigation: (action: UnsavedNavigationAction) => void;
/**
* Route an explicit Discard button through the same confirmation used for
* dirty navigation. The action runs after either saving or discarding.
*/
requestDiscard: (action: UnsavedNavigationAction) => void;
}; };
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null); const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
@@ -72,6 +77,10 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
setPendingAction(() => action); setPendingAction(() => action);
}, []); }, []);
const requestDiscard = useCallback((action: UnsavedNavigationAction) => {
requestNavigation(action);
}, [requestNavigation]);
useEffect(() => { useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) { function onBeforeUnload(event: BeforeUnloadEvent) {
const active = registrationRef.current; const active = registrationRef.current;
@@ -150,8 +159,9 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const value = useMemo<UnsavedChangesContextValue>(() => ({ const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges, hasUnsavedChanges,
registerUnsavedChanges, registerUnsavedChanges,
requestNavigation requestNavigation,
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]); requestDiscard
}), [hasUnsavedChanges, registerUnsavedChanges, requestDiscard, requestNavigation]);
return ( return (
<UnsavedChangesContext.Provider value={value}> <UnsavedChangesContext.Provider value={value}>
@@ -185,7 +195,8 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = { const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false, hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined, registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action() requestNavigation: (action) => action(),
requestDiscard: (action) => action()
}; };
export function useUnsavedChanges() { export function useUnsavedChanges() {

View 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;
}

View 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;
}

View File

@@ -106,6 +106,8 @@ type DataGridProps<T> = {
export type DataGridRowActionsProps = { export type DataGridRowActionsProps = {
disabled?: boolean; disabled?: boolean;
removeDisabled?: boolean; removeDisabled?: boolean;
/** Set to false only when row ordering is not part of this table's action set. */
reorderable?: boolean;
onAddBelow: () => void; onAddBelow: () => void;
onRemove: () => void; onRemove: () => void;
onMoveUp?: () => void; onMoveUp?: () => void;
@@ -858,6 +860,7 @@ export function DataGridPaginationBar({
export function DataGridRowActions({ export function DataGridRowActions({
disabled = false, disabled = false,
removeDisabled = false, removeDisabled = false,
reorderable = true,
onAddBelow, onAddBelow,
onRemove, onRemove,
onMoveUp, onMoveUp,
@@ -879,20 +882,18 @@ export function DataGridRowActions({
disabled, disabled,
onClick: onAddBelow onClick: onAddBelow
}, },
{ reorderable && {
id: "move-up", id: "move-up",
label: moveUpLabel, label: moveUpLabel,
icon: <ArrowUp size={16} aria-hidden="true" />, icon: <ArrowUp size={16} aria-hidden="true" />,
applicable: Boolean(onMoveUp), disabled: disabled || !onMoveUp,
disabled,
onClick: () => onMoveUp?.() onClick: () => onMoveUp?.()
}, },
{ reorderable && {
id: "move-down", id: "move-down",
label: moveDownLabel, label: moveDownLabel,
icon: <ArrowDown size={16} aria-hidden="true" />, icon: <ArrowDown size={16} aria-hidden="true" />,
applicable: Boolean(onMoveDown), disabled: disabled || !onMoveDown,
disabled,
onClick: () => onMoveDown?.() onClick: () => onMoveDown?.()
}, },
{ {
@@ -910,16 +911,18 @@ export function DataGridRowActions({
export function DataGridEmptyAction({ export function DataGridEmptyAction({
disabled = false, disabled = false,
reorderable = true,
onAdd, onAdd,
label = "i18n:govoplan-core.add_first_row.c82c15f2" label = "i18n:govoplan-core.add_first_row.c82c15f2"
}: {disabled?: boolean;onAdd: () => void;label?: string;}) { }: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
return ( return (
<TableActionGroup <TableActionGroup
className="data-grid-row-actions data-grid-empty-row-actions" className="data-grid-row-actions data-grid-empty-row-actions"
minimumSlots={reorderable ? 4 : 2}
actions={[{ actions={[{
id: "add", id: "add",
label, label,

View File

@@ -7,9 +7,14 @@ export type TableActionDefinition = {
label: string; label: string;
icon: ReactNode; icon: ReactNode;
onClick: () => void; onClick: () => void;
/** Set to false when the action does not apply to this row. */ /**
* @deprecated Use `disabled` for a row-level unavailable state and omit the
* action from the array only when it is not part of this table's action set.
* A false value is retained as a disabled, visible action for compatibility.
*/
applicable?: boolean; applicable?: boolean;
disabled?: boolean; disabled?: boolean;
disabledReason?: ReactNode;
variant?: "primary" | "secondary" | "ghost" | "danger"; variant?: "primary" | "secondary" | "ghost" | "danger";
}; };
@@ -17,12 +22,14 @@ export type TableActionGroupProps = {
actions: readonly (TableActionDefinition | false | null | undefined)[]; actions: readonly (TableActionDefinition | false | null | undefined)[];
label?: string; label?: string;
className?: string; className?: string;
/** Reserve trailing action slots so controls keep the same column position. */
minimumSlots?: number;
}; };
function isApplicableAction( function isDefinedAction(
action: TableActionDefinition | false | null | undefined action: TableActionDefinition | false | null | undefined
): action is TableActionDefinition { ): action is TableActionDefinition {
return Boolean(action && action.applicable !== false); return Boolean(action);
} }
export function runTableAction( export function runTableAction(
@@ -36,19 +43,24 @@ export function runTableAction(
/** /**
* The standard row-level action surface for tables and DataGrids. * The standard row-level action surface for tables and DataGrids.
* *
* Callers describe only actions that make sense for the row. An action may be * Callers define the action set for the whole table. An action that cannot be
* disabled when it applies but is temporarily unavailable; actions marked as * used for one row stays visible and disabled; an action that is structurally
* not applicable are omitted entirely. Labels remain available to assistive * irrelevant to the table is omitted from the array. Labels remain available
* technology and as native tooltips while the visible controls stay icon-only. * to assistive technology and as native tooltips while the visible controls
* stay icon-only. `minimumSlots` reserves trailing positions for empty-state
* rows, keeping their first action aligned with ordinary rows.
*/ */
export default function TableActionGroup({ export default function TableActionGroup({
actions, actions,
label = "i18n:govoplan-core.actions.c3cd636a", label = "i18n:govoplan-core.actions.c3cd636a",
className = "" className = "",
minimumSlots = 0
}: TableActionGroupProps) { }: TableActionGroupProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const applicableActions = actions.filter(isApplicableAction); const definedActions = actions.filter(isDefinedAction);
if (applicableActions.length === 0) return null; if (definedActions.length === 0) return null;
const normalizedMinimumSlots = Number.isFinite(minimumSlots) ? Math.max(0, Math.floor(minimumSlots)) : 0;
const placeholderCount = Math.max(0, normalizedMinimumSlots - definedActions.length);
return ( return (
<div <div
@@ -56,7 +68,7 @@ export default function TableActionGroup({
role="group" role="group"
aria-label={translateText(label)} aria-label={translateText(label)}
> >
{applicableActions.map((action) => { {definedActions.map((action) => {
const translatedLabel = translateText(action.label); const translatedLabel = translateText(action.label);
return ( return (
<Button <Button
@@ -66,7 +78,8 @@ export default function TableActionGroup({
className="table-action-button" className="table-action-button"
aria-label={translatedLabel} aria-label={translatedLabel}
title={translatedLabel} title={translatedLabel}
disabled={action.disabled} disabled={action.disabled || action.applicable === false}
disabledReason={action.disabledReason}
onClick={(event) => runTableAction(event, action.onClick)} onClick={(event) => runTableAction(event, action.onClick)}
> >
<span className="table-action-icon" aria-hidden="true"> <span className="table-action-icon" aria-hidden="true">
@@ -75,6 +88,13 @@ export default function TableActionGroup({
</Button> </Button>
); );
})} })}
{Array.from({ length: placeholderCount }, (_unused, index) => (
<span
key={`reserved-action-slot-${index}`}
className="table-action-placeholder"
aria-hidden="true"
/>
))}
</div> </div>
); );
} }

View File

@@ -92,7 +92,15 @@ export default function SettingsPage({
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage(); const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null; const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null; const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]); const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
"mail_servers:read",
"mail_servers:write",
"mail_servers:manage_credentials",
"mail:profile:write_own",
"mail:secret:manage_own",
"admin:policies:read",
"admin:policies:write"
]);
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]); const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const contributedSections = useMemo( const contributedSections = useMemo(
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)), () => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
@@ -319,8 +327,8 @@ export default function SettingsPage({
scopeId={auth.user.id} scopeId={auth.user.id}
profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798" profileTitle="i18n:govoplan-core.my_mail_server_profiles.c5830798"
policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23" policyTitle="i18n:govoplan-core.my_mail_profile_policy.6e480f23"
canWriteProfiles={hasScope(auth, "mail_servers:write")} canWriteProfiles={hasAnyScope(auth, ["mail_servers:write", "mail:profile:write_own"])}
canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canManageCredentials={hasAnyScope(auth, ["mail_servers:manage_credentials", "mail:secret:manage_own"])}
canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} /> canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />
} }

View File

@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document", "i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…", "i18n:govoplan-core.working.13b7bfca": "Working…",
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace", "i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
"i18n:govoplan-core.accounts.36bae316": "Accounts",
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
"i18n:govoplan-core.people.b37554f6": "People",
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
"i18n:govoplan-core.external_person.35115d8e": "External person",
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
"i18n:govoplan-core.source.6da13add": "Source",
"i18n:govoplan-core.add_person.3b10561b": "Add person",
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet", "i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active", "i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.", "i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document", "i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…", "i18n:govoplan-core.working.13b7bfca": "Working…",
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich", "i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
"i18n:govoplan-core.people.b37554f6": "Personen",
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
"i18n:govoplan-core.source.6da13add": "Quelle",
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet", "i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active", "i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.", "i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",

View File

@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel"; export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle"; export { default as PageTitle } from "./components/PageTitle";
export { default as PasswordField } from "./components/PasswordField"; export { default as PasswordField } from "./components/PasswordField";
export { default as PeoplePicker } from "./components/people/PeoplePicker";
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation"; export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation"; export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
export type { PasswordFieldProps } from "./components/PasswordField"; export type { PasswordFieldProps } from "./components/PasswordField";

View File

@@ -9,20 +9,25 @@ import { usePlatformModules } from "../platform/ModuleContext";
import type { PlatformWebModule } from "../types"; import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext"; import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { AuthInfo } from "../types";
import { hasAnyScope } from "../utils/permissions";
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de"; const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
export default function HelpMenu() { export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [contextOpen, setContextOpen] = useState(false); const [contextOpen, setContextOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation(); const location = useLocation();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const helpContext = helpContextForPathname(location.pathname); const helpContext = helpContextForPathname(location.pathname, location.search);
const modules = usePlatformModules(); const modules = usePlatformModules();
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const docsAvailable = modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs")); const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
const docsAvailable = configuredDocsAvailable &&
modules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
useEffect(() => { useEffect(() => {
function openContextHelp(event: KeyboardEvent) { function openContextHelp(event: KeyboardEvent) {
@@ -53,6 +58,7 @@ export default function HelpMenu() {
function openDocs(type: "user" | "admin") { function openDocs(type: "user" | "admin") {
setOpen(false); setOpen(false);
setContextOpen(false);
if (!docsAvailable) { if (!docsAvailable) {
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer"); window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
return; return;
@@ -87,15 +93,17 @@ export default function HelpMenu() {
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}> <button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")} <BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button> </button>
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}> {adminDocsAvailable &&
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")} <button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
</button> <BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button>
}
<hr /> <hr />
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a> <a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> i18n:govoplan-core.gitlab.9a9b09d9</a>
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button> <button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
</div> </div>
} }
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />} {contextOpen && <ContextHelpModal context={helpContext} onOpenDocs={() => openDocs("user")} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />} {aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
</div>); </div>);
@@ -106,7 +114,7 @@ function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`; return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
} }
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) { function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContext;onOpenDocs: () => void;onClose: () => void;}) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
return ( return (
<Dialog <Dialog
@@ -123,6 +131,7 @@ function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: (
<div className="help-panel-section"> <div className="help-panel-section">
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3> <h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p> <p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText("i18n:govoplan-core.open_user_documentation.084af515")}</Button>
</div> </div>
</Dialog>); </Dialog>);

View File

@@ -219,7 +219,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="titlebar-spacer" /> <div className="titlebar-spacer" />
<LanguageMenu /> <LanguageMenu />
<HelpMenu /> <HelpMenu auth={auth} />
{auth && notificationsAvailable && {auth && notificationsAvailable &&
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}> <button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>

View File

@@ -1,4 +1,4 @@
import type { PlatformRouteContribution, PlatformWebModule } from "../types"; import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null { export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
for (const module of modules) { for (const module of modules) {
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] { export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
} }
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}

View File

@@ -1,10 +1,11 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react"; import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules"; import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
import { import {
hasUiCapability as hasUiCapabilityForModules, hasUiCapability as hasUiCapabilityForModules,
moduleInstalled as moduleInstalledForModules, moduleInstalled as moduleInstalledForModules,
moduleIntegrationEnabled as moduleIntegrationEnabledForModules, moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
routeContributionsForModules as routeContributionsForModuleList, routeContributionsForModules as routeContributionsForModuleList,
uiCapabilities as uiCapabilitiesForModules, uiCapabilities as uiCapabilitiesForModules,
uiCapability as uiCapabilityForModules } from uiCapability as uiCapabilityForModules } from
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity, remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion, remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems, navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
uiCapabilities: { uiCapabilities: {
...(module.uiCapabilities ?? {}), ...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info) ...runtimeUiCapabilitiesForModule(module, info)
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
}; };
} }
function filterPublicRoutes(
module: PlatformWebModule,
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
) {
if (!routes?.length) return [];
const allowedPaths = new Set(routes.map((route) => route.path));
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
}
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
return {
id: info.id,
name: info.name,
version: info.version,
dependencies: [],
optional_dependencies: [],
enabled: true,
runtime_ui_capabilities: [],
nav: [],
frontend: {
...info.frontend,
routes: [],
nav: [],
settings_routes: []
}
};
}
export function resolveInstalledPublicWebModules(
platformModules: PlatformPublicModuleInfo[] | null | undefined
): PlatformWebModule[] {
if (!platformModules?.length) return [];
const localById = new Map(localModules.map((module) => [module.id, module]));
return platformModules.flatMap((info) => {
const local = localById.get(info.id);
if (!local) return [];
const resolved = applyServerMetadata(local, publicModuleInfo(info));
return resolved.publicRoutes?.length ? [resolved] : [];
});
}
export async function loadRemotePublicWebModules(
platformModules: PlatformPublicModuleInfo[] | null | undefined,
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
): Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
return loaded.filter((module) => module.publicRoutes?.length);
}
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] { export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
if (!platformModules?.length) return localModules; if (!platformModules?.length) return localModules;
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
return routeContributionsForModuleList(modules); return routeContributionsForModuleList(modules);
} }
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
return publicRouteContributionsForModuleList(modules);
}
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] { export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules). return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
flatMap((capability) => capability.widgets ?? []). flatMap((capability) => capability.widgets ?? []).

View File

@@ -1516,6 +1516,166 @@
display: none; display: none;
} }
/* Principal-aware account/contact picker with structured external entry. */
.people-picker {
display: grid;
gap: 16px;
min-width: 0;
}
.people-picker-search-field,
.people-picker-selection,
.people-picker-manual {
display: grid;
gap: 7px;
min-width: 0;
}
.people-picker-search-control {
position: relative;
display: flex;
align-items: center;
}
.people-picker-search-control > svg {
position: absolute;
left: 12px;
z-index: 1;
color: var(--muted);
pointer-events: none;
}
.people-picker-search-control input {
padding-left: 38px;
}
.people-picker-search-control:focus-within > svg {
color: var(--primary);
}
.people-picker-results {
display: grid;
max-height: 360px;
overflow: auto;
border: var(--border-line-dark);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow-popover);
padding: 5px;
}
.people-picker-result-group {
display: grid;
gap: 2px;
}
.people-picker-result-group + .people-picker-result-group {
margin-top: 5px;
border-top: var(--border-line);
padding-top: 5px;
}
.people-picker-result-group-label {
color: var(--text-label);
font-size: 11px;
font-weight: 800;
letter-spacing: .04em;
padding: 5px 9px 3px;
text-transform: uppercase;
}
.people-picker-result {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
align-items: center;
gap: 16px;
width: 100%;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.people-picker-result:hover:not(:disabled),
.people-picker-result.is-active:not(:disabled),
.people-picker-result:focus-visible:not(:disabled) {
background: var(--primary-soft);
box-shadow: var(--primary-inset-ring);
outline: none;
}
.people-picker-result:disabled {
background: var(--control-disabled-bg);
color: var(--control-disabled-text);
cursor: not-allowed;
}
.people-picker-result-main,
.people-picker-result-context,
.people-picker-selected-person {
display: grid;
gap: 2px;
min-width: 0;
}
.people-picker-result-main strong,
.people-picker-result-main small,
.people-picker-result-context small,
.people-picker-selected-person strong,
.people-picker-selected-person small {
overflow-wrap: anywhere;
}
.people-picker-result-main small,
.people-picker-result-context,
.people-picker-selected-person small {
color: var(--muted);
font-size: 12px;
}
.people-picker-result-context {
justify-items: end;
text-align: right;
}
.people-picker-status {
min-height: 18px;
color: var(--muted);
font-size: 12px;
}
.people-picker-manual {
justify-items: start;
}
.people-picker-manual-form {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
gap: 12px;
width: 100%;
border: var(--border-line);
border-radius: 8px;
background: var(--surface-subtle);
padding: 12px;
}
.people-picker-manual-form > label {
display: grid;
gap: 6px;
min-width: 0;
}
.people-picker-manual-actions {
display: flex;
align-items: center;
gap: 8px;
}
.people-picker-manual-form > .form-help {
grid-column: 1 / -1;
margin: 0;
}
.people-picker-selection > .field-label,
.people-picker-selection > span > .field-label {
width: fit-content;
}
@media (max-width: 760px) {
.people-picker-result {
grid-template-columns: 1fr;
gap: 4px;
}
.people-picker-result-context {
justify-items: start;
text-align: left;
}
.people-picker-manual-form {
grid-template-columns: 1fr;
}
}
/* Bootstrap-like switch controls without importing Bootstrap. */ /* Bootstrap-like switch controls without importing Bootstrap. */
.toggle-switch-row { .toggle-switch-row {
display: flex; display: flex;

View File

@@ -94,7 +94,8 @@
padding: 18px 34px 16px; padding: 18px 34px 16px;
} }
.workspace-heading .mono-small { margin-top: 8px; } .workspace-heading .mono-small { margin-top: 8px; }
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } .panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
.panel { overflow: hidden; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } .card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); } .card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;} .card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}

View File

@@ -686,6 +686,15 @@
place-items: center; place-items: center;
} }
.table-action-placeholder {
display: block;
flex: 0 0 36px;
width: 36px;
height: 36px;
visibility: hidden;
pointer-events: none;
}
.data-grid-empty-message { .data-grid-empty-message {
color: var(--muted); color: var(--muted);
min-height: 64px; min-height: 64px;

View File

@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void; onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
}; };
export type PlatformPublicRouteContext = {
settings: ApiSettings;
auth: AuthInfo | null;
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
};
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string; export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
export type AdminSectionRenderContext = PlatformRouteContext & { export type AdminSectionRenderContext = PlatformRouteContext & {
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
render: (context: PlatformRouteContext) => ReactNode; render: (context: PlatformRouteContext) => ReactNode;
}; };
export type PlatformPublicRouteContribution = {
path: string;
order?: number;
render: (context: PlatformPublicRouteContext) => ReactNode;
};
export type PlatformUiCapabilities = Record<string, unknown>; export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformTranslationDictionary = Record<string, string>; export type PlatformTranslationDictionary = Record<string, string>;
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
remoteAssetContractVersion?: string | null; remoteAssetContractVersion?: string | null;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
routes?: PlatformRouteContribution[]; routes?: PlatformRouteContribution[];
publicRoutes?: PlatformPublicRouteContribution[];
translations?: PlatformTranslations; translations?: PlatformTranslations;
uiCapabilities?: PlatformUiCapabilities; uiCapabilities?: PlatformUiCapabilities;
runtimeUiCapabilities?: PlatformUiCapabilities; runtimeUiCapabilities?: PlatformUiCapabilities;
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
asset_manifest_integrity?: string | null; asset_manifest_integrity?: string | null;
asset_manifest_contract_version?: string | null; asset_manifest_contract_version?: string | null;
routes: PlatformFrontendRouteInfo[]; routes: PlatformFrontendRouteInfo[];
public_routes: Array<{
path: string;
component: string;
order: number;
}>;
nav: Array<{ nav: Array<{
path: string; path: string;
label: string; label: string;
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
settings_routes: PlatformFrontendRouteInfo[]; settings_routes: PlatformFrontendRouteInfo[];
}; };
export type PlatformPublicModuleInfo = {
id: string;
name: string;
version: string;
frontend: Pick<
PlatformFrontendModuleInfo,
| "module_id"
| "package_name"
| "asset_manifest"
| "asset_manifest_signature"
| "asset_manifest_public_key_id"
| "asset_manifest_integrity"
| "asset_manifest_contract_version"
| "public_routes"
>;
};
export type PlatformModuleInfo = { export type PlatformModuleInfo = {
id: string; id: string;
name: string; name: string;

View File

@@ -31,16 +31,24 @@ const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" }, campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" }, templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" }, files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" },
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" }, "address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" }, reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" }, settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" } admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
}; };
export function helpContextForPathname(pathname: string): HelpContext { export function helpContextForPathname(pathname: string, search = ""): HelpContext {
const route = pathname || "/"; const route = pathname || "/";
const segments = route.split("/").filter(Boolean); const segments = route.split("/").filter(Boolean);
if (segments[0] === "settings") {
const section = new URLSearchParams(search).get("section") || "";
if (section === "mail-profiles") {
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` };
}
}
if (segments[0] === "campaigns" && segments[1]) { if (segments[0] === "campaigns" && segments[1]) {
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route }; if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
if (segments[2] === "wizard") { if (segments[2] === "wizard") {

View File

@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
); );
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control"); assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form"); assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
const contextActions = renderToStaticMarkup( const contextActions = renderToStaticMarkup(
<TableActionGroup <TableActionGroup
actions={[ actions={[
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop }, { id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop }, { id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop } { id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
]} ]}
minimumSlots={4}
/> />
); );
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply"); assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form"); assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label"); assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip"); assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative"); assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
@@ -58,10 +63,16 @@ assertEqual(propagationStopped, true, "table actions do not trigger clickable ro
assertEqual(actionCalled, true, "table actions still invoke their handler"); assertEqual(actionCalled, true, "table actions still invoke their handler");
const noReorderActions = renderToStaticMarkup( const noReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} /> <DataGridRowActions reorderable={false} onAddBelow={noop} onRemove={noop} />
); );
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply"); assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
const unavailableReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
);
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>); const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button"); assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable"); assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");

View 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"
);

View 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");

View File

@@ -23,12 +23,15 @@
"tests/explorer-tree.test.tsx", "tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx", "tests/icon-button.test.tsx",
"tests/mail-components.test.tsx", "tests/mail-components.test.tsx",
"tests/people-picker.test.tsx",
"tests/resource-access-explanation.test.tsx", "tests/resource-access-explanation.test.tsx",
"tests/selection-list.test.tsx", "tests/selection-list.test.tsx",
"src/components/CredentialPanel.tsx", "src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx", "src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx", "src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx", "src/components/MessageDisplayPanel.tsx",
"src/components/people/PeoplePicker.tsx",
"src/components/people/peoplePickerTypes.ts",
"src/components/ResourceAccessExplanation.tsx", "src/components/ResourceAccessExplanation.tsx",
"src/components/SelectionList.tsx", "src/components/SelectionList.tsx",
"src/components/mail/MailServerSettingsPanel.tsx", "src/components/mail/MailServerSettingsPanel.tsx",

View File

@@ -17,7 +17,9 @@
"include": [ "include": [
"tests/module-capabilities.test.ts", "tests/module-capabilities.test.ts",
"tests/privacy-policy.test.ts", "tests/privacy-policy.test.ts",
"tests/help-context.test.ts",
"src/platform/moduleLogic.ts", "src/platform/moduleLogic.ts",
"src/utils/helpContext.ts",
"src/features/privacy/policyLogic.ts", "src/features/privacy/policyLogic.ts",
"src/types.ts" "src/types.ts"
] ]