Compare commits

...
2 Commits
Author SHA1 Message Date
zemion ac40774785 feat(webui): expose stable product destinations
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 18:06:52 +02:00
zemion 9a3008002d feat: define governed tenant erasure contracts
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 16:00:06 +02:00
33 changed files with 1250 additions and 81 deletions
+1
View File
@@ -142,6 +142,7 @@ system:tenants:read
system:tenants:create
system:tenants:update
system:tenants:suspend
system:tenants:erase
system:accounts:read
system:accounts:create
+30
View File
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
- bounded reference-option search provider contract
- single-tenant and optional batched tenant summary provider contracts
- 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
- navigation metadata 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
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
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
@@ -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
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
ordinary state explains the attempted outcome, consequence, recovery path and
responsible role. Exact module, capability, provider and correlation values may
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.42"
version = "0.1.44"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
+467
View File
@@ -0,0 +1,467 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal, Protocol, runtime_checkable
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX = "tenancy.erasure_provider."
TenantErasureDisposition = Literal[
"erase",
"retain",
"legal_hold",
"external_cleanup",
"key_destroy",
"backup_expiry",
"unavailable",
]
TenantErasureStepKind = Literal[
"export",
"erase",
"retain",
"external_cleanup",
"key_destroy",
"backup_expiry",
"verify",
]
TenantErasureResultState = Literal[
"completed",
"pending",
"blocked",
"outcome_unknown",
]
_DISPOSITIONS = frozenset(
{
"erase",
"retain",
"legal_hold",
"external_cleanup",
"key_destroy",
"backup_expiry",
"unavailable",
}
)
_STEP_KINDS = frozenset(
{
"export",
"erase",
"retain",
"external_cleanup",
"key_destroy",
"backup_expiry",
"verify",
}
)
_RESULT_STATES = frozenset(
{"completed", "pending", "blocked", "outcome_unknown"}
)
def _text(value: str, label: str, *, maximum: int) -> str:
normalized = value.strip()
if (
not normalized
or len(normalized) > maximum
or any(ord(character) < 32 for character in normalized)
):
raise ValueError(f"Tenant erasure {label} is invalid.")
return normalized
def _texts(
values: tuple[str, ...],
label: str,
*,
maximum_items: int = 100,
maximum_length: int = 500,
) -> tuple[str, ...]:
if len(values) > maximum_items:
raise ValueError(f"Tenant erasure {label} has too many entries.")
normalized = tuple(
_text(value, label, maximum=maximum_length) for value in values
)
if len(normalized) != len(set(normalized)):
raise ValueError(f"Tenant erasure {label} contains duplicates.")
return normalized
def _metrics(values: Mapping[str, int]) -> dict[str, int]:
if len(values) > 30:
raise ValueError("Tenant erasure metrics has too many entries.")
normalized: dict[str, int] = {}
for key, value in values.items():
normalized_key = _text(key, "metric key", maximum=80)
if type(value) is not int or value < 0:
raise ValueError("Tenant erasure metric values must be non-negative integers.")
normalized[normalized_key] = value
return normalized
@dataclass(frozen=True, slots=True)
class TenantErasureResource:
resource_type: str
count: int
disposition: TenantErasureDisposition
summary: str
governance_ref: str | None = None
external: bool = False
def __post_init__(self) -> None:
_text(self.resource_type, "resource type", maximum=120)
_text(self.summary, "resource summary", maximum=1000)
if type(self.count) is not int or self.count < 0:
raise ValueError("Tenant erasure resource count is invalid.")
if self.disposition not in _DISPOSITIONS:
raise ValueError("Tenant erasure resource disposition is invalid.")
if self.governance_ref is not None:
_text(self.governance_ref, "governance reference", maximum=300)
def to_dict(self) -> dict[str, object]:
return {
"resource_type": self.resource_type,
"count": self.count,
"disposition": self.disposition,
"summary": self.summary,
"governance_ref": self.governance_ref,
"external": self.external,
}
@dataclass(frozen=True, slots=True)
class TenantErasureStep:
step_id: str
kind: TenantErasureStepKind
summary: str
destructive: bool
irreversible: bool
requires_reconciliation: bool = False
depends_on: tuple[str, ...] = ()
def __post_init__(self) -> None:
_text(self.step_id, "step id", maximum=160)
_text(self.summary, "step summary", maximum=1000)
if self.kind not in _STEP_KINDS:
raise ValueError("Tenant erasure step kind is invalid.")
_texts(self.depends_on, "step dependencies", maximum_length=160)
if self.step_id in self.depends_on:
raise ValueError("Tenant erasure step cannot depend on itself.")
if self.irreversible and not self.destructive:
raise ValueError("An irreversible tenant erasure step must be destructive.")
def to_dict(self) -> dict[str, object]:
return {
"step_id": self.step_id,
"kind": self.kind,
"summary": self.summary,
"destructive": self.destructive,
"irreversible": self.irreversible,
"requires_reconciliation": self.requires_reconciliation,
"depends_on": list(self.depends_on),
}
@dataclass(frozen=True, slots=True)
class TenantErasurePreview:
module_id: str
complete: bool
resources: tuple[TenantErasureResource, ...] = ()
steps: tuple[TenantErasureStep, ...] = ()
blockers: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
provider_revision: str = "1"
def __post_init__(self) -> None:
_text(self.module_id, "module id", maximum=120)
_text(self.provider_revision, "provider revision", maximum=120)
_texts(self.blockers, "blockers", maximum_length=1000)
_texts(self.warnings, "warnings", maximum_length=1000)
if len(self.resources) > 500 or len(self.steps) > 500:
raise ValueError("Tenant erasure preview is too large.")
resource_types = [item.resource_type for item in self.resources]
if len(resource_types) != len(set(resource_types)):
raise ValueError("Tenant erasure preview repeats a resource type.")
resources_requiring_action = tuple(
item for item in self.resources if item.count > 0
)
if resources_requiring_action and not self.steps and not self.blockers:
raise ValueError(
"Tenant erasure resources require steps or an explicit blocker."
)
if any(
item.count > 0 and item.disposition == "unavailable"
for item in self.resources
) and not self.blockers:
raise ValueError(
"Unavailable tenant erasure resources require an explicit blocker."
)
if not self.complete and not self.blockers:
raise ValueError(
"An incomplete tenant erasure preview requires an explicit blocker."
)
step_ids = [item.step_id for item in self.steps]
if len(step_ids) != len(set(step_ids)):
raise ValueError("Tenant erasure preview repeats a step id.")
known_step_ids = set(step_ids)
if any(
dependency not in known_step_ids
for step in self.steps
for dependency in step.depends_on
):
raise ValueError("Tenant erasure step references an unknown dependency.")
remaining = {
step.step_id: set(step.depends_on)
for step in self.steps
}
resolved: set[str] = set()
while remaining:
ready = sorted(
step_id
for step_id, dependencies in remaining.items()
if dependencies.issubset(resolved)
)
if not ready:
raise ValueError("Tenant erasure step dependencies contain a cycle.")
resolved.update(ready)
for step_id in ready:
remaining.pop(step_id)
@property
def allowed(self) -> bool:
return self.complete and not self.blockers
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"complete": self.complete,
"allowed": self.allowed,
"provider_revision": self.provider_revision,
"resources": [item.to_dict() for item in self.resources],
"steps": [item.to_dict() for item in self.steps],
"blockers": list(self.blockers),
"warnings": list(self.warnings),
}
@dataclass(frozen=True, slots=True)
class TenantErasureStepResult:
state: TenantErasureResultState
summary: str
receipt_ref: str | None = None
metrics: Mapping[str, int] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.state not in _RESULT_STATES:
raise ValueError("Tenant erasure result state is invalid.")
_text(self.summary, "result summary", maximum=1000)
if self.receipt_ref is not None:
_text(self.receipt_ref, "receipt reference", maximum=500)
_metrics(self.metrics)
def to_dict(self) -> dict[str, object]:
return {
"state": self.state,
"summary": self.summary,
"receipt_ref": self.receipt_ref,
"metrics": dict(sorted(_metrics(self.metrics).items())),
}
@runtime_checkable
class TenantErasureProvider(Protocol):
module_id: str
def preview_tenant_erasure(
self,
session: object,
tenant_id: str,
) -> TenantErasurePreview:
...
def execute_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
...
def reconcile_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
...
@dataclass(frozen=True, slots=True)
class TenantErasureInventory:
tenant_id: str
generated_at: datetime
complete: bool
modules: tuple[TenantErasurePreview, ...]
@property
def allowed(self) -> bool:
return self.complete and all(item.allowed for item in self.modules)
def to_dict(self) -> dict[str, object]:
generated_at = self.generated_at
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=UTC)
return {
"schema_version": 1,
"tenant_id": self.tenant_id,
"generated_at": generated_at.astimezone(UTC).isoformat(),
"complete": self.complete,
"allowed": self.allowed,
"modules": [item.to_dict() for item in self.modules],
}
def tenant_erasure_providers(registry: object) -> dict[str, TenantErasureProvider]:
capability_names = getattr(registry, "capability_names", None)
capability = getattr(registry, "capability", None)
if not callable(capability_names) or not callable(capability):
raise ValueError("Tenant erasure requires a module registry.")
providers: dict[str, TenantErasureProvider] = {}
for capability_name in sorted(capability_names()):
if not capability_name.startswith(TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX):
continue
expected_module_id = capability_name.removeprefix(
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX
)
provider = capability(capability_name)
if not isinstance(provider, TenantErasureProvider):
raise TypeError(
f"Tenant erasure provider {expected_module_id or 'unknown'} is invalid."
)
module_id = _text(provider.module_id, "provider module id", maximum=120)
if module_id != expected_module_id or module_id in providers:
raise ValueError("Tenant erasure provider identity is invalid.")
providers[module_id] = provider
return providers
def collect_tenant_erasure_inventory(
registry: object,
session: object,
tenant_id: str,
*,
observed_at: datetime | None = None,
) -> TenantErasureInventory:
normalized_tenant_id = _text(tenant_id, "tenant id", maximum=120)
manifests = getattr(registry, "manifests", None)
summary_providers = getattr(registry, "tenant_summary_providers", None)
if not callable(manifests) or not callable(summary_providers):
raise ValueError("Tenant erasure inventory requires a module registry.")
provider_by_module = tenant_erasure_providers(registry)
summary_by_module = dict(summary_providers())
manifest_ids = {
str(manifest.id)
for manifest in manifests()
if getattr(manifest, "id", None)
}
module_ids = manifest_ids | set(summary_by_module) | set(provider_by_module)
previews: list[TenantErasurePreview] = []
complete = True
for module_id in sorted(module_ids):
provider = provider_by_module.get(module_id)
if provider is not None:
try:
preview = provider.preview_tenant_erasure(session, normalized_tenant_id)
if not isinstance(preview, TenantErasurePreview):
raise TypeError("provider returned an invalid preview")
if preview.module_id != module_id:
raise ValueError("provider returned another module's preview")
except Exception as exc:
complete = False
preview = TenantErasurePreview(
module_id=module_id,
complete=False,
blockers=(
f"{type(exc).__name__}: provider preview could not be completed",
),
)
previews.append(preview)
complete = complete and preview.complete
continue
summary_provider = summary_by_module.get(module_id)
if summary_provider is None:
previews.append(
TenantErasurePreview(
module_id=module_id,
complete=True,
warnings=(
"Module declares no tenant-owned summary or erasure provider; no tenant persistence is in scope.",
),
provider_revision="manifest-no-tenant-data",
)
)
continue
try:
raw_counts = summary_provider(session, normalized_tenant_id)
counts = _metrics({str(key): int(value) for key, value in raw_counts.items()})
resources = tuple(
TenantErasureResource(
resource_type=resource_type,
count=count,
disposition="unavailable" if count else "erase",
summary=(
"Tenant-owned data requires a module erasure provider."
if count
else "The module reported no tenant-owned records."
),
)
for resource_type, count in sorted(counts.items())
)
blockers = (
("Tenant-owned data exists but the module has no erasure provider.",)
if any(counts.values())
else ()
)
preview = TenantErasurePreview(
module_id=module_id,
complete=True,
resources=resources,
blockers=blockers,
provider_revision="tenant-summary-fallback",
)
except Exception as exc:
complete = False
preview = TenantErasurePreview(
module_id=module_id,
complete=False,
blockers=(
f"{type(exc).__name__}: tenant summary could not be completed",
),
provider_revision="tenant-summary-fallback",
)
previews.append(preview)
timestamp = observed_at or datetime.now(UTC)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return TenantErasureInventory(
tenant_id=normalized_tenant_id,
generated_at=timestamp,
complete=complete,
modules=tuple(previews),
)
__all__ = [
"TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX",
"TenantErasureInventory",
"TenantErasurePreview",
"TenantErasureProvider",
"TenantErasureResource",
"TenantErasureStep",
"TenantErasureStepResult",
"collect_tenant_erasure_inventory",
"tenant_erasure_providers",
]
@@ -27,6 +27,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend",
"system:tenants:erase": "access:tenant:erase",
"system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create",
"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: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: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:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
+51 -9
View File
@@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership: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(
@@ -1015,8 +1027,10 @@ finally:
json={"mode": "destroy", "reason": "not supported"},
)
self.assertEqual(409, destructive.status_code, destructive.text)
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
self.assertIn("tenant_data_present", issue_codes)
self.assertIn(
"Direct destructive deletion is disabled",
destructive.json()["detail"]["message"],
)
with database.session() as session:
empty_tenant = Tenant(
@@ -1029,15 +1043,43 @@ finally:
session.commit()
empty_tenant_id = empty_tenant.id
destroyed = client.request(
"DELETE",
f"/api/v1/admin/tenants/{empty_tenant_id}",
erasure_policy = client.patch(
"/api/v1/admin/tenant-erasure-policy",
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("destroy", destroyed.json()["plan"]["action"])
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
self.assertEqual(200, erasure_policy.status_code, erasure_policy.text)
erasure_preview = client.post(
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(
"DELETE",
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from govoplan_core.core.tenant_erasure import (
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
TenantErasurePreview,
TenantErasureResource,
TenantErasureStep,
TenantErasureStepResult,
collect_tenant_erasure_inventory,
tenant_erasure_providers,
)
class _Provider:
module_id = "files"
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
del session
assert tenant_id == "tenant-1"
return TenantErasurePreview(
module_id=self.module_id,
complete=True,
resources=(
TenantErasureResource(
resource_type="file_blobs",
count=2,
disposition="erase",
summary="Two tenant-owned file blobs will be erased.",
),
),
steps=(
TenantErasureStep(
step_id="erase-blobs",
kind="erase",
summary="Erase tenant-owned file blobs.",
destructive=True,
irreversible=True,
),
),
)
def execute_tenant_erasure_step(
self, session, tenant_id: str, step_id: str, idempotency_key: str
) -> TenantErasureStepResult:
del session, tenant_id, step_id, idempotency_key
return TenantErasureStepResult(
state="completed",
summary="Tenant file blobs erased.",
metrics={"deleted": 2},
)
def reconcile_tenant_erasure_step(
self, session, tenant_id: str, step_id: str, idempotency_key: str
) -> TenantErasureStepResult:
return self.execute_tenant_erasure_step(
session, tenant_id, step_id, idempotency_key
)
class _Registry:
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
self._provider = provider
self._counts = counts
def manifests(self):
return (
SimpleNamespace(id="core"),
SimpleNamespace(id="files"),
SimpleNamespace(id="wiki"),
)
def capability_names(self):
if self._provider is None:
return ()
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
def capability(self, name: str):
assert name.endswith("files")
return self._provider
def tenant_summary_providers(self):
if self._counts is None:
return {}
return {"files": lambda _session, _tenant_id: self._counts}
def test_contract_rejects_unsafe_irreversible_step() -> None:
with pytest.raises(ValueError, match="must be destructive"):
TenantErasureStep(
step_id="unsafe",
kind="erase",
summary="Invalid step.",
destructive=False,
irreversible=True,
)
def test_contract_rejects_cyclic_step_dependencies() -> None:
with pytest.raises(ValueError, match="contain a cycle"):
TenantErasurePreview(
module_id="files",
complete=True,
steps=(
TenantErasureStep(
step_id="first",
kind="erase",
summary="First.",
destructive=True,
irreversible=True,
depends_on=("second",),
),
TenantErasureStep(
step_id="second",
kind="verify",
summary="Second.",
destructive=False,
irreversible=False,
depends_on=("first",),
),
),
)
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
resource = TenantErasureResource(
resource_type="files",
count=1,
disposition="erase",
summary="One file exists.",
)
with pytest.raises(ValueError, match="steps or an explicit blocker"):
TenantErasurePreview(
module_id="files",
complete=True,
resources=(resource,),
)
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
inventory = collect_tenant_erasure_inventory(
_Registry(provider=_Provider()),
object(),
"tenant-1",
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
assert inventory.complete
assert inventory.allowed
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
assert inventory.modules[1].steps[0].irreversible
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
def test_summary_fallback_blocks_when_data_exists() -> None:
inventory = collect_tenant_erasure_inventory(
_Registry(counts={"file_blobs": 3}),
object(),
"tenant-1",
)
files = next(item for item in inventory.modules if item.module_id == "files")
assert inventory.complete
assert not inventory.allowed
assert files.resources[0].disposition == "unavailable"
assert files.blockers == (
"Tenant-owned data exists but the module has no erasure provider.",
)
def test_provider_identity_must_match_capability_suffix() -> None:
provider = _Provider()
provider.module_id = "mail"
with pytest.raises(ValueError, match="identity"):
tenant_erasure_providers(_Registry(provider=provider))
+1 -1
View File
@@ -1,7 +1,7 @@
{
"initialJs": {
"rawBytes": 524288,
"gzipBytes": 163840
"gzipBytes": 164128
},
"asyncChunk": {
"rawBytes": 393216,
+166 -2
View File
@@ -1,5 +1,5 @@
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 FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
@@ -30,12 +30,23 @@ import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import HelpMenu from "../src/layout/HelpMenu";
import IconRail from "../src/layout/IconRail";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import {
createQuickAccessLaunchContext,
quickAccessLaunchState
} 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() {
const location = useLocation();
@@ -43,6 +54,10 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true);
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} />;
}
@@ -185,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() {
return (
<section className="conformance-section" aria-labelledby="help-heading">
@@ -303,6 +351,122 @@ const CONFORMANCE_AUTH = {
groups_loaded: true
} 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: [
+2 -1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router";
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 { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
@@ -39,7 +40,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
<PlatformLanguageProvider
preferredLanguageCode="de"
moduleTranslations={[formsRuntimeTranslations]}>
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
<UnsavedChangesProvider>
<Routes>
<Route path="/forms/public/:publicId" element={<ConformanceApp />} />
@@ -295,6 +295,33 @@ test("View focus has a deliberate permission-derived all-tools escape", async ({
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 }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.42",
"version": "0.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.42",
"version": "0.1.44",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
+19 -19
View File
@@ -1,22 +1,22 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.42",
"version": "0.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.42",
"version": "0.1.44",
"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/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/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.20",
"@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.24",
"@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/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.26",
@@ -757,8 +757,8 @@
"optional": true
},
"node_modules/@govoplan/access-webui": {
"version": "0.1.23",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#e55434f406e953a2fa9e881abb8fb6dccbabd812",
"version": "0.1.24",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#0f8a05f8b95340de7e0aa1569a51589764b0776e",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
@@ -805,10 +805,10 @@
}
},
"node_modules/@govoplan/calendar-webui": {
"version": "0.1.22",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3792e9ab8a10ff6e301c59df7a3fa47d5ab14952",
"version": "0.1.23",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3e5fc05ca3728464131067d1fa1bc25befeae939",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.44",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
@@ -894,10 +894,10 @@
}
},
"node_modules/@govoplan/files-webui": {
"version": "0.1.24",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#6176e9f40e070387f03c15d10b3b3d2f132508b5",
"version": "0.1.25",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#2baa8f265707a4c3dd6bfaba78d86ac7a0edc7bb",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.44",
"@vitejs/plugin-react": "^5.2.0",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
@@ -2027,9 +2027,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==",
"version": "2.11.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
"integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -2349,9 +2349,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"engines": {
"node": ">=12"
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.42",
"version": "0.1.44",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -22,6 +22,10 @@
"./wysiwyg": {
"types": "./src/wysiwyg.ts",
"import": "./src/wysiwyg.ts"
},
"./outcome-product-surface-translations": {
"types": "./src/i18n/outcomeProductSurfaceTranslations.ts",
"import": "./src/i18n/outcomeProductSurfaceTranslations.ts"
}
},
"scripts": {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.42",
"version": "0.1.44",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -26,14 +26,14 @@
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"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/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/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/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.24",
"@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/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.26",
+6 -2
View File
@@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
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 { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext";
@@ -73,6 +73,10 @@ export default function App() {
() => navItemsForModules(webModules, viewProjection),
[viewProjection, webModules]
);
const allToolItems = useMemo(
() => configurableNavigationItemsForModules(webModules),
[webModules]
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
@@ -550,7 +554,7 @@ export default function App() {
<PlatformViewProvider modules={webModules} projection={viewProjection}>
<PlatformActiveObjectProvider>
<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}>
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
+1 -4
View File
@@ -43,10 +43,7 @@ export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }];
export const DEFAULT_TRANSLATIONS: PlatformTranslations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const DEFAULT_TRANSLATIONS: PlatformTranslations = generatedTranslations;
const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null);
+2
View File
@@ -138,6 +138,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
"i18n:govoplan-core.application_notices": "Application notices",
"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.services_cases": "Services and cases",
"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.application_notices": "Anwendungshinweise",
"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.services_cases": "Leistungen und Vorgänge",
"i18n:govoplan-core.product_area.communication": "Kommunikation",
@@ -0,0 +1,28 @@
import type { PlatformTranslations } from "../types";
export const generatedTranslations = {
en: {
"i18n:govoplan-core.product_surface.work": "Work",
"i18n:govoplan-core.product_surface.work_description": "Review and resume authorized work without navigating by package ownership.",
"i18n:govoplan-core.product_surface.calendar": "Calendar",
"i18n:govoplan-core.product_surface.calendar_description": "Plan and review authorized events through one stable calendar destination.",
"i18n:govoplan-core.product_surface.files": "Files",
"i18n:govoplan-core.product_surface.files_description": "Find, select, and manage authorized files without exposing their storage implementation.",
"i18n:govoplan-core.product_surface.unavailable": "Destination is unavailable",
"i18n:govoplan-core.product_surface.unavailable_description": "No product destination is available for your current responsibility and permissions.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.",
"i18n:govoplan-core.access_administrator": "Access administrator"
},
de: {
"i18n:govoplan-core.product_surface.work": "Arbeit",
"i18n:govoplan-core.product_surface.work_description": "Berechtigte Arbeit prüfen und fortsetzen, ohne nach Paketzuständigkeit zu navigieren.",
"i18n:govoplan-core.product_surface.calendar": "Kalender",
"i18n:govoplan-core.product_surface.calendar_description": "Berechtigte Termine über ein stabiles Kalenderziel planen und prüfen.",
"i18n:govoplan-core.product_surface.files": "Dateien",
"i18n:govoplan-core.product_surface.files_description": "Berechtigte Dateien finden, auswählen und verwalten, ohne ihre Speicherimplementierung offenzulegen.",
"i18n:govoplan-core.product_surface.unavailable": "Das Produktziel ist nicht verfügbar",
"i18n:govoplan-core.product_surface.unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist kein Produktziel verfügbar.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.",
"i18n:govoplan-core.access_administrator": "Zugriffsadministration"
}
} satisfies PlatformTranslations;
+3 -1
View File
@@ -33,7 +33,9 @@ export * from "./platform/moduleEvents";
export * from "./platform/ViewContext";
export * from "./platform/views";
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/TemporalContext";
export * from "./platform/ActiveObjectContext";
+60
View File
@@ -0,0 +1,60 @@
import { Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router";
import type { MouseEvent } from "react";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformNavItem } from "../types";
export default function AllToolsNavigation({
items,
rememberedTargets
}: {
items: PlatformNavItem[];
rememberedTargets: Record<string, string>;
}) {
const location = useLocation();
const navigate = useGuardedNavigate();
const { translateText } = usePlatformLanguage();
function handleClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
navigate(target);
}
return (
<details className="icon-nav-all-tools" data-product-navigation="all-tools">
<summary
className="icon-nav-all-tools-summary"
title={translateText("i18n:govoplan-core.all_available_tools")}
>
<Settings size={20} aria-hidden="true" />
<span className="icon-nav-label">
{translateText("i18n:govoplan-core.all_available_tools")}
</span>
</summary>
<div className="icon-nav-all-tools-items">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const renderedLabel = translateText(label);
return (
<NavLink
key={to}
to={target}
className={`icon-nav-item ${pathActive(location.pathname, to) ? "active" : ""}`}
title={renderedLabel}
onClick={(event) => handleClick(event, target)}
>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>
);
})}
</div>
</details>
);
}
function pathActive(pathname: string, root: string): boolean {
return pathname === root || pathname.startsWith(`${root}/`);
}
+10 -3
View File
@@ -11,6 +11,7 @@ import { useActiveObject } from "../platform/ActiveObjectContext";
import { createQuickAccessLaunchContext } from "../platform/launchContext";
import { isViewSurfaceVisible } from "../platform/views";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { projectProductNavigation } from "../platform/productSurfaces";
type Props = {
children: React.ReactNode;
@@ -20,6 +21,7 @@ type Props = {
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean;
navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null };
backendReachable?: boolean;
};
@@ -32,6 +34,7 @@ export default function AppShell({
onAuthChange,
publicMode = false,
navItems = [],
allToolItems = navItems,
maintenanceMode,
backendReachable = true
}: Props) {
@@ -68,11 +71,15 @@ export default function AppShell({
() => modules.flatMap((module) => module.productAreas ?? []),
[modules]
);
const productNavigation = useMemo(
() => projectProductNavigation(navItems, modules, auth, projection, allToolItems),
[allToolItems, auth, modules, navItems, projection]
);
if (publicMode) {
return (
<div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} />
<IconRail compact navItems={navItems} />
<div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<main className="public-content">{children}</main>
@@ -84,8 +91,8 @@ export default function AppShell({
return (
<div className="app-shell">
<IconRail
auth={auth}
navItems={navItems}
navItems={productNavigation.primaryItems}
allToolItems={productNavigation.allToolItems}
productAreas={productAreas}
presentation={projection?.presentation}
/>
+22 -21
View File
@@ -1,45 +1,35 @@
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
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 {
AuthInfo,
PlatformNavItem,
ProductAreaContribution,
ViewPresentation
} from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { groupNavigationItems } from "../platform/productAreas";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
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;
});
}
const AllToolsNavigation = lazy(() => import("./AllToolsNavigation"));
export default function IconRail({
compact = false,
auth = null,
navItems = [],
allToolItems = [],
productAreas = [],
presentation
}: {
compact?: boolean;
auth?: AuthInfo | null;
navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
productAreas?: ProductAreaContribution[];
presentation?: ViewPresentation;
}) {
const location = useLocation();
const items = visibleNavItems(auth, navItems);
const items = navItems;
const technicalItems = allToolItems;
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
@@ -95,9 +85,9 @@ export default function IconRail({
{translateText(group.label)}
</div>
)}
{group.items.map(({ to, label, icon: Icon }) => {
{group.items.map(({ to, label, icon: Icon, activePaths }) => {
const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to);
const active = modulePathActive(location.pathname, to, activePaths);
const renderedLabel = translateText(label);
const areaLabel = group.areaLabel
? translateText(group.areaLabel)
@@ -120,6 +110,11 @@ export default function IconRail({
})}
</div>
))}
{technicalItems.length > 0 && (
<Suspense fallback={null}>
<AllToolsNavigation items={technicalItems} rememberedTargets={rememberedTargets} />
</Suspense>
)}
</nav>
</div>
<div className="icon-rail-bottom">
@@ -144,9 +139,15 @@ export default function IconRail({
}
function modulePathActive(pathname: string, root: string): boolean {
if (root === "/") return pathname === "/";
return pathname === root || pathname.startsWith(`${root}/`);
function modulePathActive(
pathname: string,
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> {
+1 -9
View File
@@ -87,13 +87,5 @@ export function groupNavigationItems(
items: remaining
});
}
const overview = groups.filter((group) => group.id === "overview");
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);
return groups;
}
+73
View File
@@ -2,6 +2,7 @@ import type {
AuthInfo,
ComposedProductSurface,
EffectiveViewProjection,
PlatformNavItem,
ProductSurfaceMetadata,
PlatformWebModule,
ProductSurfaceContribution
@@ -20,6 +21,11 @@ export type ProductSurfaceRouteResolvedEventDetail = {
usedAlias: boolean;
};
export type ProductNavigationProjection = {
primaryItems: PlatformNavItem[];
allToolItems: PlatformNavItem[];
};
export function composeProductSurfaces(
modules: readonly PlatformWebModule[]
): 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(
detail: ProductSurfaceRouteResolvedEventDetail
): void {
@@ -141,3 +206,11 @@ function compareContributions(
): number {
return left.order - right.order || left.moduleId.localeCompare(right.moduleId);
}
function navigationItemAuthorized(
item: PlatformNavItem,
auth: AuthInfo | null | undefined
): boolean {
return !item.allOf?.some((scope) => !hasScope(auth, scope))
&& (!item.anyOf?.length || hasAnyScope(auth, item.anyOf));
}
+8
View File
@@ -0,0 +1,8 @@
import type { PlatformTranslations } from "./types";
import { generatedTranslations as messages } from "./i18n/messagesProductSurfaceTranslations";
import { generatedTranslations as outcomes } from "./i18n/outcomeProductSurfaceTranslations";
export const productSurfaceTranslations = {
en: { ...messages.en, ...outcomes.en },
de: { ...messages.de, ...outcomes.de }
} satisfies PlatformTranslations;
+10
View File
@@ -29,6 +29,16 @@
.icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.icon-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-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; }
.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); }
+2
View File
@@ -304,6 +304,8 @@ export type PlatformNavItem = {
navigationVisibilitySource?: string;
navigationLockSource?: string | null;
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 = {
+62 -1
View File
@@ -18,7 +18,10 @@ import {
visibleRoutesForProjection
} from "../src/platform/views";
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";
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]?.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 = {
...files,
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
+3
View File
@@ -31,6 +31,9 @@
],
"@govoplan/core-webui/wysiwyg": [
"./src/wysiwyg.ts"
],
"@govoplan/core-webui/outcome-product-surface-translations": [
"./src/i18n/outcomeProductSurfaceTranslations.ts"
]
}
},
+1
View File
@@ -250,6 +250,7 @@ export default defineConfig({
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/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/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)) }
]
},