Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac40774785 | ||
|
|
9a3008002d | ||
|
|
9cb2080938 | ||
|
|
08c3e47b6d |
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"initialJs": {
|
"initialJs": {
|
||||||
"rawBytes": 524288,
|
"rawBytes": 524288,
|
||||||
"gzipBytes": 163840
|
"gzipBytes": 164128
|
||||||
},
|
},
|
||||||
"asyncChunk": {
|
"asyncChunk": {
|
||||||
"rawBytes": 393216,
|
"rawBytes": 393216,
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+3
-3
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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 />} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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}/`);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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); }
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"] }],
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)) }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user