Compare commits

...
4 Commits
Author SHA1 Message Date
zemion ac40774785 feat(webui): expose stable product destinations
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 18:06:52 +02:00
zemion 9a3008002d feat: define governed tenant erasure contracts
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 16:00:06 +02:00
zemion 9cb2080938 feat: collect infrastructure dependency inventories
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 15:00:08 +02:00
zemion 08c3e47b6d Exercise accessible resident permit journeys
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 14:03:43 +02:00
42 changed files with 2120 additions and 123 deletions
+1
View File
@@ -142,6 +142,7 @@ system:tenants:read
system:tenants:create system:tenants:create
system:tenants:update system:tenants:update
system:tenants:suspend system:tenants:suspend
system:tenants:erase
system:accounts:read system:accounts:read
system:accounts:create system:accounts:create
+13
View File
@@ -200,6 +200,19 @@ Feature providers remain responsible for their own semantics:
Ops projects the same Core-validated receipt. It must not maintain a second Ops projects the same Core-validated receipt. It must not maintain a second
parser with different validation or secret-handling rules. parser with different validation or secret-handling rules.
Core also defines the inverse, read-only dependency-inventory contract used
before the installer changes one of those infrastructure capabilities. An
enabled module registers
`infrastructure.dependency_inventory.<module_id>` and returns bounded, stable
references to its persisted configuration or data, a lifecycle state, scope,
numeric metrics, and a required operator action. Providers must not return
secrets or use this read to migrate state. The Core collector validates provider
identity and capability coverage, orders records deterministically, and marks
the complete inventory failed when any provider raises or violates the
contract. Ops is the authorized projection boundary; the installer remains the
consumer and must match installation id, freshness, completion and impacted
capability coverage before apply.
The admin wizard backend starts with these routes: The admin wizard backend starts with these routes:
- `GET /api/v1/admin/configuration-packages/catalog` - `GET /api/v1/admin/configuration-packages/catalog`
+8
View File
@@ -103,6 +103,14 @@ modal at narrow widths, closes with Escape, and restores focus to the triggering
control. Module journeys should add their own exact high-risk mappings; they do control. Module journeys should add their own exact high-risk mappings; they do
not need to reimplement the keyboard or dialog mechanics. not need to reimplement the keyboard or dialog mechanics.
The same conformance suite mounts the production Forms Runtime self-service and
assisted Anwohnerparkausweis surfaces with German module translations. Desktop
and mobile runs traverse native controls by keyboard, inspect accessible names
and landmarks, run WCAG 2.1 A/AA automation, verify responsive overflow, and
retain independent per-field assisted provenance. Physical assistive-technology
spot checks remain release evidence rather than being represented as browser
automation.
## Verification ## Verification
```bash ```bash
+30
View File
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
- bounded reference-option search provider contract - bounded reference-option search provider contract
- single-tenant and optional batched tenant summary provider contracts - single-tenant and optional batched tenant summary provider contracts
- tenant delete-veto provider contract - tenant delete-veto provider contract
- provider-neutral tenant-erasure preview, step, idempotency, and
reconciliation contracts in `govoplan_core.core.tenant_erasure`
- WebUI module contribution contract - WebUI module contribution contract
- navigation metadata contract - navigation metadata contract
- command/event envelope contract - command/event envelope contract
@@ -149,6 +151,17 @@ Destructive tenant lifecycle planning deliberately continues to use the
single-tenant path so it invokes every registered provider for the target single-tenant path so it invokes every registered provider for the target
tenant, independent of ordinary list-page projections. tenant, independent of ordinary list-page projections.
Governed populated-tenant erasure is separate from ordinary delete vetoes.
Modules contribute `tenancy.erasure_provider.<module_id>` capabilities with a
bounded resource inventory, explicit erase/retain/legal-hold/external/key/
backup dispositions, ordered destructive warnings, idempotent step execution,
and reconciliation. The collector fails closed when a provider is invalid or
fails. A module with nonzero tenant summary counts and no erasure capability is
reported as unsupported and blocks execution; modules with neither contract
are explicitly projected as outside tenant-persistence scope. Provider
evidence contains counts and stable references only and must never contain
secrets or erased subject data.
This list is the Milestone A kernel-contract freeze baseline. New module work This list is the Milestone A kernel-contract freeze baseline. New module work
may extend the kernel by adding explicit contracts, but existing contracts must may extend the kernel by adding explicit contracts, but existing contracts must
remain source-compatible through the 0.1.x split line unless a migration shim remain source-compatible through the 0.1.x split line unless a migration shim
@@ -1049,6 +1062,23 @@ available owner route. It emits `govoplan:product-surface-route-resolved` before
the redirect so migration telemetry can observe alias use without making the the redirect so migration telemetry can observe alias use without making the
technical module part of the ordinary label. technical module part of the ordinary label.
The shell projects every authorized, View-visible owner route with a product
contribution into one stable product navigation item. The product label and
entry path replace package topology in the primary rail; every contributing
owner path still marks that item active. `All available tools` is a collapsed,
permission-derived catalogue built independently of the active View, so a
focused workflow cannot remove the explicit escape. It may reveal an
authorized owner route that a View omitted, but never an unauthorized route.
Navigation visibility preferences do not delete catalogue entries, and the
original owner routes remain compatible deep links.
The initial promoted destinations are `work.items` at `/work`,
`meetings.calendar` at `/agenda`, `communication.messages` at `/messages`
(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels
and availability language are centralized in Core while Tasks, Calendar,
Mail/Postbox, and Files retain route, command, search, help, documentation,
authorization, and data ownership.
Use `ProductAvailabilityState` for unavailable and degraded outcomes. The Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
ordinary state explains the attempted outcome, consequence, recovery path and ordinary state explains the attempted outcome, consequence, recovery path and
responsible role. Exact module, capability, provider and correlation values may responsible role. Exact module, capability, provider and correlation values may
+3 -1
View File
@@ -236,7 +236,9 @@ instead of reproducing their behavior.
not self-explanatory. not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted - `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by `show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root. applying `ui-hide-help-hints` at the document root. When shown, the shared
marker is a labelled, keyboard-focusable help control and exposes its tooltip
on focus as well as pointer hover.
- Shared action-bearing components accept an optional disabled reason. In - Shared action-bearing components accept an optional disabled reason. In
particular, `MailServerSettingsPanel` forwards protocol-specific test particular, `MailServerSettingsPanel` forwards protocol-specific test
blockers into the shared focusable disabled-action tooltip; modules provide blockers into the shared focusable disabled-action tooltip; modules provide
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.40" version = "0.1.44"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -2,14 +2,18 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
import json import json
import os import os
from pathlib import Path from pathlib import Path
import re import re
from typing import Any from typing import Any, Protocol, runtime_checkable
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH" DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX = (
"infrastructure.dependency_inventory."
)
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024 MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
CAPABILITY_STATES = frozenset( CAPABILITY_STATES = frozenset(
{ {
@@ -20,12 +24,125 @@ CAPABILITY_STATES = frozenset(
} }
) )
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$") _ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
_DEPENDENCY_STATES = frozenset(
{"active", "inactive", "data_present", "pending_work", "runtime_binding"}
)
class InfrastructureCapabilityReceiptError(ValueError): class InfrastructureCapabilityReceiptError(ValueError):
pass pass
@dataclass(frozen=True, slots=True)
class InfrastructureDependency:
"""A non-secret module-owned dependency on deployment infrastructure."""
capability_id: str
module_id: str
dependency_type: str
dependency_ref: str
state: str
scope: str
summary: str
metrics: Mapping[str, int]
required_action: str
def __post_init__(self) -> None:
for field_name, value, maximum in (
("capability_id", self.capability_id, 120),
("module_id", self.module_id, 120),
("dependency_type", self.dependency_type, 120),
("dependency_ref", self.dependency_ref, 240),
("scope", self.scope, 120),
("summary", self.summary, 1000),
("required_action", self.required_action, 1000),
):
if (
not value.strip()
or len(value) > maximum
or any(ord(char) < 32 for char in value)
):
raise ValueError(
f"Infrastructure dependency {field_name} is invalid."
)
if self.state not in _DEPENDENCY_STATES:
raise ValueError(
f"Infrastructure dependency state is unsupported: {self.state!r}."
)
if len(self.metrics) > 20 or any(
not isinstance(key, str)
or not key.strip()
or len(key) > 80
or any(ord(char) < 32 for char in key)
or type(value) is not int
or value < 0
for key, value in self.metrics.items()
):
raise ValueError("Infrastructure dependency metrics are invalid.")
def to_dict(self) -> dict[str, object]:
return {
"capability_id": self.capability_id,
"module_id": self.module_id,
"dependency_type": self.dependency_type,
"dependency_ref": self.dependency_ref,
"state": self.state,
"scope": self.scope,
"summary": self.summary,
"metrics": dict(sorted(self.metrics.items())),
"required_action": self.required_action,
}
@runtime_checkable
class InfrastructureDependencyProvider(Protocol):
module_id: str
capability_ids: tuple[str, ...]
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
...
@dataclass(frozen=True, slots=True)
class InfrastructureDependencyProviderReport:
module_id: str
capability_ids: tuple[str, ...]
state: str
dependency_count: int
error: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"capability_ids": list(self.capability_ids),
"state": self.state,
"dependency_count": self.dependency_count,
"error": self.error,
}
@dataclass(frozen=True, slots=True)
class InfrastructureDependencyInventory:
installation_id: str
generated_at: str
complete: bool
inspected_capability_ids: tuple[str, ...]
providers: tuple[InfrastructureDependencyProviderReport, ...]
dependencies: tuple[InfrastructureDependency, ...]
schema_version: int = 1
def to_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"installation_id": self.installation_id,
"generated_at": self.generated_at,
"complete": self.complete,
"inspected_capability_ids": list(self.inspected_capability_ids),
"providers": [item.to_dict() for item in self.providers],
"dependencies": [item.to_dict() for item in self.dependencies],
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class InfrastructureCapability: class InfrastructureCapability:
id: str id: str
@@ -214,6 +331,134 @@ def deployment_capability_status(
} }
def collect_infrastructure_dependency_inventory(
registry: object,
*,
installation_id: str,
observed_at: datetime | None = None,
) -> InfrastructureDependencyInventory:
"""Collect actual module-owned dependencies without importing module internals."""
normalized_installation_id = installation_id.strip()
if not normalized_installation_id or len(normalized_installation_id) > 100:
raise ValueError("Infrastructure dependency installation id is invalid.")
capability_names = getattr(registry, "capability_names", None)
capability = getattr(registry, "capability", None)
if not callable(capability_names) or not callable(capability):
raise ValueError("Infrastructure dependency inventory requires a module registry.")
provider_names = tuple(
name
for name in capability_names()
if isinstance(name, str)
and name.startswith(INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX)
)
reports: list[InfrastructureDependencyProviderReport] = []
dependencies: list[InfrastructureDependency] = []
inspected_capability_ids: set[str] = set()
complete = True
for provider_name in sorted(provider_names):
expected_module_id = provider_name.removeprefix(
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX
)
module_id = expected_module_id or "unknown"
declared_ids: tuple[str, ...] = ()
try:
provider = capability(provider_name)
if not isinstance(provider, InfrastructureDependencyProvider):
raise TypeError("provider does not implement the inventory contract")
module_id = provider.module_id.strip()
declared_ids = tuple(
sorted(
{
item.strip()
for item in provider.capability_ids
if isinstance(item, str) and item.strip()
}
)
)
if (
module_id != expected_module_id
or len(module_id) > 120
or any(ord(char) < 32 for char in module_id)
or not declared_ids
or len(declared_ids) > 30
or any(
len(item) > 120 or any(ord(char) < 32 for char in item)
for item in declared_ids
)
):
raise ValueError("provider identity or capability declaration is invalid")
provider_dependencies = tuple(provider.infrastructure_dependencies())
if len(provider_dependencies) > 10_000:
raise ValueError("provider dependency inventory is too large")
seen_refs: set[tuple[str, str, str]] = set()
for item in provider_dependencies:
if not isinstance(item, InfrastructureDependency):
raise TypeError("provider returned an invalid dependency")
if item.module_id != module_id or item.capability_id not in declared_ids:
raise ValueError("provider returned a dependency outside its declaration")
identity = (
item.capability_id,
item.dependency_type,
item.dependency_ref,
)
if identity in seen_refs:
raise ValueError("provider returned a duplicate dependency")
seen_refs.add(identity)
if len(dependencies) + len(provider_dependencies) > 10_000:
raise ValueError("combined dependency inventory is too large")
dependencies.extend(provider_dependencies)
inspected_capability_ids.update(declared_ids)
reports.append(
InfrastructureDependencyProviderReport(
module_id=module_id,
capability_ids=declared_ids,
state="complete",
dependency_count=len(provider_dependencies),
)
)
except Exception as exc:
complete = False
inspected_capability_ids.update(declared_ids)
reports.append(
InfrastructureDependencyProviderReport(
module_id=module_id,
capability_ids=declared_ids,
state="error",
dependency_count=0,
error=(
f"{type(exc).__name__}: provider inventory could not be completed"
),
)
)
timestamp = observed_at or datetime.now(UTC)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return InfrastructureDependencyInventory(
installation_id=normalized_installation_id,
generated_at=timestamp.astimezone(UTC).isoformat(),
complete=complete,
inspected_capability_ids=tuple(sorted(inspected_capability_ids)),
providers=tuple(
sorted(reports, key=lambda item: (item.module_id, item.capability_ids))
),
dependencies=tuple(
sorted(
dependencies,
key=lambda item: (
item.capability_id,
item.module_id,
item.dependency_type,
item.dependency_ref,
),
)
),
)
def _capability(value: object) -> InfrastructureCapability: def _capability(value: object) -> InfrastructureCapability:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise InfrastructureCapabilityReceiptError( raise InfrastructureCapabilityReceiptError(
@@ -352,10 +597,16 @@ def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, obj
__all__ = [ __all__ = [
"CAPABILITY_STATES", "CAPABILITY_STATES",
"DEPLOYMENT_CAPABILITIES_ENV", "DEPLOYMENT_CAPABILITIES_ENV",
"INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX",
"InfrastructureCapability", "InfrastructureCapability",
"InfrastructureCapabilityReceipt", "InfrastructureCapabilityReceipt",
"InfrastructureCapabilityReceiptError", "InfrastructureCapabilityReceiptError",
"InfrastructureDependency",
"InfrastructureDependencyInventory",
"InfrastructureDependencyProvider",
"InfrastructureDependencyProviderReport",
"InfrastructurePostInstallTask", "InfrastructurePostInstallTask",
"collect_infrastructure_dependency_inventory",
"deployment_capability_status", "deployment_capability_status",
"infrastructure_capability_receipt_from_mapping", "infrastructure_capability_receipt_from_mapping",
"load_infrastructure_capability_receipt", "load_infrastructure_capability_receipt",
+467
View File
@@ -0,0 +1,467 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal, Protocol, runtime_checkable
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX = "tenancy.erasure_provider."
TenantErasureDisposition = Literal[
"erase",
"retain",
"legal_hold",
"external_cleanup",
"key_destroy",
"backup_expiry",
"unavailable",
]
TenantErasureStepKind = Literal[
"export",
"erase",
"retain",
"external_cleanup",
"key_destroy",
"backup_expiry",
"verify",
]
TenantErasureResultState = Literal[
"completed",
"pending",
"blocked",
"outcome_unknown",
]
_DISPOSITIONS = frozenset(
{
"erase",
"retain",
"legal_hold",
"external_cleanup",
"key_destroy",
"backup_expiry",
"unavailable",
}
)
_STEP_KINDS = frozenset(
{
"export",
"erase",
"retain",
"external_cleanup",
"key_destroy",
"backup_expiry",
"verify",
}
)
_RESULT_STATES = frozenset(
{"completed", "pending", "blocked", "outcome_unknown"}
)
def _text(value: str, label: str, *, maximum: int) -> str:
normalized = value.strip()
if (
not normalized
or len(normalized) > maximum
or any(ord(character) < 32 for character in normalized)
):
raise ValueError(f"Tenant erasure {label} is invalid.")
return normalized
def _texts(
values: tuple[str, ...],
label: str,
*,
maximum_items: int = 100,
maximum_length: int = 500,
) -> tuple[str, ...]:
if len(values) > maximum_items:
raise ValueError(f"Tenant erasure {label} has too many entries.")
normalized = tuple(
_text(value, label, maximum=maximum_length) for value in values
)
if len(normalized) != len(set(normalized)):
raise ValueError(f"Tenant erasure {label} contains duplicates.")
return normalized
def _metrics(values: Mapping[str, int]) -> dict[str, int]:
if len(values) > 30:
raise ValueError("Tenant erasure metrics has too many entries.")
normalized: dict[str, int] = {}
for key, value in values.items():
normalized_key = _text(key, "metric key", maximum=80)
if type(value) is not int or value < 0:
raise ValueError("Tenant erasure metric values must be non-negative integers.")
normalized[normalized_key] = value
return normalized
@dataclass(frozen=True, slots=True)
class TenantErasureResource:
resource_type: str
count: int
disposition: TenantErasureDisposition
summary: str
governance_ref: str | None = None
external: bool = False
def __post_init__(self) -> None:
_text(self.resource_type, "resource type", maximum=120)
_text(self.summary, "resource summary", maximum=1000)
if type(self.count) is not int or self.count < 0:
raise ValueError("Tenant erasure resource count is invalid.")
if self.disposition not in _DISPOSITIONS:
raise ValueError("Tenant erasure resource disposition is invalid.")
if self.governance_ref is not None:
_text(self.governance_ref, "governance reference", maximum=300)
def to_dict(self) -> dict[str, object]:
return {
"resource_type": self.resource_type,
"count": self.count,
"disposition": self.disposition,
"summary": self.summary,
"governance_ref": self.governance_ref,
"external": self.external,
}
@dataclass(frozen=True, slots=True)
class TenantErasureStep:
step_id: str
kind: TenantErasureStepKind
summary: str
destructive: bool
irreversible: bool
requires_reconciliation: bool = False
depends_on: tuple[str, ...] = ()
def __post_init__(self) -> None:
_text(self.step_id, "step id", maximum=160)
_text(self.summary, "step summary", maximum=1000)
if self.kind not in _STEP_KINDS:
raise ValueError("Tenant erasure step kind is invalid.")
_texts(self.depends_on, "step dependencies", maximum_length=160)
if self.step_id in self.depends_on:
raise ValueError("Tenant erasure step cannot depend on itself.")
if self.irreversible and not self.destructive:
raise ValueError("An irreversible tenant erasure step must be destructive.")
def to_dict(self) -> dict[str, object]:
return {
"step_id": self.step_id,
"kind": self.kind,
"summary": self.summary,
"destructive": self.destructive,
"irreversible": self.irreversible,
"requires_reconciliation": self.requires_reconciliation,
"depends_on": list(self.depends_on),
}
@dataclass(frozen=True, slots=True)
class TenantErasurePreview:
module_id: str
complete: bool
resources: tuple[TenantErasureResource, ...] = ()
steps: tuple[TenantErasureStep, ...] = ()
blockers: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
provider_revision: str = "1"
def __post_init__(self) -> None:
_text(self.module_id, "module id", maximum=120)
_text(self.provider_revision, "provider revision", maximum=120)
_texts(self.blockers, "blockers", maximum_length=1000)
_texts(self.warnings, "warnings", maximum_length=1000)
if len(self.resources) > 500 or len(self.steps) > 500:
raise ValueError("Tenant erasure preview is too large.")
resource_types = [item.resource_type for item in self.resources]
if len(resource_types) != len(set(resource_types)):
raise ValueError("Tenant erasure preview repeats a resource type.")
resources_requiring_action = tuple(
item for item in self.resources if item.count > 0
)
if resources_requiring_action and not self.steps and not self.blockers:
raise ValueError(
"Tenant erasure resources require steps or an explicit blocker."
)
if any(
item.count > 0 and item.disposition == "unavailable"
for item in self.resources
) and not self.blockers:
raise ValueError(
"Unavailable tenant erasure resources require an explicit blocker."
)
if not self.complete and not self.blockers:
raise ValueError(
"An incomplete tenant erasure preview requires an explicit blocker."
)
step_ids = [item.step_id for item in self.steps]
if len(step_ids) != len(set(step_ids)):
raise ValueError("Tenant erasure preview repeats a step id.")
known_step_ids = set(step_ids)
if any(
dependency not in known_step_ids
for step in self.steps
for dependency in step.depends_on
):
raise ValueError("Tenant erasure step references an unknown dependency.")
remaining = {
step.step_id: set(step.depends_on)
for step in self.steps
}
resolved: set[str] = set()
while remaining:
ready = sorted(
step_id
for step_id, dependencies in remaining.items()
if dependencies.issubset(resolved)
)
if not ready:
raise ValueError("Tenant erasure step dependencies contain a cycle.")
resolved.update(ready)
for step_id in ready:
remaining.pop(step_id)
@property
def allowed(self) -> bool:
return self.complete and not self.blockers
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"complete": self.complete,
"allowed": self.allowed,
"provider_revision": self.provider_revision,
"resources": [item.to_dict() for item in self.resources],
"steps": [item.to_dict() for item in self.steps],
"blockers": list(self.blockers),
"warnings": list(self.warnings),
}
@dataclass(frozen=True, slots=True)
class TenantErasureStepResult:
state: TenantErasureResultState
summary: str
receipt_ref: str | None = None
metrics: Mapping[str, int] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.state not in _RESULT_STATES:
raise ValueError("Tenant erasure result state is invalid.")
_text(self.summary, "result summary", maximum=1000)
if self.receipt_ref is not None:
_text(self.receipt_ref, "receipt reference", maximum=500)
_metrics(self.metrics)
def to_dict(self) -> dict[str, object]:
return {
"state": self.state,
"summary": self.summary,
"receipt_ref": self.receipt_ref,
"metrics": dict(sorted(_metrics(self.metrics).items())),
}
@runtime_checkable
class TenantErasureProvider(Protocol):
module_id: str
def preview_tenant_erasure(
self,
session: object,
tenant_id: str,
) -> TenantErasurePreview:
...
def execute_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
...
def reconcile_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
...
@dataclass(frozen=True, slots=True)
class TenantErasureInventory:
tenant_id: str
generated_at: datetime
complete: bool
modules: tuple[TenantErasurePreview, ...]
@property
def allowed(self) -> bool:
return self.complete and all(item.allowed for item in self.modules)
def to_dict(self) -> dict[str, object]:
generated_at = self.generated_at
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=UTC)
return {
"schema_version": 1,
"tenant_id": self.tenant_id,
"generated_at": generated_at.astimezone(UTC).isoformat(),
"complete": self.complete,
"allowed": self.allowed,
"modules": [item.to_dict() for item in self.modules],
}
def tenant_erasure_providers(registry: object) -> dict[str, TenantErasureProvider]:
capability_names = getattr(registry, "capability_names", None)
capability = getattr(registry, "capability", None)
if not callable(capability_names) or not callable(capability):
raise ValueError("Tenant erasure requires a module registry.")
providers: dict[str, TenantErasureProvider] = {}
for capability_name in sorted(capability_names()):
if not capability_name.startswith(TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX):
continue
expected_module_id = capability_name.removeprefix(
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX
)
provider = capability(capability_name)
if not isinstance(provider, TenantErasureProvider):
raise TypeError(
f"Tenant erasure provider {expected_module_id or 'unknown'} is invalid."
)
module_id = _text(provider.module_id, "provider module id", maximum=120)
if module_id != expected_module_id or module_id in providers:
raise ValueError("Tenant erasure provider identity is invalid.")
providers[module_id] = provider
return providers
def collect_tenant_erasure_inventory(
registry: object,
session: object,
tenant_id: str,
*,
observed_at: datetime | None = None,
) -> TenantErasureInventory:
normalized_tenant_id = _text(tenant_id, "tenant id", maximum=120)
manifests = getattr(registry, "manifests", None)
summary_providers = getattr(registry, "tenant_summary_providers", None)
if not callable(manifests) or not callable(summary_providers):
raise ValueError("Tenant erasure inventory requires a module registry.")
provider_by_module = tenant_erasure_providers(registry)
summary_by_module = dict(summary_providers())
manifest_ids = {
str(manifest.id)
for manifest in manifests()
if getattr(manifest, "id", None)
}
module_ids = manifest_ids | set(summary_by_module) | set(provider_by_module)
previews: list[TenantErasurePreview] = []
complete = True
for module_id in sorted(module_ids):
provider = provider_by_module.get(module_id)
if provider is not None:
try:
preview = provider.preview_tenant_erasure(session, normalized_tenant_id)
if not isinstance(preview, TenantErasurePreview):
raise TypeError("provider returned an invalid preview")
if preview.module_id != module_id:
raise ValueError("provider returned another module's preview")
except Exception as exc:
complete = False
preview = TenantErasurePreview(
module_id=module_id,
complete=False,
blockers=(
f"{type(exc).__name__}: provider preview could not be completed",
),
)
previews.append(preview)
complete = complete and preview.complete
continue
summary_provider = summary_by_module.get(module_id)
if summary_provider is None:
previews.append(
TenantErasurePreview(
module_id=module_id,
complete=True,
warnings=(
"Module declares no tenant-owned summary or erasure provider; no tenant persistence is in scope.",
),
provider_revision="manifest-no-tenant-data",
)
)
continue
try:
raw_counts = summary_provider(session, normalized_tenant_id)
counts = _metrics({str(key): int(value) for key, value in raw_counts.items()})
resources = tuple(
TenantErasureResource(
resource_type=resource_type,
count=count,
disposition="unavailable" if count else "erase",
summary=(
"Tenant-owned data requires a module erasure provider."
if count
else "The module reported no tenant-owned records."
),
)
for resource_type, count in sorted(counts.items())
)
blockers = (
("Tenant-owned data exists but the module has no erasure provider.",)
if any(counts.values())
else ()
)
preview = TenantErasurePreview(
module_id=module_id,
complete=True,
resources=resources,
blockers=blockers,
provider_revision="tenant-summary-fallback",
)
except Exception as exc:
complete = False
preview = TenantErasurePreview(
module_id=module_id,
complete=False,
blockers=(
f"{type(exc).__name__}: tenant summary could not be completed",
),
provider_revision="tenant-summary-fallback",
)
previews.append(preview)
timestamp = observed_at or datetime.now(UTC)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return TenantErasureInventory(
tenant_id=normalized_tenant_id,
generated_at=timestamp,
complete=complete,
modules=tuple(previews),
)
__all__ = [
"TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX",
"TenantErasureInventory",
"TenantErasurePreview",
"TenantErasureProvider",
"TenantErasureResource",
"TenantErasureStep",
"TenantErasureStepResult",
"collect_tenant_erasure_inventory",
"tenant_erasure_providers",
]
@@ -27,6 +27,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"system:tenants:create": "access:tenant:create", "system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update", "system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend", "system:tenants:suspend": "access:tenant:suspend",
"system:tenants:erase": "access:tenant:erase",
"system:accounts:read": "access:account:read", "system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create", "system:accounts:create": "access:account:create",
"system:accounts:update": "access:account:update", "system:accounts:update": "access:account:update",
@@ -78,6 +78,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"), PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"), PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"), PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
PermissionDefinition("system:tenants:erase", "Erase tenants", "Preview, approve, execute, and reconcile governed destructive tenant erasure.", "System administration", "system"),
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"), PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"), PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"), PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
+76
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import os import os
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import unittest import unittest
@@ -9,12 +10,53 @@ from unittest.mock import patch
from govoplan_core.core.infrastructure_capabilities import ( from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapabilityReceiptError, InfrastructureCapabilityReceiptError,
InfrastructureDependency,
collect_infrastructure_dependency_inventory,
deployment_capability_status, deployment_capability_status,
infrastructure_capability_receipt_from_mapping, infrastructure_capability_receipt_from_mapping,
load_infrastructure_capability_receipt, load_infrastructure_capability_receipt,
) )
class _InventoryProvider:
module_id = "mail"
capability_ids = ("mail.smtp",)
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
return (
InfrastructureDependency(
capability_id="mail.smtp",
module_id="mail",
dependency_type="smtp_endpoint",
dependency_ref="mail-server:server-1",
state="active",
scope="system",
summary="One active SMTP endpoint uses the deployment relay.",
metrics={"credential_binding_count": 1},
required_action="Rebind or retire the endpoint before removal.",
),
)
class _FailingInventoryProvider:
module_id = "files"
capability_ids = ("files.storage",)
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
raise RuntimeError("database URL must not escape")
class _Registry:
def __init__(self, providers: dict[str, object]) -> None:
self.providers = providers
def capability_names(self) -> tuple[str, ...]:
return tuple(self.providers)
def capability(self, name: str) -> object | None:
return self.providers.get(name)
def _receipt_payload() -> dict[str, object]: def _receipt_payload() -> dict[str, object]:
return { return {
"schema_version": 1, "schema_version": 1,
@@ -48,6 +90,40 @@ def _receipt_payload() -> dict[str, object]:
class InfrastructureCapabilityReceiptTests(unittest.TestCase): class InfrastructureCapabilityReceiptTests(unittest.TestCase):
def test_collects_non_secret_provider_dependency_inventory(self) -> None:
inventory = collect_infrastructure_dependency_inventory(
_Registry(
{
"infrastructure.dependency_inventory.mail": _InventoryProvider(),
"unrelated.capability": object(),
}
),
installation_id="govoplan-test",
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
self.assertTrue(inventory.complete)
self.assertEqual(("mail.smtp",), inventory.inspected_capability_ids)
self.assertEqual("mail-server:server-1", inventory.dependencies[0].dependency_ref)
self.assertEqual("2026-08-24T12:00:00+00:00", inventory.generated_at)
self.assertNotIn("database URL", json.dumps(inventory.to_dict()))
def test_provider_failure_makes_inventory_incomplete_without_leaking_error(self) -> None:
inventory = collect_infrastructure_dependency_inventory(
_Registry(
{
"infrastructure.dependency_inventory.files": (
_FailingInventoryProvider()
)
}
),
installation_id="govoplan-test",
)
self.assertFalse(inventory.complete)
self.assertEqual("error", inventory.providers[0].state)
self.assertNotIn("database URL", str(inventory.providers[0].error))
def test_parses_typed_capability_and_task_lookup(self) -> None: def test_parses_typed_capability_and_task_lookup(self) -> None:
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload()) receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
+51 -9
View File
@@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read")) self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read")) self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read")) self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
self.assertTrue(
scopes_grant_compatible(
["access:tenant:erase"],
"system:tenants:erase",
)
)
self.assertFalse(
scopes_grant_compatible(
["system:tenants:write"],
"system:tenants:erase",
)
)
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read")) self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
self.assertTrue( self.assertTrue(
scopes_grant_compatible( scopes_grant_compatible(
@@ -1015,8 +1027,10 @@ finally:
json={"mode": "destroy", "reason": "not supported"}, json={"mode": "destroy", "reason": "not supported"},
) )
self.assertEqual(409, destructive.status_code, destructive.text) self.assertEqual(409, destructive.status_code, destructive.text)
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]} self.assertIn(
self.assertIn("tenant_data_present", issue_codes) "Direct destructive deletion is disabled",
destructive.json()["detail"]["message"],
)
with database.session() as session: with database.session() as session:
empty_tenant = Tenant( empty_tenant = Tenant(
@@ -1029,15 +1043,43 @@ finally:
session.commit() session.commit()
empty_tenant_id = empty_tenant.id empty_tenant_id = empty_tenant.id
destroyed = client.request( erasure_policy = client.patch(
"DELETE", "/api/v1/admin/tenant-erasure-policy",
f"/api/v1/admin/tenants/{empty_tenant_id}",
headers=headers, headers=headers,
json={"mode": "destroy", "reason": "empty tenant cleanup"}, json={
"production_profile": False,
"required_approvals": 1,
"preview_ttl_seconds": 900,
"recent_authentication_seconds": 900,
},
) )
self.assertEqual(200, destroyed.status_code, destroyed.text) self.assertEqual(200, erasure_policy.status_code, erasure_policy.text)
self.assertEqual("destroy", destroyed.json()["plan"]["action"]) erasure_preview = client.post(
self.assertTrue(destroyed.json()["plan"]["destructive_supported"]) f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations",
headers=headers,
json={
"idempotency_key": f"empty-destroy-{name}",
"reason": "empty tenant cleanup",
},
)
self.assertEqual(201, erasure_preview.status_code, erasure_preview.text)
self.assertTrue(erasure_preview.json()["preview"]["allowed"])
operation_id = erasure_preview.json()["id"]
approved_erasure = client.post(
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/approve",
headers=headers,
json={"confirmation": f"empty-destroy-{name}"},
)
self.assertEqual(200, approved_erasure.status_code, approved_erasure.text)
self.assertEqual("ready", approved_erasure.json()["state"])
executed_erasure = client.post(
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/execute",
headers=headers,
json={"confirmation": f"empty-destroy-{name}"},
)
self.assertEqual(200, executed_erasure.status_code, executed_erasure.text)
self.assertEqual("completed", executed_erasure.json()["state"])
self.assertIsNone(executed_erasure.json()["reason"])
retired = client.request( retired = client.request(
"DELETE", "DELETE",
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from govoplan_core.core.tenant_erasure import (
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
TenantErasurePreview,
TenantErasureResource,
TenantErasureStep,
TenantErasureStepResult,
collect_tenant_erasure_inventory,
tenant_erasure_providers,
)
class _Provider:
module_id = "files"
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
del session
assert tenant_id == "tenant-1"
return TenantErasurePreview(
module_id=self.module_id,
complete=True,
resources=(
TenantErasureResource(
resource_type="file_blobs",
count=2,
disposition="erase",
summary="Two tenant-owned file blobs will be erased.",
),
),
steps=(
TenantErasureStep(
step_id="erase-blobs",
kind="erase",
summary="Erase tenant-owned file blobs.",
destructive=True,
irreversible=True,
),
),
)
def execute_tenant_erasure_step(
self, session, tenant_id: str, step_id: str, idempotency_key: str
) -> TenantErasureStepResult:
del session, tenant_id, step_id, idempotency_key
return TenantErasureStepResult(
state="completed",
summary="Tenant file blobs erased.",
metrics={"deleted": 2},
)
def reconcile_tenant_erasure_step(
self, session, tenant_id: str, step_id: str, idempotency_key: str
) -> TenantErasureStepResult:
return self.execute_tenant_erasure_step(
session, tenant_id, step_id, idempotency_key
)
class _Registry:
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
self._provider = provider
self._counts = counts
def manifests(self):
return (
SimpleNamespace(id="core"),
SimpleNamespace(id="files"),
SimpleNamespace(id="wiki"),
)
def capability_names(self):
if self._provider is None:
return ()
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
def capability(self, name: str):
assert name.endswith("files")
return self._provider
def tenant_summary_providers(self):
if self._counts is None:
return {}
return {"files": lambda _session, _tenant_id: self._counts}
def test_contract_rejects_unsafe_irreversible_step() -> None:
with pytest.raises(ValueError, match="must be destructive"):
TenantErasureStep(
step_id="unsafe",
kind="erase",
summary="Invalid step.",
destructive=False,
irreversible=True,
)
def test_contract_rejects_cyclic_step_dependencies() -> None:
with pytest.raises(ValueError, match="contain a cycle"):
TenantErasurePreview(
module_id="files",
complete=True,
steps=(
TenantErasureStep(
step_id="first",
kind="erase",
summary="First.",
destructive=True,
irreversible=True,
depends_on=("second",),
),
TenantErasureStep(
step_id="second",
kind="verify",
summary="Second.",
destructive=False,
irreversible=False,
depends_on=("first",),
),
),
)
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
resource = TenantErasureResource(
resource_type="files",
count=1,
disposition="erase",
summary="One file exists.",
)
with pytest.raises(ValueError, match="steps or an explicit blocker"):
TenantErasurePreview(
module_id="files",
complete=True,
resources=(resource,),
)
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
inventory = collect_tenant_erasure_inventory(
_Registry(provider=_Provider()),
object(),
"tenant-1",
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
assert inventory.complete
assert inventory.allowed
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
assert inventory.modules[1].steps[0].irreversible
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
def test_summary_fallback_blocks_when_data_exists() -> None:
inventory = collect_tenant_erasure_inventory(
_Registry(counts={"file_blobs": 3}),
object(),
"tenant-1",
)
files = next(item for item in inventory.modules if item.module_id == "files")
assert inventory.complete
assert not inventory.allowed
assert files.resources[0].disposition == "unavailable"
assert files.blockers == (
"Tenant-owned data exists but the module has no erasure provider.",
)
def test_provider_identity_must_match_capability_suffix() -> None:
provider = _Provider()
provider.module_id = "mail"
with pytest.raises(ValueError, match="identity"):
tenant_erasure_providers(_Registry(provider=provider))
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"initialJs": { "initialJs": {
"rawBytes": 524288, "rawBytes": 524288,
"gzipBytes": 163840 "gzipBytes": 164128
}, },
"asyncChunk": { "asyncChunk": {
"rawBytes": 393216, "rawBytes": 393216,
+189 -2
View File
@@ -1,6 +1,9 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react"; import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage";
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail"; import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
import ActionToolbar from "../src/components/ActionToolbar"; import ActionToolbar from "../src/components/ActionToolbar";
import Button from "../src/components/Button"; import Button from "../src/components/Button";
@@ -27,12 +30,23 @@ import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar"; import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import HelpMenu from "../src/layout/HelpMenu"; import HelpMenu from "../src/layout/HelpMenu";
import IconRail from "../src/layout/IconRail";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import { import {
createQuickAccessLaunchContext, createQuickAccessLaunchContext,
quickAccessLaunchState quickAccessLaunchState
} from "../src/platform/launchContext"; } from "../src/platform/launchContext";
import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types"; import { projectProductNavigation } from "../src/platform/productSurfaces";
import type {
ApiSettings,
AuthInfo,
EffectiveViewProjection,
PlatformNavItem,
PlatformWebModule,
ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessToolMetadata
} from "../src/types";
export default function ConformanceApp() { export default function ConformanceApp() {
const location = useLocation(); const location = useLocation();
@@ -40,6 +54,20 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true); const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState(""); const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("product-navigation")) {
return <ProductNavigationScenario />;
}
if (location.pathname.startsWith("/forms/public/")) {
return <PublicFormPage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
}
if (location.pathname === "/forms-runtime") {
return <FormsRuntimePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
}
if (location.pathname.startsWith("/forms-runtime/")) {
return <FormInstancePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
}
return ( return (
<main className="conformance-root" data-conformance-id="shared-ui-lab"> <main className="conformance-root" data-conformance-id="shared-ui-lab">
<PageLayout <PageLayout
@@ -172,6 +200,39 @@ export default function ConformanceApp() {
); );
} }
function ProductNavigationScenario() {
const projection = useMemo(
() => projectProductNavigation(
PRODUCT_NAV_ITEMS,
PRODUCT_NAV_MODULES,
PRODUCT_NAV_AUTH
),
[]
);
return (
<div className="app-shell" data-conformance-id="product-navigation">
<IconRail
navItems={projection.primaryItems}
allToolItems={projection.allToolItems}
productAreas={PRODUCT_NAV_AREAS}
/>
<main className="main-area">
<PageLayout
archetype="overview"
mode="embedded"
title="Anwohnerparkausweis bearbeiten"
description="Die Navigation beschreibt Arbeit und Ergebnisse; technische Eigentümer bleiben nachvollziehbar erreichbar."
>
<StatePanel
title="Vorgang ist bereit"
description="Nutzen Sie Arbeit, Kalender, Nachrichten oder Dateien für den nächsten Schritt."
/>
</PageLayout>
</main>
</div>
);
}
function HelpConformanceScenario() { function HelpConformanceScenario() {
return ( return (
<section className="conformance-section" aria-labelledby="help-heading"> <section className="conformance-section" aria-labelledby="help-heading">
@@ -290,6 +351,132 @@ const CONFORMANCE_AUTH = {
groups_loaded: true groups_loaded: true
} satisfies AuthInfo; } satisfies AuthInfo;
const PRODUCT_NAV_AUTH = {
...CONFORMANCE_AUTH,
scopes: [
"tasks:item:read",
"calendar:event:read",
"mail:mailbox:read",
"postbox:message:read",
"files:file:read"
]
} satisfies AuthInfo;
const PRODUCT_NAV_ITEMS: PlatformNavItem[] = [
{ to: "/tasks", label: "Tasks", icon: ListChecks, surfaceId: "tasks.nav.tasks", anyOf: ["tasks:item:read"], order: 30 },
{ to: "/files", label: "Files", icon: Folder, surfaceId: "files.nav.files", anyOf: ["files:file:read"], order: 40 },
{ to: "/mail", label: "Mail", icon: Mail, surfaceId: "mail.nav.mail", anyOf: ["mail:mailbox:read"], order: 50 },
{ to: "/postbox", label: "Postbox", icon: Inbox, surfaceId: "postbox.nav.postbox", anyOf: ["postbox:message:read"], order: 51 },
{ to: "/calendar", label: "Calendar", icon: CalendarDays, surfaceId: "calendar.nav.calendar", anyOf: ["calendar:event:read"], order: 55 }
];
const PRODUCT_NAV_AREAS: ProductAreaContribution[] = [
{ id: "work", moduleId: "tasks", label: "i18n:govoplan-core.product_area.work", iconName: "list-checks", surfaceIds: ["tasks.nav.tasks"], order: 10 },
{ id: "records-documents", moduleId: "files", label: "i18n:govoplan-core.product_area.records_documents", iconName: "folder", surfaceIds: ["files.nav.files"], order: 30 },
{ id: "communication", moduleId: "mail", label: "i18n:govoplan-core.product_area.communication", iconName: "mail", surfaceIds: ["mail.nav.mail", "postbox.nav.postbox"], order: 40 },
{ id: "meetings-decisions", moduleId: "calendar", label: "i18n:govoplan-core.product_area.meetings_decisions", iconName: "calendar", surfaceIds: ["calendar.nav.calendar"], order: 50 }
];
const PRODUCT_NAV_MODULES: PlatformWebModule[] = [
productModule("tasks", productSurface({
id: "work.items",
moduleId: "tasks",
label: "i18n:govoplan-core.product_surface.work",
description: "i18n:govoplan-core.product_surface.work_description",
iconName: "list-checks",
entryPath: "/work",
routePath: "/tasks",
surfaceIds: ["tasks.nav.tasks"],
anyOf: ["tasks:item:read"]
})),
productModule("files", productSurface({
id: "records.files",
moduleId: "files",
label: "i18n:govoplan-core.product_surface.files",
description: "i18n:govoplan-core.product_surface.files_description",
iconName: "folder",
entryPath: "/documents",
routePath: "/files",
surfaceIds: ["files.nav.files"],
anyOf: ["files:file:read"]
})),
productModule("mail", productSurface({
id: "communication.messages",
moduleId: "mail",
label: "i18n:govoplan-core.product_surface.messages",
description: "i18n:govoplan-core.product_surface.messages_description",
iconName: "mail",
entryPath: "/messages",
routePath: "/mail",
surfaceIds: ["mail.nav.mail"],
anyOf: ["mail:mailbox:read"],
aliases: ["/inbox"]
})),
productModule("postbox", productSurface({
id: "communication.messages",
moduleId: "postbox",
label: "i18n:govoplan-core.product_surface.messages",
description: "i18n:govoplan-core.product_surface.messages_description",
iconName: "mail",
entryPath: "/messages",
routePath: "/postbox",
surfaceIds: ["postbox.nav.postbox"],
anyOf: ["postbox:message:read"],
aliases: ["/inbox"],
order: 20
})),
productModule("calendar", productSurface({
id: "meetings.calendar",
moduleId: "calendar",
label: "i18n:govoplan-core.product_surface.calendar",
description: "i18n:govoplan-core.product_surface.calendar_description",
iconName: "calendar",
entryPath: "/agenda",
routePath: "/calendar",
surfaceIds: ["calendar.nav.calendar"],
anyOf: ["calendar:event:read"]
}))
];
function productModule(id: string, surface: ProductSurfaceContribution): PlatformWebModule {
return { id, label: id, version: "test", productSurfaces: [surface] };
}
function productSurface(
partial: Pick<ProductSurfaceContribution,
"id" | "moduleId" | "label" | "description" | "iconName" | "entryPath" |
"routePath" | "surfaceIds" | "anyOf"> & Partial<ProductSurfaceContribution>
): ProductSurfaceContribution {
return {
contractVersion: "1",
presentations: ["task", "reader"],
capabilityIds: [],
searchSourceIds: [],
helpContextIds: [],
documentationTopicIds: [],
allOf: [],
aliases: [],
order: 10,
unavailable: {
reason: "authorization",
title: "Not available",
description: "The destination is not available for this responsibility.",
resolution: "Ask the access administrator to review the assignment."
},
...partial
};
}
const FORMS_RUNTIME_AUTH = {
...CONFORMANCE_AUTH,
scopes: [
"forms_runtime:submission:assist",
"forms_runtime:submission:participate",
"forms_runtime:workspace:read",
"forms_runtime:workspace:write"
]
} satisfies AuthInfo;
const CONFORMANCE_SETTINGS: ApiSettings = { const CONFORMANCE_SETTINGS: ApiSettings = {
apiBaseUrl: "", apiBaseUrl: "",
apiKey: "", apiKey: "",
+39 -7
View File
@@ -1,20 +1,52 @@
// Narrow facade used only by the conformance build. It lets the optional // Narrow facade used only by the conformance build. It lets optional modules
// Quick Access module exercise its real rail without pulling the composed // exercise their real task surfaces without pulling the composed application's
// application's generated module catalogue into this isolated test bundle. // generated module catalogue into this isolated test bundle.
export { apiFetch } from "../src/api/client"; export { apiFetch, apiPath } from "../src/api/client";
export { default as ActionBlockerHint } from "../src/components/ActionBlockerHint";
export { default as ActionToolbar } from "../src/components/ActionToolbar";
export { default as Button } from "../src/components/Button";
export { default as ConfirmDialog } from "../src/components/ConfirmDialog";
export { default as DescriptionList, DescriptionItem } from "../src/components/DescriptionList";
export { default as Dialog } from "../src/components/Dialog";
export { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
export { default as DismissibleAlert } from "../src/components/DismissibleAlert"; export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink"; export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
export type { DocumentationHelpReference } from "../src/components/help/documentationHelp";
export { default as FileDropZone } from "../src/components/FileDropZone";
export { default as FormField } from "../src/components/FormField";
export { FormGrid } from "../src/components/ContentGrid";
export { default as IconButton } from "../src/components/IconButton"; export { default as IconButton } from "../src/components/IconButton";
export { default as LoadingFrame } from "../src/components/LoadingFrame"; export { default as LoadingFrame } from "../src/components/LoadingFrame";
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
export { usePlatformLanguage } from "../src/i18n/LanguageContext"; export { default as PageScrollViewport } from "../src/components/PageScrollViewport";
export {
default as SelectionList,
SelectionListItem,
SelectionListItemContent
} from "../src/components/SelectionList";
export { default as StatePanel } from "../src/components/StatePanel";
export { default as StatusBadge } from "../src/components/StatusBadge";
export { default as ToggleSwitch } from "../src/components/ToggleSwitch";
export {
useGuardedNavigate,
useUnsavedDraftGuard
} from "../src/components/UnsavedChangesGuard";
export {
i18nMessage,
usePlatformLanguage
} from "../src/i18n/LanguageContext";
export { usePlatformModuleInstalled } from "../src/platform/ModuleContext";
export { export {
dispatchQuickAccessResult, dispatchQuickAccessResult,
quickAccessLaunchState quickAccessLaunchState
} from "../src/platform/launchContext"; } from "../src/platform/launchContext";
export { i18nMessage } from "../src/i18n/LanguageContext"; export { hasScope } from "../src/utils/permissions";
export { default as WorkspaceActionBar } from "../src/components/WorkspaceActionBar";
export { default as WorkspaceFrame } from "../src/components/WorkspaceFrame";
export type { export type {
ApiSettings, ApiSettings,
PlatformRouteContext,
PlatformTranslations,
QuickAccessRailProps, QuickAccessRailProps,
QuickAccessToolsUiCapability QuickAccessToolsUiCapability
} from "../src/types"; } from "../src/types";
+12 -3
View File
@@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router"; import { BrowserRouter, Route, Routes } from "react-router";
import ConformanceApp from "./ConformanceApp"; import ConformanceApp from "./ConformanceApp";
import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations";
import { productSurfaceTranslations } from "../src/index";
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard"; import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext"; import { PlatformModulesProvider } from "../src/platform/ModuleContext";
@@ -14,6 +16,7 @@ import "../src/styles/badges.css";
import "../src/styles/components.css"; import "../src/styles/components.css";
import "../src/styles/dialogs.css"; import "../src/styles/dialogs.css";
import "@govoplan/quick-access-webui/styles/quick-access.css"; import "@govoplan/quick-access-webui/styles/quick-access.css";
import "../../../govoplan-forms-runtime/webui/src/styles/forms-runtime.css";
import "./conformance.css"; import "./conformance.css";
const theme = new URLSearchParams(window.location.search).get("theme"); const theme = new URLSearchParams(window.location.search).get("theme");
@@ -35,9 +38,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<PlatformModulesProvider modules={CONFORMANCE_MODULES}> <PlatformModulesProvider modules={CONFORMANCE_MODULES}>
<PlatformLanguageProvider preferredLanguageCode="de"> <PlatformLanguageProvider
preferredLanguageCode="de"
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<ConformanceApp /> <Routes>
<Route path="/forms/public/:publicId" element={<ConformanceApp />} />
<Route path="/forms-runtime/:instanceId" element={<ConformanceApp />} />
<Route path="*" element={<ConformanceApp />} />
</Routes>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</PlatformLanguageProvider> </PlatformLanguageProvider>
</PlatformModulesProvider> </PlatformModulesProvider>
@@ -19,6 +19,125 @@ async function expectNoAccessibilityViolations(page: import("@playwright/test").
expect(violations).toEqual([]); expect(violations).toEqual([]);
} }
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 }
]) {
test(`resident permit self-service is keyboard and accessibility conformant on ${viewport.name}`, async ({ page }) => {
const journey = await mockPublicResidentPermitJourney(page);
await page.setViewportSize(viewport);
await page.goto("/forms/public/resident-parking-permit?theme=light");
await expect(page.getByRole("heading", { level: 1, name: "Anwohnerparkausweis beantragen" })).toBeVisible();
await expectNoAccessibilityViolations(page);
const name = page.getByLabel("Name der antragstellenden Person");
await name.focus();
await page.keyboard.type("Ada Lovelace");
await page.keyboard.press("Tab");
await expect(page.getByLabel("E-Mail-Adresse")).toBeFocused();
await page.keyboard.type("ada.lovelace@example.test");
await page.keyboard.press("Tab");
await expect(page.getByLabel("Hauptwohnsitz")).toBeFocused();
await page.keyboard.type("Musterstraße 17, 10115 Berlin");
await page.keyboard.press("Tab");
await expect(page.getByLabel("Kfz-Kennzeichen")).toBeFocused();
await page.keyboard.type("B-AL 1843");
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Entwurf speichern" })).toBeFocused();
await page.keyboard.press("Enter");
await expect.poll(() => journey.savedValues()).toEqual({
applicant_name: "Ada Lovelace",
applicant_email: "ada.lovelace@example.test",
residence_address: "Musterstraße 17, 10115 Berlin",
licence_plate: "B-AL 1843"
});
await page.getByRole("button", { name: "Absenden" }).click();
const confirm = page.getByRole("alertdialog", { name: "Formular absenden" });
await expect(confirm).toBeVisible();
await expectNoAccessibilityViolations(page);
await confirm.getByRole("button", { name: "Absenden" }).click();
await expect(page.getByText("Übermittlung eingegangen")).toBeVisible();
await expect(page.getByText("receipt-rpp-2026-0001")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test(`resident permit assisted intake preserves per-field provenance on ${viewport.name}`, async ({ page }) => {
const journey = await mockAssistedResidentPermitJourney(page);
await page.setViewportSize(viewport);
await page.goto("/forms-runtime?theme=light");
await page.getByRole("button", { name: "Assistierte Erfassung" }).click();
const startDialog = page.getByRole("dialog", { name: "Assistierte Erfassung starten" });
await expect(startDialog).toBeVisible();
await expectNoAccessibilityViolations(page);
await startDialog.getByLabel("Referenz der betroffenen Partei").fill("party:resident-ada-lovelace");
await startDialog.getByLabel("Referenz der zuständigen Funktion").fill("function:parking-permits");
await startDialog.getByLabel("Zweck").fill("Anwohnerparkausweis beantragen");
await startDialog.getByLabel("Referenz der Rechtsgrundlage").fill("law:resident-parking-permit");
await startDialog.getByLabel("Barrierefreiheits- oder Kommunikationsunterstützung").fill("Leichte Sprache");
const notice = startDialog.getByRole("checkbox", { name: "Datenschutz- und Verfahrenshinweis wurde erteilt" });
await notice.focus();
await page.keyboard.press("Space");
await expect(notice).toBeChecked();
await startDialog.getByLabel("Referenz der betroffenen Partei").focus();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toHaveAttribute("aria-label", "Feldhilfe anzeigen");
await page.keyboard.press("Tab");
await expect(startDialog.getByLabel("Referenz der vertretenen Partei")).toBeFocused();
await startDialog.getByRole("button", { name: "Sitzung starten" }).click();
await expect(page).toHaveURL(/\/forms-runtime\/assisted-rpp-1$/);
await expect(page.getByRole("heading", { level: 1, name: "Anwohnerparkausweis beantragen" })).toBeVisible();
await page.getByLabel("Name der antragstellenden Person").fill("Ada Lovelace");
await page.getByLabel("E-Mail-Adresse").fill("ada.lovelace@example.test");
await page.getByLabel("Hauptwohnsitz").fill("Musterstraße 17, 10115 Berlin");
await page.getByLabel("Kfz-Kennzeichen").fill("B-AL 1843");
await page.getByLabel("Änderungsgrund").fill("Angaben gemeinsam mit der antragstellenden Person erfasst.");
await page.getByRole("button", { name: "Entwurf speichern" }).click();
await page.getByRole("button", { name: "Rücklesen und absenden" }).click();
const readback = page.getByRole("dialog", { name: "Assistiertes Rücklesen erfassen" });
await expect(readback).toBeVisible();
await expect(readback.getByRole("group", { name: "Hauptwohnsitz" })).toBeVisible();
await expectNoAccessibilityViolations(page);
const addressSource = readback.getByRole("group", { name: "Hauptwohnsitz" });
await addressSource.getByLabel("Wertquelle").selectOption("document");
await addressSource.getByLabel("Quellenvertrauen").selectOption("verified");
await addressSource.getByLabel("Erklärende Partei oder Quellenreferenz").fill("files:residence-proof-2026");
const plateSource = readback.getByRole("group", { name: "Kfz-Kennzeichen" });
await plateSource.getByLabel("Wertquelle").focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await plateSource.getByLabel("Quellenvertrauen").selectOption("verified");
await plateSource.getByLabel("Erklärende Partei oder Quellenreferenz").fill("register:vehicle-B-AL-1843");
await readback.getByRole("button", { name: "Erfassen und fortfahren" }).click();
await expect.poll(() => journey.confirmationSources()).toMatchObject({
applicant_name: { source: "person_statement", confidence: "stated" },
residence_address: {
source: "document",
confidence: "verified",
declared_by_ref: "files:residence-proof-2026"
},
licence_plate: {
source: "system",
confidence: "verified",
declared_by_ref: "register:vehicle-B-AL-1843"
}
});
const submit = page.getByRole("alertdialog", { name: "Formular absenden" });
await submit.getByRole("button", { name: "Absenden" }).click();
await expect(page.getByText("receipt-assisted-rpp-2026-0001")).toBeVisible();
await expect(page.getByText("Rücklesen erfasst")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
}
test("shared components remain accessible and keyboard operable", async ({ page }) => { test("shared components remain accessible and keyboard operable", async ({ page }) => {
await page.goto("/?theme=light"); await page.goto("/?theme=light");
await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
@@ -176,6 +295,33 @@ test("View focus has a deliberate permission-derived all-tools escape", async ({
await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0);
}); });
test("product navigation hides package topology behind stable bilingual destinations", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/?theme=light&product-navigation=1");
await page.getByRole("button", { name: "Expand navigation" }).click();
const primary = page.locator(".icon-nav > .icon-nav-group");
await expect(primary.getByRole("link")).toHaveText([
"Arbeit",
"Dateien",
"Nachrichten",
"Kalender"
]);
await expect(primary.getByRole("link", { name: /Tasks|Files|Mail|Postbox|Calendar/ })).toHaveCount(0);
const allTools = page.locator("[data-product-navigation='all-tools']");
await expect(allTools.getByText("Alle verfügbaren Werkzeuge", { exact: true })).toBeVisible();
await allTools.locator("summary").click();
await expect(allTools.getByRole("link")).toHaveText([
"Tasks",
"Files",
"Mail",
"Postbox",
"Calendar"
]);
await expectNoAccessibilityViolations(page);
});
test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => { test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => { await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({ await route.fulfill({
@@ -229,6 +375,301 @@ test("narrow layout preserves task order without horizontal overflow", async ({
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 }); await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
}); });
async function expectNoHorizontalOverflow(page: import("@playwright/test").Page) {
const overflowing = await page.evaluate(() => Array.from(document.querySelectorAll<HTMLElement>("body *"))
.filter((element) => {
const style = window.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
})
.map((element) => {
const rect = element.getBoundingClientRect();
return {
element: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
left: Math.round(rect.left),
right: Math.round(rect.right)
};
})
.filter(({ left, right }) => left < -1 || right > window.innerWidth + 1)
.slice(0, 20));
expect(overflowing).toEqual([]);
}
async function mockPublicResidentPermitJourney(page: import("@playwright/test").Page) {
let current = residentPermitInstance("public-rpp-1", "started", 1, {});
let savedValues: Record<string, unknown> = {};
await page.route("**/api/v1/forms-runtime/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
if (path.endsWith("/public/profiles/resident-parking-permit/start") && method === "POST") {
return fulfillJson(route, {
session_id: "session-public-rpp-1",
mode: "anonymous",
status: "active",
expires_at: "2026-08-25T10:00:00Z",
instance: current,
token: "public-rpp-token",
replayed: false
});
}
if (path.endsWith("/public/intake") && method === "GET") {
return fulfillJson(route, { instance: current, definition: residentPermitDefinition() });
}
if (path.endsWith("/public/intake") && method === "PATCH") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
savedValues = payload.values;
current = residentPermitInstance("public-rpp-1", "draft", 2, payload.values);
return fulfillJson(route, current);
}
if (path.endsWith("/public/intake/submit") && method === "POST") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
current = {
...residentPermitInstance("public-rpp-1", "submitted", 3, payload.values),
receipt_id: "receipt-rpp-2026-0001"
};
return fulfillJson(route, current);
}
return route.abort("failed");
});
return { savedValues: () => savedValues };
}
async function mockAssistedResidentPermitJourney(page: import("@playwright/test").Page) {
let current = residentPermitInstance("assisted-rpp-1", "started", 1, {}, true);
let confirmationSources: Record<string, unknown> = {};
let confirmations: unknown[] = [];
await page.route("**/api/v1/forms-runtime/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
if (path.endsWith("/instances") && method === "GET") {
return fulfillJson(route, { instances: [], total: 0, offset: 0, limit: 200 });
}
if (path.endsWith("/assisted-intake/profiles") && method === "GET") {
return fulfillJson(route, { profiles: [{
profile_id: "assisted-profile-rpp",
public_id: "resident-parking-permit",
definition_ref: residentPermitDefinition().reference,
mode: "assisted",
enabled: true,
revision: 1,
draft_ttl_seconds: 2_592_000,
invitation_ttl_seconds: 1_209_600,
rate_limit_per_minute: 60,
metadata: { definition_title: "Anwohnerparkausweis beantragen" }
}] });
}
if (path.endsWith("/assisted-intake/start") && method === "POST") {
return fulfillJson(route, {
session_id: "assisted-session-rpp-1",
mode: "assisted",
status: "active",
expires_at: "2026-08-25T10:00:00Z",
instance: current,
token: null,
replayed: false
});
}
if (path.endsWith("/instances/assisted-rpp-1/definition") && method === "GET") {
return fulfillJson(route, residentPermitDefinition());
}
if (path.endsWith("/instances/assisted-rpp-1/history") && method === "GET") {
return fulfillJson(route, { revisions: [current] });
}
if (path.endsWith("/instances/assisted-rpp-1/events") && method === "GET") {
return fulfillJson(route, { events: [{
event_id: `event-${current.revision}`,
event_type: current.status === "submitted" ? "submitted" : "draft_saved",
instance_revision: current.revision,
status: current.status,
occurred_at: current.recorded_at,
actor_id: "operator-1",
payload: {}
}] });
}
if (path.endsWith("/instances/assisted-rpp-1/handoffs") && method === "GET") {
return fulfillJson(route, { handoffs: [] });
}
if (path.endsWith("/instances/assisted-rpp-1/assisted-confirmations") && method === "GET") {
return fulfillJson(route, { confirmations });
}
if (path.endsWith("/instances/assisted-rpp-1/assisted-confirmations") && method === "POST") {
const payload = request.postDataJSON() as { field_sources: Record<string, unknown> };
confirmationSources = payload.field_sources;
const confirmation = {
confirmation_id: "confirmation-rpp-1",
instance_id: "assisted-rpp-1",
instance_revision: current.revision,
outcome: "confirmed",
method: "spoken_readback",
confirmed_by_ref: "party:resident-ada-lovelace",
operator_actor_id: "operator-1",
confirmed_at: "2026-08-24T10:10:00Z",
payload_sha256: "a".repeat(64),
correction_note: null,
metadata: { field_sources: confirmationSources }
};
confirmations = [confirmation];
return fulfillJson(route, confirmation);
}
if (path.endsWith("/instances/assisted-rpp-1/submit") && method === "POST") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
current = {
...residentPermitInstance("assisted-rpp-1", "submitted", current.revision + 1, payload.values, true),
receipt_id: "receipt-assisted-rpp-2026-0001"
};
return fulfillJson(route, current);
}
if (path.endsWith("/instances/assisted-rpp-1") && method === "PATCH") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
current = residentPermitInstance("assisted-rpp-1", "draft", current.revision + 1, payload.values, true);
return fulfillJson(route, current);
}
if (path.endsWith("/instances/assisted-rpp-1") && method === "GET") {
return fulfillJson(route, current);
}
return route.abort("failed");
});
return { confirmationSources: () => confirmationSources };
}
function residentPermitDefinition() {
return {
reference: {
kind: "form",
owner_module: "forms",
object_id: "resident-parking-permit-application",
tenant_id: "tenant-1",
version: "3",
label: "Anwohnerparkausweis beantragen"
},
key: "resident_parking_permit.apply",
temporal: { revision: "3", recorded_at: "2026-08-24T10:00:00Z" },
title: "Resident parking permit application",
description: "Apply digitally or together with an authorized service worker.",
fields: [
residentPermitField("applicant_name", "Applicant name", "text", { min_length: 2, max_length: 200 }),
residentPermitField("applicant_email", "Applicant email", "email", { format: "email" }),
residentPermitField("residence_address", "Primary residence", "text", { max_length: 500 }),
residentPermitField("licence_plate", "Licence plate", "text", { max_length: 20 })
],
publication_state: "published",
allow_drafts: true,
max_attachments: 0,
signature_requirement: "none",
policy_refs: ["law:resident-parking-permit"],
handoff_kinds: [],
fallback_locale: "de",
localizations: [{
locale: "de",
title: "Anwohnerparkausweis beantragen",
description: "Beantragen Sie den Anwohnerparkausweis digital oder gemeinsam mit einer berechtigten Servicestelle.",
field_labels: {
applicant_name: "Name der antragstellenden Person",
applicant_email: "E-Mail-Adresse",
residence_address: "Hauptwohnsitz",
licence_plate: "Kfz-Kennzeichen"
},
field_help_texts: {},
option_labels: {},
page_titles: {},
section_titles: {}
}]
};
}
function residentPermitField(
key: string,
label: string,
valueType: "text" | "email",
constraints: Record<string, unknown>
) {
return {
key,
label,
value_type: valueType,
required: true,
help_text: null,
options: [],
constraints,
default_value: null,
visibility_condition: null
};
}
function residentPermitInstance(
instanceId: string,
status: string,
revision: number,
values: Record<string, unknown>,
assisted = false
) {
return {
reference: {
kind: "form_instance",
owner_module: "forms_runtime",
object_id: instanceId,
tenant_id: "tenant-1",
version: String(revision),
label: "Anwohnerparkausweis beantragen"
},
tenant_id: "tenant-1",
instance_id: instanceId,
revision,
status,
definition_ref: residentPermitDefinition().reference,
values,
validation_results: [],
attachment_refs: [],
signature_refs: [],
handoff_refs: [],
service_ref: null,
receipt_id: null as string | null,
recorded_at: "2026-08-24T10:00:00Z",
change_reason: revision === 1 ? "Assisted session started." : "Draft saved.",
created_by: "operator-1",
changed_by: "operator-1",
metadata: assisted ? {
intake: {
session_id: "assisted-session-rpp-1",
profile_id: "assisted-profile-rpp",
mode: "assisted",
channel: "counter",
affected_party_ref: "party:resident-ada-lovelace",
represented_party_ref: null,
authority_basis: "self",
purpose: "Anwohnerparkausweis beantragen",
legal_basis_ref: "law:resident-parking-permit",
consent_basis: "in-person-confirmation",
notice_given: true,
responsible_function_ref: "function:parking-permits",
language: "de",
accessibility_needs: ["Leichte Sprache"],
field_sources: {},
operator: { actor_id: "operator-1", auth_method: "session" }
}
} : {},
status_access: null,
replayed: false
};
}
async function fulfillJson(
route: import("@playwright/test").Route,
body: unknown
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body)
});
}
function quickAccessPayload(includeMessages: boolean) { function quickAccessPayload(includeMessages: boolean) {
const files = { const files = {
id: "files", id: "files",
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.40", "version": "0.1.44",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.40", "version": "0.1.44",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
@@ -378,7 +378,7 @@
}, },
"../../govoplan-forms-runtime/webui": { "../../govoplan-forms-runtime/webui": {
"name": "@govoplan/forms-runtime-webui", "name": "@govoplan/forms-runtime-webui",
"version": "0.1.18", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
+40 -40
View File
@@ -1,28 +1,28 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.40", "version": "0.1.44",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.40", "version": "0.1.44",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.18", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27",
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.18", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.23", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.25", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18", "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.21",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.18", "@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.22",
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22", "@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22",
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22", "@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22",
"@tiptap/core": "^3.29.2", "@tiptap/core": "^3.29.2",
@@ -757,8 +757,8 @@
"optional": true "optional": true
}, },
"node_modules/@govoplan/access-webui": { "node_modules/@govoplan/access-webui": {
"version": "0.1.23", "version": "0.1.24",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#e55434f406e953a2fa9e881abb8fb6dccbabd812", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#0f8a05f8b95340de7e0aa1569a51589764b0776e",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -789,8 +789,8 @@
} }
}, },
"node_modules/@govoplan/audit-webui": { "node_modules/@govoplan/audit-webui": {
"version": "0.1.18", "version": "0.1.20",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#6e63f6920ad5fb71f5d40e785eaed263905d658c", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#f2d7b9b29a497607d20f57886448247a8561df8d",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -805,10 +805,10 @@
} }
}, },
"node_modules/@govoplan/calendar-webui": { "node_modules/@govoplan/calendar-webui": {
"version": "0.1.22", "version": "0.1.23",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3792e9ab8a10ff6e301c59df7a3fa47d5ab14952", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3e5fc05ca3728464131067d1fa1bc25befeae939",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.44",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
@@ -859,8 +859,8 @@
} }
}, },
"node_modules/@govoplan/dashboard-webui": { "node_modules/@govoplan/dashboard-webui": {
"version": "0.1.18", "version": "0.1.20",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#ede16c5439f71ed36b50994d5ad7f656212a43c4", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#ec240ed2199637a68751e348887908a82d4cfe65",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -894,10 +894,10 @@
} }
}, },
"node_modules/@govoplan/files-webui": { "node_modules/@govoplan/files-webui": {
"version": "0.1.23", "version": "0.1.25",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#11b9b7c4c6d919b9f6649034445edfea0c77d042", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#2baa8f265707a4c3dd6bfaba78d86ac7a0edc7bb",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.44",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
@@ -948,8 +948,8 @@
} }
}, },
"node_modules/@govoplan/mail-webui": { "node_modules/@govoplan/mail-webui": {
"version": "0.1.25", "version": "0.1.26",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#ecc1283de76f154919c786cb84354afb8f2299c1", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#c62c7783d6f522b7ec13063024885bb88e257012",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -964,8 +964,8 @@
} }
}, },
"node_modules/@govoplan/ops-webui": { "node_modules/@govoplan/ops-webui": {
"version": "0.1.18", "version": "0.1.21",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#71666db45c8e602ea439495017e5849cad44ec4b", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#ac6b211827571c69b058fc857bbddd15125519c6",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
@@ -1002,8 +1002,8 @@
} }
}, },
"node_modules/@govoplan/policy-webui": { "node_modules/@govoplan/policy-webui": {
"version": "0.1.18", "version": "0.1.22",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#65159dec5fb6594fddfc2c5f8ab199c1e23994a2", "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#29a9aea3b186b45fcf1b7bed9ebbd1712390f5c6",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -2027,9 +2027,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.18", "version": "2.11.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.cjs" "baseline-browser-mapping": "dist/cli.cjs"
@@ -2127,9 +2127,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.412", "version": "1.5.413",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz",
"integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", "integrity": "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
@@ -2289,9 +2289,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "1.33.0", "version": "1.34.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz",
"integrity": "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==", "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2349,9 +2349,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.5", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.40", "version": "0.1.44",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -22,6 +22,10 @@
"./wysiwyg": { "./wysiwyg": {
"types": "./src/wysiwyg.ts", "types": "./src/wysiwyg.ts",
"import": "./src/wysiwyg.ts" "import": "./src/wysiwyg.ts"
},
"./outcome-product-surface-translations": {
"types": "./src/i18n/outcomeProductSurfaceTranslations.ts",
"import": "./src/i18n/outcomeProductSurfaceTranslations.ts"
} }
}, },
"scripts": { "scripts": {
+9 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.40", "version": "0.1.44",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -26,21 +26,21 @@
"preview": "vite preview --host 127.0.0.1 --port 4173" "preview": "vite preview --host 127.0.0.1 --port 4173"
}, },
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.18", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.18", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.23", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.25", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18", "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.21",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.18", "@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.22",
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22", "@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22",
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22", "@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22",
"@tiptap/core": "^3.29.2", "@tiptap/core": "^3.29.2",
+6 -2
View File
@@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary"; import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; import { configurableNavigationItemsForModules, firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext"; import { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext"; import { PlatformTemporalProvider } from "./platform/TemporalContext";
@@ -73,6 +73,10 @@ export default function App() {
() => navItemsForModules(webModules, viewProjection), () => navItemsForModules(webModules, viewProjection),
[viewProjection, webModules] [viewProjection, webModules]
); );
const allToolItems = useMemo(
() => configurableNavigationItemsForModules(webModules),
[webModules]
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]); const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]); const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules; const contextModules = auth ? webModules : publicWebModules;
@@ -550,7 +554,7 @@ export default function App() {
<PlatformViewProvider modules={webModules} projection={viewProjection}> <PlatformViewProvider modules={webModules} projection={viewProjection}>
<PlatformActiveObjectProvider> <PlatformActiveObjectProvider>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} allToolItems={allToolItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}> <ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}> <Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />
+1
View File
@@ -207,6 +207,7 @@ export default function HoverTooltip({
<span <span
ref={triggerRef} ref={triggerRef}
className={className} className={className}
role={ariaLabel ? "button" : undefined}
tabIndex={triggerTabIndex} tabIndex={triggerTabIndex}
aria-label={translatedAriaLabel} aria-label={translatedAriaLabel}
aria-describedby={isOpen ? tooltipId : undefined} aria-describedby={isOpen ? tooltipId : undefined}
+1 -1
View File
@@ -13,7 +13,7 @@ export default function InlineHelp({ children, className = "" }: InlineHelpProps
content={children} content={children}
className={`inline-help ${className}`.trim()} className={`inline-help ${className}`.trim()}
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f" ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
triggerTabIndex={-1}> triggerTabIndex={0}>
<span className="inline-help-mark" aria-hidden="true">?</span> <span className="inline-help-mark" aria-hidden="true">?</span>
</HoverTooltip> </HoverTooltip>
); );
+1 -4
View File
@@ -43,10 +43,7 @@ export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }]; { code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }];
export const DEFAULT_TRANSLATIONS: PlatformTranslations = { export const DEFAULT_TRANSLATIONS: PlatformTranslations = generatedTranslations;
en: generatedTranslations.en,
de: generatedTranslations.de
};
const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null); const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null);
+4 -2
View File
@@ -138,6 +138,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
"i18n:govoplan-core.application_notices": "Application notices", "i18n:govoplan-core.application_notices": "Application notices",
"i18n:govoplan-core.more_tools": "More tools", "i18n:govoplan-core.more_tools": "More tools",
"i18n:govoplan-core.all_available_tools": "All available tools",
"i18n:govoplan-core.product_area.work": "Work", "i18n:govoplan-core.product_area.work": "Work",
"i18n:govoplan-core.product_area.services_cases": "Services and cases", "i18n:govoplan-core.product_area.services_cases": "Services and cases",
"i18n:govoplan-core.product_area.communication": "Communication", "i18n:govoplan-core.product_area.communication": "Communication",
@@ -874,6 +875,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
"i18n:govoplan-core.application_notices": "Anwendungshinweise", "i18n:govoplan-core.application_notices": "Anwendungshinweise",
"i18n:govoplan-core.more_tools": "Weitere Werkzeuge", "i18n:govoplan-core.more_tools": "Weitere Werkzeuge",
"i18n:govoplan-core.all_available_tools": "Alle verfügbaren Werkzeuge",
"i18n:govoplan-core.product_area.work": "Arbeit", "i18n:govoplan-core.product_area.work": "Arbeit",
"i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge", "i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge",
"i18n:govoplan-core.product_area.communication": "Kommunikation", "i18n:govoplan-core.product_area.communication": "Kommunikation",
@@ -1276,8 +1278,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.share.09ca55ca": "Freigabe", "i18n:govoplan-core.share.09ca55ca": "Freigabe",
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.", "i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
"i18n:govoplan-core.settings.c7f73bb5": "Einstellungen", "i18n:govoplan-core.settings.c7f73bb5": "Einstellungen",
"i18n:govoplan-core.show_content.0528d8d2": "Show content", "i18n:govoplan-core.show_content.0528d8d2": "Inhalt anzeigen",
"i18n:govoplan-core.show_field_help.e3dfe98f": "Show field help", "i18n:govoplan-core.show_field_help.e3dfe98f": "Feldhilfe anzeigen",
"i18n:govoplan-core.show_guided_warnings_while_editing.bc5dba85": "Show guided warnings while editing", "i18n:govoplan-core.show_guided_warnings_while_editing.bc5dba85": "Show guided warnings while editing",
"i18n:govoplan-core.show_header_only.24afefca": "Show header only", "i18n:govoplan-core.show_header_only.24afefca": "Show header only",
"i18n:govoplan-core.show_inline_guidance_and_warnings_while_campaign.a892f5e9": "Show inline guidance and warnings while campaign data is being edited.", "i18n:govoplan-core.show_inline_guidance_and_warnings_while_campaign.a892f5e9": "Show inline guidance and warnings while campaign data is being edited.",
@@ -0,0 +1,28 @@
import type { PlatformTranslations } from "../types";
export const generatedTranslations = {
en: {
"i18n:govoplan-core.product_surface.work": "Work",
"i18n:govoplan-core.product_surface.work_description": "Review and resume authorized work without navigating by package ownership.",
"i18n:govoplan-core.product_surface.calendar": "Calendar",
"i18n:govoplan-core.product_surface.calendar_description": "Plan and review authorized events through one stable calendar destination.",
"i18n:govoplan-core.product_surface.files": "Files",
"i18n:govoplan-core.product_surface.files_description": "Find, select, and manage authorized files without exposing their storage implementation.",
"i18n:govoplan-core.product_surface.unavailable": "Destination is unavailable",
"i18n:govoplan-core.product_surface.unavailable_description": "No product destination is available for your current responsibility and permissions.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.",
"i18n:govoplan-core.access_administrator": "Access administrator"
},
de: {
"i18n:govoplan-core.product_surface.work": "Arbeit",
"i18n:govoplan-core.product_surface.work_description": "Berechtigte Arbeit prüfen und fortsetzen, ohne nach Paketzuständigkeit zu navigieren.",
"i18n:govoplan-core.product_surface.calendar": "Kalender",
"i18n:govoplan-core.product_surface.calendar_description": "Berechtigte Termine über ein stabiles Kalenderziel planen und prüfen.",
"i18n:govoplan-core.product_surface.files": "Dateien",
"i18n:govoplan-core.product_surface.files_description": "Berechtigte Dateien finden, auswählen und verwalten, ohne ihre Speicherimplementierung offenzulegen.",
"i18n:govoplan-core.product_surface.unavailable": "Das Produktziel ist nicht verfügbar",
"i18n:govoplan-core.product_surface.unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist kein Produktziel verfügbar.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.",
"i18n:govoplan-core.access_administrator": "Zugriffsadministration"
}
} satisfies PlatformTranslations;
+3 -1
View File
@@ -33,7 +33,9 @@ export * from "./platform/moduleEvents";
export * from "./platform/ViewContext"; export * from "./platform/ViewContext";
export * from "./platform/views"; export * from "./platform/views";
export * from "./platform/productSurfaces"; export * from "./platform/productSurfaces";
export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/productSurfaceTranslations"; export { productSurfaceTranslations } from "./productSurfaceTranslations";
export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/messagesProductSurfaceTranslations";
export { generatedTranslations as outcomeProductSurfaceTranslations } from "./i18n/outcomeProductSurfaceTranslations";
export * from "./platform/temporal"; export * from "./platform/temporal";
export * from "./platform/TemporalContext"; export * from "./platform/TemporalContext";
export * from "./platform/ActiveObjectContext"; export * from "./platform/ActiveObjectContext";
+60
View File
@@ -0,0 +1,60 @@
import { Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router";
import type { MouseEvent } from "react";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformNavItem } from "../types";
export default function AllToolsNavigation({
items,
rememberedTargets
}: {
items: PlatformNavItem[];
rememberedTargets: Record<string, string>;
}) {
const location = useLocation();
const navigate = useGuardedNavigate();
const { translateText } = usePlatformLanguage();
function handleClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
navigate(target);
}
return (
<details className="icon-nav-all-tools" data-product-navigation="all-tools">
<summary
className="icon-nav-all-tools-summary"
title={translateText("i18n:govoplan-core.all_available_tools")}
>
<Settings size={20} aria-hidden="true" />
<span className="icon-nav-label">
{translateText("i18n:govoplan-core.all_available_tools")}
</span>
</summary>
<div className="icon-nav-all-tools-items">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const renderedLabel = translateText(label);
return (
<NavLink
key={to}
to={target}
className={`icon-nav-item ${pathActive(location.pathname, to) ? "active" : ""}`}
title={renderedLabel}
onClick={(event) => handleClick(event, target)}
>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>
);
})}
</div>
</details>
);
}
function pathActive(pathname: string, root: string): boolean {
return pathname === root || pathname.startsWith(`${root}/`);
}
+10 -3
View File
@@ -11,6 +11,7 @@ import { useActiveObject } from "../platform/ActiveObjectContext";
import { createQuickAccessLaunchContext } from "../platform/launchContext"; import { createQuickAccessLaunchContext } from "../platform/launchContext";
import { isViewSurfaceVisible } from "../platform/views"; import { isViewSurfaceVisible } from "../platform/views";
import { hasAnyScope, hasScope } from "../utils/permissions"; import { hasAnyScope, hasScope } from "../utils/permissions";
import { projectProductNavigation } from "../platform/productSurfaces";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
@@ -20,6 +21,7 @@ type Props = {
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean; publicMode?: boolean;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null }; maintenanceMode?: { enabled: boolean; message?: string | null };
backendReachable?: boolean; backendReachable?: boolean;
}; };
@@ -32,6 +34,7 @@ export default function AppShell({
onAuthChange, onAuthChange,
publicMode = false, publicMode = false,
navItems = [], navItems = [],
allToolItems = navItems,
maintenanceMode, maintenanceMode,
backendReachable = true backendReachable = true
}: Props) { }: Props) {
@@ -68,11 +71,15 @@ export default function AppShell({
() => modules.flatMap((module) => module.productAreas ?? []), () => modules.flatMap((module) => module.productAreas ?? []),
[modules] [modules]
); );
const productNavigation = useMemo(
() => projectProductNavigation(navItems, modules, auth, projection, allToolItems),
[allToolItems, auth, modules, navItems, projection]
);
if (publicMode) { if (publicMode) {
return ( return (
<div className="app-shell public-shell"> <div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} /> <IconRail compact navItems={navItems} />
<div className="app-main public-main"> <div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} /> <Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<main className="public-content">{children}</main> <main className="public-content">{children}</main>
@@ -84,8 +91,8 @@ export default function AppShell({
return ( return (
<div className="app-shell"> <div className="app-shell">
<IconRail <IconRail
auth={auth} navItems={productNavigation.primaryItems}
navItems={navItems} allToolItems={productNavigation.allToolItems}
productAreas={productAreas} productAreas={productAreas}
presentation={projection?.presentation} presentation={projection?.presentation}
/> />
+22 -21
View File
@@ -1,45 +1,35 @@
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router"; import { NavLink, useLocation } from "react-router";
import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { lazy, Suspense, useEffect, useMemo, useState, type MouseEvent } from "react";
import type { import type {
AuthInfo,
PlatformNavItem, PlatformNavItem,
ProductAreaContribution, ProductAreaContribution,
ViewPresentation ViewPresentation
} from "../types"; } from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { groupNavigationItems } from "../platform/productAreas"; import { groupNavigationItems } from "../platform/productAreas";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav"; const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded"; const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
const AllToolsNavigation = lazy(() => import("./AllToolsNavigation"));
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems].
sort((left, right) => (left.order ?? 100) - (right.order ?? 100)).
filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export default function IconRail({ export default function IconRail({
compact = false, compact = false,
auth = null,
navItems = [], navItems = [],
allToolItems = [],
productAreas = [], productAreas = [],
presentation presentation
}: { }: {
compact?: boolean; compact?: boolean;
auth?: AuthInfo | null;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
productAreas?: ProductAreaContribution[]; productAreas?: ProductAreaContribution[];
presentation?: ViewPresentation; presentation?: ViewPresentation;
}) { }) {
const location = useLocation(); const location = useLocation();
const items = visibleNavItems(auth, navItems); const items = navItems;
const technicalItems = allToolItems;
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets()); const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded()); const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]); const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
@@ -95,9 +85,9 @@ export default function IconRail({
{translateText(group.label)} {translateText(group.label)}
</div> </div>
)} )}
{group.items.map(({ to, label, icon: Icon }) => { {group.items.map(({ to, label, icon: Icon, activePaths }) => {
const target = rememberedTargets[to] ?? to; const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to); const active = modulePathActive(location.pathname, to, activePaths);
const renderedLabel = translateText(label); const renderedLabel = translateText(label);
const areaLabel = group.areaLabel const areaLabel = group.areaLabel
? translateText(group.areaLabel) ? translateText(group.areaLabel)
@@ -120,6 +110,11 @@ export default function IconRail({
})} })}
</div> </div>
))} ))}
{technicalItems.length > 0 && (
<Suspense fallback={null}>
<AllToolsNavigation items={technicalItems} rememberedTargets={rememberedTargets} />
</Suspense>
)}
</nav> </nav>
</div> </div>
<div className="icon-rail-bottom"> <div className="icon-rail-bottom">
@@ -144,9 +139,15 @@ export default function IconRail({
} }
function modulePathActive(pathname: string, root: string): boolean { function modulePathActive(
if (root === "/") return pathname === "/"; pathname: string,
return pathname === root || pathname.startsWith(`${root}/`); root: string,
activePaths: string[] = []
): boolean {
return [root, ...activePaths].some((candidate) => {
if (candidate === "/") return pathname === "/";
return pathname === candidate || pathname.startsWith(`${candidate}/`);
});
} }
function loadRememberedTargets(): Record<string, string> { function loadRememberedTargets(): Record<string, string> {
+1 -9
View File
@@ -87,13 +87,5 @@ export function groupNavigationItems(
items: remaining items: remaining
}); });
} }
const overview = groups.filter((group) => group.id === "overview"); return groups;
const configurable = groups
.filter((group) => group.id !== "overview")
.sort((left, right) => minimumOrder(left.items) - minimumOrder(right.items));
return [...overview, ...configurable];
}
function minimumOrder(items: PlatformNavItem[]): number {
return Math.min(...items.map((item) => item.order ?? 100), 10_000);
} }
+73
View File
@@ -2,6 +2,7 @@ import type {
AuthInfo, AuthInfo,
ComposedProductSurface, ComposedProductSurface,
EffectiveViewProjection, EffectiveViewProjection,
PlatformNavItem,
ProductSurfaceMetadata, ProductSurfaceMetadata,
PlatformWebModule, PlatformWebModule,
ProductSurfaceContribution ProductSurfaceContribution
@@ -20,6 +21,11 @@ export type ProductSurfaceRouteResolvedEventDetail = {
usedAlias: boolean; usedAlias: boolean;
}; };
export type ProductNavigationProjection = {
primaryItems: PlatformNavItem[];
allToolItems: PlatformNavItem[];
};
export function composeProductSurfaces( export function composeProductSurfaces(
modules: readonly PlatformWebModule[] modules: readonly PlatformWebModule[]
): ComposedProductSurface[] { ): ComposedProductSurface[] {
@@ -110,6 +116,65 @@ export function availableProductSurfaceContributors(
}); });
} }
/**
* Replace authorized owner routes with stable product entries while retaining
* the complete permission-derived catalogue as an explicit escape.
*/
export function projectProductNavigation(
items: readonly PlatformNavItem[],
modules: readonly PlatformWebModule[],
auth: AuthInfo | null | undefined,
projection?: EffectiveViewProjection | null,
catalogueItems: readonly PlatformNavItem[] = items
): ProductNavigationProjection {
const authorizedItems = items.filter((item) => navigationItemAuthorized(item, auth));
const allToolItems = catalogueItems.filter((item) => navigationItemAuthorized(item, auth));
const ownerItemByPath = new Map(authorizedItems.map((item) => [item.to, item]));
const consumedOwnerPaths = new Set<string>();
const replacementByPath = new Map<string, PlatformNavItem>();
for (const surface of composeProductSurfaces(modules)) {
const contributors = availableProductSurfaceContributors(
surface,
auth,
modules,
projection
);
const navigable = contributors
.map((contribution) => ({
contribution,
item: ownerItemByPath.get(contribution.routePath)
}))
.filter((candidate): candidate is {
contribution: ProductSurfaceContribution;
item: PlatformNavItem;
} => candidate.item !== undefined);
const target = navigable[0];
if (!target) continue;
navigable.forEach(({ contribution }) => {
consumedOwnerPaths.add(contribution.routePath);
});
replacementByPath.set(target.contribution.routePath, {
...target.item,
to: surface.entryPath,
label: surface.label,
navigationId: surface.id,
activePaths: [
...surface.aliases,
...navigable.map(({ contribution }) => contribution.routePath)
]
});
}
const primaryItems = authorizedItems.flatMap((item) => {
const replacement = replacementByPath.get(item.to);
if (replacement) return [replacement];
return consumedOwnerPaths.has(item.to) ? [] : [item];
});
return { primaryItems, allToolItems };
}
export function dispatchProductSurfaceRouteResolved( export function dispatchProductSurfaceRouteResolved(
detail: ProductSurfaceRouteResolvedEventDetail detail: ProductSurfaceRouteResolvedEventDetail
): void { ): void {
@@ -141,3 +206,11 @@ function compareContributions(
): number { ): number {
return left.order - right.order || left.moduleId.localeCompare(right.moduleId); return left.order - right.order || left.moduleId.localeCompare(right.moduleId);
} }
function navigationItemAuthorized(
item: PlatformNavItem,
auth: AuthInfo | null | undefined
): boolean {
return !item.allOf?.some((scope) => !hasScope(auth, scope))
&& (!item.anyOf?.length || hasAnyScope(auth, item.anyOf));
}
+8
View File
@@ -0,0 +1,8 @@
import type { PlatformTranslations } from "./types";
import { generatedTranslations as messages } from "./i18n/messagesProductSurfaceTranslations";
import { generatedTranslations as outcomes } from "./i18n/outcomeProductSurfaceTranslations";
export const productSurfaceTranslations = {
en: { ...messages.en, ...outcomes.en },
de: { ...messages.de, ...outcomes.de }
} satisfies PlatformTranslations;
+1 -1
View File
@@ -1,5 +1,5 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: var(--radius-pill); padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; } .status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: var(--radius-pill); padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); } .status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text); }
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); } .status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-error, .status-danger, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); } .status-blocked, .status-error, .status-danger, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); } .status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
+10
View File
@@ -29,6 +29,16 @@
.icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.icon-rail.expanded .icon-nav-label { display: block; } .icon-rail.expanded .icon-nav-label { display: block; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); } .icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); }
.icon-nav-all-tools { width: 100%; min-width: 0; border-top: 1px solid var(--rail-bg-active); }
.icon-nav-all-tools-summary { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; box-sizing: border-box; border-left: 3px solid transparent; color: var(--rail-text-muted); cursor: pointer; list-style: none; }
.icon-nav-all-tools-summary::-webkit-details-marker { display: none; }
.icon-nav-all-tools-summary > svg { justify-self: center; }
.icon-nav-all-tools-summary:hover,
.icon-nav-all-tools-summary:focus-visible,
.icon-nav-all-tools[open] > .icon-nav-all-tools-summary { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
.icon-nav-all-tools-summary:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.icon-nav-all-tools-items { width: 100%; min-width: 0; background: color-mix(in srgb, var(--rail-bg-active) 45%, var(--rail-bg)); }
.icon-nav-all-tools-items .icon-nav-item { min-height: 46px; height: 46px; }
.icon-rail.compact { width: 58px; } .icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); } .titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
+2
View File
@@ -304,6 +304,8 @@ export type PlatformNavItem = {
navigationVisibilitySource?: string; navigationVisibilitySource?: string;
navigationLockSource?: string | null; navigationLockSource?: string | null;
navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>; navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
/** Additional stable or owner paths that should mark a composed product entry active. */
activePaths?: string[];
}; };
export type NavigationLayerState = { export type NavigationLayerState = {
+62 -1
View File
@@ -18,7 +18,10 @@ import {
visibleRoutesForProjection visibleRoutesForProjection
} from "../src/platform/views"; } from "../src/platform/views";
import { groupNavigationItems } from "../src/platform/productAreas"; import { groupNavigationItems } from "../src/platform/productAreas";
import { composeProductSurfaces } from "../src/platform/productSurfaces"; import {
composeProductSurfaces,
projectProductNavigation
} from "../src/platform/productSurfaces";
import { hasAnyScope, scopeGrants } from "../src/utils/permissions"; import { hasAnyScope, scopeGrants } from "../src/utils/permissions";
function assert(condition: unknown, message: string): void { function assert(condition: unknown, message: string): void {
@@ -260,6 +263,64 @@ assert(composedMessages[0]?.entryPath === "/messages", "the composed identity sh
assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance"); assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance");
assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners"); assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners");
const messageNavigation = projectProductNavigation(
[
{ to: "/dashboard", label: "Dashboard", order: 1 },
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] },
{ to: "/admin", label: "Administration", order: 90, anyOf: ["core:admin:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo
);
assert(
messageNavigation.primaryItems.map((item) => item.to).join(",") === "/dashboard,/messages",
"ordinary navigation should replace authorized owner routes with one stable product entry"
);
assert(
messageNavigation.primaryItems.find((item) => item.to === "/messages")?.activePaths?.includes("/postbox"),
"the product entry should remain active on an owner route"
);
assert(
messageNavigation.allToolItems.map((item) => item.to).join(",") === "/dashboard,/mail,/postbox",
"the explicit tool catalogue should retain authorized technical routes"
);
const mailOnlyNavigation = projectProductNavigation(
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read"] } as AuthInfo
);
assert(
mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"composition should remain stable when only one optional contributor is authorized"
);
assert(
mailOnlyNavigation.allToolItems.map((item) => item.to).join(",") === "/mail",
"the tool catalogue must not disclose unauthorized owner routes"
);
const focusedMessageNavigation = projectProductNavigation(
[{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo,
null,
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
]
);
assert(
focusedMessageNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"a focused View should keep the selected stable product destination"
);
assert(
focusedMessageNavigation.allToolItems.map((item) => item.to).join(",") === "/mail,/postbox",
"All available tools should provide an explicit permission-derived escape from View focus"
);
const viewAwareFiles: PlatformWebModule = { const viewAwareFiles: PlatformWebModule = {
...files, ...files,
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }], navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
+3
View File
@@ -31,6 +31,9 @@
], ],
"@govoplan/core-webui/wysiwyg": [ "@govoplan/core-webui/wysiwyg": [
"./src/wysiwyg.ts" "./src/wysiwyg.ts"
],
"@govoplan/core-webui/outcome-product-surface-translations": [
"./src/i18n/outcomeProductSurfaceTranslations.ts"
] ]
} }
}, },
+1
View File
@@ -250,6 +250,7 @@ export default defineConfig({
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) }, { find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) }, { find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) }, { find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/outcome-product-surface-translations", replacement: fileURLToPath(new URL("./src/i18n/outcomeProductSurfaceTranslations.ts", import.meta.url)) },
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) } { find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
] ]
}, },