26 Commits

Author SHA1 Message Date
d487726f4d chore(release): bump Core to 0.1.13 2026-07-22 10:40:27 +02:00
e6fc07da37 chore(release): bundle Campaign 0.1.10 2026-07-22 10:38:37 +02:00
e6d589eb07 fix(release): package Core migration runtime 2026-07-22 10:34:34 +02:00
59610e21d2 chore(release): record reviewed 0.1.12 migration heads 2026-07-22 09:06:56 +02:00
cece71d945 feat(webui): synchronize external DataGrid queries 2026-07-22 09:03:11 +02:00
22e8183846 fix(webui): translate MetricCard content 2026-07-22 08:48:42 +02:00
aa111a5fe1 chore(release): bump Core to 0.1.12 2026-07-22 08:41:54 +02:00
e6062fe9e4 fix(webui): enforce full-result DataGrid queries 2026-07-22 08:05:11 +02:00
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
56 changed files with 3666 additions and 97 deletions

View File

@@ -5,7 +5,11 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan

View File

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

View File

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

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

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.9"
version = "0.1.13"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -27,6 +27,12 @@ where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main"

View File

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

View File

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

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

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

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

@@ -7,6 +7,7 @@ import logging
import os
from pathlib import Path
import re
import sysconfig
from typing import Any
from alembic import command
@@ -462,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
installed_runtime_root,
]
for candidate in candidates:
if candidate is None:

View File

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

View File

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

View File

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

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

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

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

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

143
tests/test_wheel_runtime.py Normal file
View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import sysconfig
import tempfile
import unittest
class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
access_repository = repository_root.parent / "govoplan-access"
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels"
wheel_directory.mkdir()
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(repository_root),
cwd=temporary_root,
)
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(access_repository),
cwd=temporary_root,
)
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
self.assertEqual(1, len(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
venv_python = virtual_environment / "bin" / "python"
self._run(
str(venv_python),
"-m",
"pip",
"install",
"--no-deps",
str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root,
)
database_path = temporary_root / "wheel-runtime.db"
probe = self._run(
str(venv_python),
"-c",
self._probe_script(),
str(database_path),
cwd=temporary_root,
env={
**os.environ,
# Reuse only third-party test dependencies from the parent
# environment. Editable .pth files are not processed from
# PYTHONPATH, so Core itself can only come from the wheel.
"PYTHONPATH": sysconfig.get_path("purelib"),
"GOVOPLAN_CORE_SOURCE_ROOT": "",
"GOVOPLAN_ENABLED_MODULES": "",
},
)
result = json.loads(probe.stdout)
installed_package = Path(result["package_file"])
runtime_root = Path(result["runtime_root"])
self.assertTrue(installed_package.is_relative_to(virtual_environment))
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertIn("4f2a9c8e7b6d", result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])
@staticmethod
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
env=env,
check=False,
capture_output=True,
text=True,
)
if result.returncode:
raise AssertionError(
f"Command failed ({result.returncode}): {' '.join(command)}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result
@staticmethod
def _probe_script() -> str:
return """
import json
from pathlib import Path
import sys
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect
import govoplan_core
from govoplan_core.db.migrations import _repo_root, alembic_config
database_url = f"sqlite:///{Path(sys.argv[1])}"
config = alembic_config(database_url=database_url, enabled_modules=())
command.upgrade(config, "heads")
script = ScriptDirectory.from_config(config)
engine = create_engine(database_url)
try:
with engine.connect() as connection:
tables = sorted(inspect(connection).get_table_names())
finally:
engine.dispose()
print(json.dumps({
"package_file": govoplan_core.__file__,
"runtime_root": str(_repo_root()),
"heads": sorted(script.get_heads()),
"tables": tables,
}))
"""
if __name__ == "__main__":
unittest.main()

View File

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

View File

@@ -1,18 +1,18 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
@@ -787,13 +787,13 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
"version": "0.1.10",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#735e874bd03c55c626347f5356301fe221145b98",
"dependencies": {
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.9",
"version": "0.1.13",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -26,9 +26,11 @@
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
"test: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",
"version": "0.1.9",
"version": "0.1.13",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -31,7 +31,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
import { usePlatformLanguage } from "../i18n/LanguageContext";
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
const { translateText } = usePlatformLanguage();
const renderedValue = typeof value === "string" ? translateText(value) : value;
return (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
{detail && <div className="metric-detail">{detail}</div>}
<div className="metric-label">{translateText(label)}</div>
<div className="metric-value">{renderedValue}</div>
{detail && <div className="metric-detail">{translateText(detail)}</div>}
</div>
);
}

View File

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

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

@@ -30,18 +30,36 @@ export type DataGridQueryState = {
filters: Record<string, string>;
};
export type DataGridPagination = {
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
mode?: "client" | "server";
type DataGridPaginationBase = {
page: number;
pageSize: number;
totalRows?: number;
pageSizeOptions?: number[];
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
disabled?: boolean;
};
export type DataGridClientPagination = DataGridPaginationBase & {
/**
* Client mode filters and sorts before slicing. `rows` must therefore contain
* the complete logical result set, never an already paginated backend page.
*/
mode?: "client";
totalRows?: never;
};
export type DataGridServerPagination = DataGridPaginationBase & {
/**
* Server mode treats `rows` as the current backend page. The query callback
* must apply every emitted sort/filter to the backend before pagination.
*/
mode: "server";
/** Total rows after the backend has applied the current filters. */
totalRows: number;
};
export type DataGridPagination = DataGridClientPagination | DataGridServerPagination;
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
export type DataGridColumn<T> = {
@@ -78,7 +96,7 @@ type DataGridState = {
bufferWidth?: number;
};
type DataGridProps<T> = {
type DataGridBaseProps<T> = {
id: string;
rows: T[];
columns: DataGridColumn<T>[];
@@ -98,14 +116,29 @@ type DataGridProps<T> = {
storageKey?: string;
initialFilters?: Record<string, string | string[]>;
initialSort?: {columnId: string;direction: DataGridSortDirection;};
pagination?: DataGridPagination;
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
onQueryChange?: (query: DataGridQueryState) => void;
/**
* Synchronize a query selected outside the grid, for example by a summary
* count shortcut. Server grids should feed `onQueryChange` back into this
* value so the header controls and backend query remain in agreement.
*/
query?: DataGridQueryState;
};
/**
* Query ownership is deliberately explicit: client grids receive all rows and
* DataGrid queries them locally; server grids receive one page and must handle
* the complete query through `onQueryChange`.
*/
export type DataGridProps<T> = DataGridBaseProps<T> & (
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
);
export type DataGridRowActionsProps = {
disabled?: boolean;
removeDisabled?: boolean;
/** Set to false only when row ordering is not part of this table's action set. */
reorderable?: boolean;
onAddBelow: () => void;
onRemove: () => void;
onMoveUp?: () => void;
@@ -177,6 +210,7 @@ export default function DataGrid<T>({
storageKey,
initialFilters = {},
initialSort,
query,
pagination,
onQueryChange
}: DataGridProps<T>) {
@@ -203,7 +237,15 @@ export default function DataGrid<T>({
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
const [state, setState] = useState<DataGridState>(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort));
const externalQueryKey = query ? JSON.stringify(query) : "";
const normalizedExternalQuery = useMemo(
() => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined,
[externalQueryKey]
);
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
normalizedExternalQuery
));
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
@@ -211,14 +253,21 @@ export default function DataGrid<T>({
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
const serverQueryMode = pagination?.mode === "server";
const onQueryChangeRef = useRef(onQueryChange);
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
const lastLayoutSignatureRef = useRef<string | null>(null);
const lastContainerWidthRef = useRef<number | undefined>(undefined);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
const serverQueryMode = pagination?.mode === "server";
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
useEffect(() => {
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
if (!serverQueryMode) lastServerQueryRef.current = null;
}, [pagination, serverQueryMode]);
useEffect(() => {
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
}, [columns, effectiveResizeBehavior]);
@@ -231,6 +280,10 @@ export default function DataGrid<T>({
setState((current) => mergeInitialSort(current, normalizedInitialSort));
}, [normalizedInitialSort]);
useEffect(() => {
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
}, [normalizedExternalQuery]);
useEffect(() => {
try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
@@ -458,10 +511,17 @@ export default function DataGrid<T>({
useEffect(() => {
if (!serverQueryMode) return;
onQueryChangeRef.current?.({
const query = {
sort: state.sort ?? null,
filters: { ...(state.filters ?? {}) }
});
};
const previousQuery = lastServerQueryRef.current;
lastServerQueryRef.current = query;
const serverPagination = serverPaginationRef.current;
if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) {
serverPagination.onPageChange(1);
}
onQueryChangeRef.current?.(query);
}, [serverQueryMode, state.sort, state.filters]);
const filterTypes = useMemo(() => {
@@ -858,6 +918,7 @@ export function DataGridPaginationBar({
export function DataGridRowActions({
disabled = false,
removeDisabled = false,
reorderable = true,
onAddBelow,
onRemove,
onMoveUp,
@@ -879,20 +940,18 @@ export function DataGridRowActions({
disabled,
onClick: onAddBelow
},
{
reorderable && {
id: "move-up",
label: moveUpLabel,
icon: <ArrowUp size={16} aria-hidden="true" />,
applicable: Boolean(onMoveUp),
disabled,
disabled: disabled || !onMoveUp,
onClick: () => onMoveUp?.()
},
{
reorderable && {
id: "move-down",
label: moveDownLabel,
icon: <ArrowDown size={16} aria-hidden="true" />,
applicable: Boolean(onMoveDown),
disabled,
disabled: disabled || !onMoveDown,
onClick: () => onMoveDown?.()
},
{
@@ -910,16 +969,18 @@ export function DataGridRowActions({
export function DataGridEmptyAction({
disabled = false,
reorderable = true,
onAdd,
label = "i18n:govoplan-core.add_first_row.c82c15f2"
}: {disabled?: boolean;onAdd: () => void;label?: string;}) {
}: {disabled?: boolean;reorderable?: boolean;onAdd: () => void;label?: string;}) {
return (
<TableActionGroup
className="data-grid-row-actions data-grid-empty-row-actions"
minimumSlots={reorderable ? 4 : 2}
actions={[{
id: "add",
label,
@@ -1199,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
return { ...state, sort: initialSort };
}
function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState {
if (!query) return state;
const current = {
sort: state.sort ?? null,
filters: state.filters ?? {}
};
if (dataGridQueriesEqual(current, query)) return state;
return {
...state,
sort: query.sort ? { ...query.sort } : null,
filters: { ...query.filters }
};
}
function formatListFilter(values: string[]): string {
return `list:${JSON.stringify([...new Set(values)])}`;
}
@@ -1665,6 +1740,16 @@ function compareValues(a: unknown, b: unknown): number {
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
}
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function stringifyCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
export { default as PasswordField } from "./components/PasswordField";
export { default as PeoplePicker } from "./components/people/PeoplePicker";
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
export type { PasswordFieldProps } from "./components/PasswordField";
@@ -110,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail
export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
export { default as TableActionGroup } from "./components/table/TableActionGroup";
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";

View File

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

View File

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

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 {
for (const module of modules) {
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
import { renderToStaticMarkup } from "react-dom/server";
import Button from "../src/components/Button";
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid";
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
function noop() {}
@@ -32,18 +32,23 @@ const emptyAction = renderToStaticMarkup(
);
assertEqual(buttonCount(emptyAction), 1, "empty action renders the expected control");
assertEqual(nonSubmittingButtonCount(emptyAction), 1, "empty action does not submit an enclosing form");
assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "empty actions reserve the remaining ordinary row positions");
const contextActions = renderToStaticMarkup(
<TableActionGroup
actions={[
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
{ id: "remove", label: "Remove", icon: <span>R</span>, disabled: true, onClick: noop }
{ id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
]}
minimumSlots={4}
/>
);
assertEqual(buttonCount(contextActions), 2, "table action groups omit actions that do not apply");
assertEqual(nonSubmittingButtonCount(contextActions), 2, "table action groups do not submit an enclosing form");
assertEqual(buttonCount(contextActions), 3, "row-level unavailable actions remain visible");
assertEqual(nonSubmittingButtonCount(contextActions), 3, "table action groups do not submit an enclosing form");
assertEqual((contextActions.match(/disabled=""/g) ?? []).length, 2, "unavailable actions are disabled");
assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1, "minimum action slots reserve trailing positions");
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
@@ -58,11 +63,69 @@ assertEqual(propagationStopped, true, "table actions do not trigger clickable ro
assertEqual(actionCalled, true, "table actions still invoke their handler");
const noReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
<DataGridRowActions reorderable={false} onAddBelow={noop} onRemove={noop} />
);
assertEqual(buttonCount(noReorderActions), 2, "row actions omit reorder controls when ordering does not apply");
const unavailableReorderActions = renderToStaticMarkup(
<DataGridRowActions onAddBelow={noop} onRemove={noop} />
);
assertEqual(buttonCount(unavailableReorderActions), 4, "ordered rows retain unavailable boundary controls");
assertEqual((unavailableReorderActions.match(/disabled=""/g) ?? []).length, 2, "unavailable boundary controls are disabled");
const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission denied">Save</Button>);
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
type FilterRow = { id: string; name: string };
const filterRows: FilterRow[] = [
{ id: "first-non-match", name: "Ordinary row" },
{ id: "second-non-match", name: "Another row" },
{ id: "first-match", name: "Target alpha" },
{ id: "third-non-match", name: "Unrelated row" },
{ id: "second-match", name: "Target beta" },
{ id: "third-match", name: "Target gamma" }
];
const filterColumns: DataGridColumn<FilterRow>[] = [
{ id: "name", header: "Name", filterable: true, value: (row) => row.name }
];
const fullResultFilterMarkup = renderToStaticMarkup(
<DataGrid
id="full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
initialFilters={{ name: "target" }}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(fullResultFilterMarkup.includes("Target alpha"), true, "client filtering finds matches beyond the unfiltered first page");
assertEqual(fullResultFilterMarkup.includes("Target beta"), true, "client filtering happens before the first page is sliced");
assertEqual(fullResultFilterMarkup.includes("Target gamma"), false, "client pagination still limits the filtered page size");
assertEqual(fullResultFilterMarkup.includes("1\u20132"), true, "pagination counts describe the filtered result set");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?3/.test(fullResultFilterMarkup), true, "the filtered total includes matches from every source row");
const clearedFilterMarkup = renderToStaticMarkup(
<DataGrid
id="cleared-full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(clearedFilterMarkup.includes("Ordinary row"), true, "clearing filters restores the first unfiltered row");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?6/.test(clearedFilterMarkup), true, "clearing filters restores the full pagination total");
const externalShortcutMarkup = renderToStaticMarkup(
<DataGrid
id="external-query-shortcut-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
query={{ sort: null, filters: { name: "target" } }}
/>
);
assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query");
assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");

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,26 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
import { renderToStaticMarkup } from "react-dom/server";
import MetricCard from "../src/components/MetricCard";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const translatedMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<MetricCard
label="i18n:govoplan-core.installed_modules.32b3e799"
value="i18n:govoplan-core.core_only.d46ce7d9"
detail="i18n:govoplan-core.install_the_campaign_module_to_show_campaign_act.3085f23e"
tone="info"
/>
</PlatformLanguageProvider>
);
assert(translatedMarkup.includes('class="metric-card metric-info"'), "the visual tone is preserved");
assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated");
assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated");
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");

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,17 @@
"tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx",
"tests/mail-components.test.tsx",
"tests/metric-card.test.tsx",
"tests/people-picker.test.tsx",
"tests/resource-access-explanation.test.tsx",
"tests/selection-list.test.tsx",
"src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx",
"src/components/MetricCard.tsx",
"src/components/people/PeoplePicker.tsx",
"src/components/people/peoplePickerTypes.ts",
"src/components/ResourceAccessExplanation.tsx",
"src/components/SelectionList.tsx",
"src/components/mail/MailServerSettingsPanel.tsx",
@@ -42,6 +47,7 @@
"src/components/IconButton.tsx",
"src/components/admin/AdminIconButton.tsx",
"src/components/ToggleSwitch.tsx",
"src/components/table/DataGrid.tsx",
"src/components/table/TableActionGroup.tsx"
]
}

View File

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