Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08c3e47b6d | ||
|
|
6e518fa6a2 | ||
|
|
f98cf9ced8 | ||
|
|
d2e491348d | ||
|
|
562d278f60 | ||
|
|
c6f6faf64f | ||
|
|
1c3ee9e8c7 | ||
|
|
aa91063211 | ||
|
|
fa2d5d40dd |
@@ -157,6 +157,16 @@ The initial implementation includes provider-neutral orchestration helpers:
|
||||
- `apply_configuration_package(...)`
|
||||
- `export_configuration_package(...)`
|
||||
|
||||
Portable fragments may bind deployment-specific operator input without placing
|
||||
that value in the signed reusable definition. A payload value of
|
||||
`{"$data": "requirement_key"}` references a key declared in the manifest's
|
||||
`data_requirements`. Preflight fails before invoking the owning provider when a
|
||||
reference is malformed, undeclared, or unresolved. Once supplied, Core replaces
|
||||
the reference in memory and passes only the resolved fragment to the provider.
|
||||
This mechanism is for deployment bindings and wording, not plaintext secrets:
|
||||
credential-envelope or environment references remain the normal portable
|
||||
boundary.
|
||||
|
||||
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
||||
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
||||
fragments and applies them idempotently. Mail and Files also register providers
|
||||
@@ -212,6 +222,14 @@ The admin wizard backend starts with these routes:
|
||||
10. Store import provenance, package version, supplied non-secret metadata, and
|
||||
audit events.
|
||||
|
||||
Provider applies may commit independently. Core therefore stops at the first
|
||||
apply or health blocker and reports an explicit rollback state. A blocked
|
||||
preflight or a no-op needs no recovery; a successful multi-provider mutation
|
||||
retains the reviewed pre-apply database snapshot as its generic rollback path;
|
||||
a later-provider failure is reported as a partial apply that requires snapshot
|
||||
recovery or an explicitly supported module-owned compensation. The generic
|
||||
wizard never claims atomic cross-module undo.
|
||||
|
||||
The wizard should display everything necessary and nothing unnecessary. Generic
|
||||
sections should cover package trust, dependency plan, required data, conflicts,
|
||||
review, and result. Module-specific fields should appear only when the selected
|
||||
@@ -262,6 +280,11 @@ Exported packages should record provenance: source GovOPlaN version, module
|
||||
versions, exporter identity, timestamp, selected scope, redactions, and
|
||||
validation status.
|
||||
|
||||
The orchestrator emits this provenance independently of provider payloads and
|
||||
lists secret requirement keys as redacted without serializing their supplied
|
||||
values. Providers still own the deeper rule that credentials, tokens, and
|
||||
decrypted envelope contents must never appear in exported fragments.
|
||||
|
||||
## Catalogs And Trust
|
||||
|
||||
Configuration catalogs should follow the existing module package catalog model:
|
||||
|
||||
@@ -58,6 +58,17 @@ than adding custom `F1` listeners:
|
||||
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
|
||||
labelled primary/content panes; pages inside it use `PageLayout` in
|
||||
`workspace` mode and retain their own route-level help identity.
|
||||
- `PasswordField` passes its owner context and module through reveal/generate
|
||||
actions and the shared generator dialog. Credential consumers must supply an
|
||||
exact owner context; the generic component does not own credential policy.
|
||||
|
||||
High-risk controls use one of the source-inventory risk classes (`authority`,
|
||||
`credential`, `disclosure`, `encryption`, `external-effect`, `irreversible`,
|
||||
`policy`, or `retention`) and require exact F1 help. The extractor infers
|
||||
obvious cases conservatively; components may declare `data-help-risk`
|
||||
explicitly or mark a reviewed ordinary control with
|
||||
`data-help-risk-reviewed="standard"`. The strict workspace gate rejects new
|
||||
unresolved high-risk debt.
|
||||
|
||||
Module routes, public routes, settings sections, and administration sections
|
||||
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
|
||||
|
||||
@@ -18,6 +18,27 @@ The platform inventory recognizes both inline locale objects and generated
|
||||
catalogs declared as `const de` / `const en`. Its strict mode requires both
|
||||
locales and reports `de` explicitly as the reference locale.
|
||||
|
||||
## Structured Documentation Localization
|
||||
|
||||
`DocumentationTopic.translations` continues to own localized title, summary,
|
||||
and body prose. Topics whose metadata contains rendered prose opt into the
|
||||
separate `structured_translation_version="1"` contract and provide a complete
|
||||
same-shape value for each translated metadata key in
|
||||
`structured_translations`. Version 1 covers workflow prerequisites, steps,
|
||||
outcome, result and verification; reference fields; limitations, constraints,
|
||||
consequences and consequence classes; and the other rendered explanation
|
||||
fields declared by Core.
|
||||
|
||||
The registry rejects an unversioned translation, an unsupported contract
|
||||
version, missing structured keys, changed object keys or list lengths, empty
|
||||
translated strings, and changed non-text values. Stable field IDs, routes,
|
||||
permission scopes, and other technical leaves therefore remain structurally
|
||||
bound to the source metadata. The Docs module overlays only a validated locale
|
||||
at response time and reports the selected structured locale separately from the
|
||||
title/body locale. Missing structured translations fall back to source content
|
||||
and remain visible in public coverage until the owning module adopts the
|
||||
contract.
|
||||
|
||||
## Help Resolution
|
||||
|
||||
Every focusable field and action receives a stable derived F1 identity from the
|
||||
@@ -54,6 +75,17 @@ native control nested in `FormField`. Dynamic context expressions remain
|
||||
separate evidence and generic derived fallbacks remain in the richer-help
|
||||
candidate queue.
|
||||
|
||||
The same inventory classifies controls whose labels, identities, component
|
||||
context, or explicit `data-help-risk` indicate authority, credentials,
|
||||
disclosure, encryption, external effects, irreversible changes, policy, or
|
||||
retention. These controls require an exact context rather than relying only on
|
||||
page fallback. Reviewed false positives carry
|
||||
`data-help-risk-reviewed="standard"`. Invalid risk classes and any increase
|
||||
above the versioned `tools/inventory/high-risk-help-baseline.json` ceiling fail
|
||||
strict declaration checks; the ceiling is lowered as the finite queue is
|
||||
resolved. Password fields and their generator dialog propagate the owning
|
||||
field's context so shared credential controls never invent a Core-owned topic.
|
||||
|
||||
The generated `help_review_candidates` list is therefore a content-depth queue,
|
||||
not a list of controls on which F1 cannot work. It should prioritize:
|
||||
|
||||
@@ -71,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
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -88,6 +128,8 @@ The check must report:
|
||||
- no duplicate stable IDs;
|
||||
- no undeclared public WebUI surface;
|
||||
- no stale runtime route or endpoint declaration.
|
||||
- no invalid high-risk help annotation or regression above the recorded
|
||||
exact-context debt ceiling.
|
||||
|
||||
Browser acceptance is part of the focused workspace gate and can be run alone:
|
||||
|
||||
|
||||
@@ -1030,6 +1030,32 @@ Any future exception is extraction debt and must be temporary, documented in the
|
||||
script with a reason, and removed when a capability/API/event contract replaces
|
||||
it.
|
||||
|
||||
## Product Surface Contributions
|
||||
|
||||
`FrontendModule.product_surfaces` is the versioned product-composition contract
|
||||
for stable identities that may have one or more technical owners. A contribution
|
||||
declares contract version 1, a product identity, common label/icon/description,
|
||||
stable entry path, owner route and View surfaces, supported task/reader/admin/
|
||||
operator presentations, authorization requirements, capabilities, search
|
||||
sources, help contexts, documentation topics, migration aliases, and standard
|
||||
unavailable/degraded explanations.
|
||||
|
||||
Core validates every reference against the owning manifest. Contributors that
|
||||
share an identity must agree on its common product metadata and entry path;
|
||||
entry and alias paths cannot belong to another product identity. The WebUI
|
||||
composes valid owners by product id, filters them through authorization and the
|
||||
effective View, and resolves the stable entry or migration alias to the first
|
||||
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.
|
||||
|
||||
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
|
||||
be supplied as a collapsed technical detail; they are not the primary error.
|
||||
The state is presentation only and never grants authority or changes provider
|
||||
health.
|
||||
|
||||
## Boundary Decision Register
|
||||
|
||||
These durable decisions close older exploratory core issues. Implementation
|
||||
|
||||
@@ -236,7 +236,9 @@ instead of reproducing their behavior.
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
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
|
||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.32"
|
||||
version = "0.1.41"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
@@ -38,6 +40,12 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
|
||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
ConfigurationRollbackStatus = Literal[
|
||||
"blocked_before_apply",
|
||||
"not_required",
|
||||
"database_restore_required",
|
||||
"partial_apply_requires_recovery",
|
||||
]
|
||||
ConfigurationPackageClass = Literal[
|
||||
"reference",
|
||||
"product",
|
||||
@@ -461,6 +469,21 @@ class ConfigurationApplyResult:
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
created_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
updated_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
rollback: "ConfigurationRollbackState | None" = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationRollbackState:
|
||||
status: ConfigurationRollbackStatus
|
||||
summary: str
|
||||
recovery_action: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"recovery_action": self.recovery_action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -471,11 +494,40 @@ class ConfigurationExportSelection:
|
||||
object_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportProvenance:
|
||||
exported_at: str
|
||||
source_core_version: str
|
||||
module_versions: Mapping[str, str]
|
||||
tenant_id: str | None
|
||||
exporter_id: str | None
|
||||
scopes: tuple[str, ...] = ()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
object_refs: tuple[str, ...] = ()
|
||||
redacted_secret_keys: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"exported_at": self.exported_at,
|
||||
"source_core_version": self.source_core_version,
|
||||
"module_versions": dict(self.module_versions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"exporter_id": self.exporter_id,
|
||||
"selection": {
|
||||
"scopes": list(self.scopes),
|
||||
"module_ids": list(self.module_ids),
|
||||
"object_refs": list(self.object_refs),
|
||||
},
|
||||
"redacted_secret_keys": list(self.redacted_secret_keys),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportResult:
|
||||
fragments: tuple[ConfigurationPackageFragment, ...] = ()
|
||||
data_requirements: tuple[ConfigurationRequiredData, ...] = ()
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
provenance: ConfigurationExportProvenance | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -508,6 +560,7 @@ def dry_run_configuration_package(
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
required_data: list[ConfigurationRequiredData] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
declared_data: dict[str, ConfigurationRequiredData] = {}
|
||||
|
||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||
@@ -515,6 +568,7 @@ def dry_run_configuration_package(
|
||||
for item in manifest.data_requirements:
|
||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||
required_data.append(requirement)
|
||||
declared_data[requirement.key] = requirement
|
||||
if requirement.required and requirement.key not in context.supplied_data:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -525,6 +579,25 @@ def dry_run_configuration_package(
|
||||
))
|
||||
|
||||
for fragment in manifest.fragments:
|
||||
data_ref_diagnostics = _fragment_data_reference_diagnostics(
|
||||
fragment,
|
||||
declared_data=declared_data,
|
||||
supplied_data=context.supplied_data,
|
||||
)
|
||||
if data_ref_diagnostics:
|
||||
diagnostics.extend(data_ref_diagnostics)
|
||||
plan.append(ConfigurationPlanItem(
|
||||
action="blocked",
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
summary="Fragment needs declared deployment data before provider preflight.",
|
||||
))
|
||||
continue
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
context.supplied_data,
|
||||
)
|
||||
provider = provider_map.get(fragment.module_id)
|
||||
if provider is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
@@ -550,7 +623,7 @@ def dry_run_configuration_package(
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
|
||||
continue
|
||||
try:
|
||||
result = provider.preflight(fragment, context)
|
||||
result = provider.preflight(resolved_fragment, context)
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -605,19 +678,41 @@ def apply_configuration_package(
|
||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
|
||||
if blockers:
|
||||
return ConfigurationApplyResult(diagnostics=tuple(blockers))
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(blockers),
|
||||
rollback=ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="No provider changes were attempted because package preflight is blocked.",
|
||||
),
|
||||
)
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
|
||||
created_refs: dict[str, str] = {}
|
||||
updated_refs: dict[str, str] = {}
|
||||
stopped_after_blocker = False
|
||||
for fragment in manifest.fragments:
|
||||
provider = provider_map[fragment.module_id]
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
apply_context.supplied_data,
|
||||
)
|
||||
try:
|
||||
result = provider.apply(fragment, apply_context.supplied_data, apply_context)
|
||||
result = provider.apply(
|
||||
resolved_fragment,
|
||||
apply_context.supplied_data,
|
||||
apply_context,
|
||||
)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
created_refs.update(result.created_refs)
|
||||
updated_refs.update(result.updated_refs)
|
||||
diagnostics.extend(provider.health(result, apply_context))
|
||||
health_diagnostics = provider.health(result, apply_context)
|
||||
diagnostics.extend(health_diagnostics)
|
||||
if any(
|
||||
item.severity == "blocker"
|
||||
for item in (*result.diagnostics, *health_diagnostics)
|
||||
):
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -627,10 +722,36 @@ def apply_configuration_package(
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Stop the import, keep previous configuration, and inspect provider logs.",
|
||||
))
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
changed = bool(created_refs or updated_refs)
|
||||
if stopped_after_blocker and changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="partial_apply_requires_recovery",
|
||||
summary="At least one provider committed changes before a later provider blocked the package.",
|
||||
recovery_action="Restore the reviewed pre-apply database snapshot or use module-owned compensation where explicitly supported.",
|
||||
)
|
||||
elif stopped_after_blocker:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="The first provider blocked before any configuration reference was created or updated.",
|
||||
)
|
||||
elif changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="database_restore_required",
|
||||
summary="The package changed provider-owned configuration; generic cross-module compensation is not available.",
|
||||
recovery_action="Retain the pre-apply database snapshot until verification is complete; restore it if the package must be rolled back.",
|
||||
)
|
||||
else:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="not_required",
|
||||
summary="All package fragments were no-ops, so no rollback action is required.",
|
||||
)
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
created_refs=created_refs,
|
||||
updated_refs=updated_refs,
|
||||
rollback=rollback,
|
||||
)
|
||||
|
||||
|
||||
@@ -669,10 +790,29 @@ def export_configuration_package(
|
||||
fragments.extend(result.fragments)
|
||||
data_requirements.extend(result.data_requirements)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
deduped_required_data = tuple(_dedupe_required_data(data_requirements))
|
||||
provenance = ConfigurationExportProvenance(
|
||||
exported_at=datetime.now(UTC).isoformat(),
|
||||
source_core_version=_installed_core_version(),
|
||||
module_versions={
|
||||
module_id: context.installed_modules[module_id]
|
||||
for module_id in sorted(set(module_ids))
|
||||
if module_id in context.installed_modules
|
||||
},
|
||||
tenant_id=selection.tenant_id,
|
||||
exporter_id=context.operator_user_id,
|
||||
scopes=selection.scopes,
|
||||
module_ids=tuple(module_ids),
|
||||
object_refs=selection.object_refs,
|
||||
redacted_secret_keys=tuple(
|
||||
sorted(item.key for item in deduped_required_data if item.secret)
|
||||
),
|
||||
)
|
||||
return ConfigurationExportResult(
|
||||
fragments=tuple(fragments),
|
||||
data_requirements=tuple(_dedupe_required_data(data_requirements)),
|
||||
data_requirements=deduped_required_data,
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
@@ -1283,6 +1423,103 @@ def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[Co
|
||||
return result
|
||||
|
||||
|
||||
def _fragment_data_reference_diagnostics(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
*,
|
||||
declared_data: Mapping[str, ConfigurationRequiredData],
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> list[ConfigurationDiagnostic]:
|
||||
references: set[str] = set()
|
||||
invalid = _collect_fragment_data_references(fragment.payload, references)
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
object_ref = fragment.fragment_id or fragment.fragment_type
|
||||
if invalid:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_invalid",
|
||||
message="Configuration fragment data references must be objects containing only a non-empty $data key.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Replace malformed references with {\"$data\": \"declared_requirement_key\"}.",
|
||||
))
|
||||
for key in sorted(references - set(declared_data)):
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_undeclared",
|
||||
message=f"Configuration fragment references undeclared operator data {key!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Declare the key in package data_requirements before using it in a fragment.",
|
||||
))
|
||||
for key in sorted(references & set(declared_data)):
|
||||
if key in supplied_data:
|
||||
continue
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_missing",
|
||||
message=f"Configuration fragment needs operator data {declared_data[key].label!r} before provider preflight.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Provide the value in the generated configuration package form.",
|
||||
))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _collect_fragment_data_references(value: object, references: set[str]) -> bool:
|
||||
invalid = False
|
||||
if isinstance(value, Mapping):
|
||||
if "$data" in value:
|
||||
key = value.get("$data")
|
||||
if len(value) != 1 or not isinstance(key, str) or not key.strip():
|
||||
return True
|
||||
references.add(key.strip())
|
||||
return False
|
||||
for item in value.values():
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
for item in value:
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
return invalid
|
||||
|
||||
|
||||
def _resolve_fragment_data_references(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> ConfigurationPackageFragment:
|
||||
payload = _resolve_data_reference_value(fragment.payload, supplied_data)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError("Resolved configuration fragment payload must remain an object.")
|
||||
return ConfigurationPackageFragment(
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_data_reference_value(value: object, supplied_data: Mapping[str, Any]) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
if set(value) == {"$data"}:
|
||||
key = value.get("$data")
|
||||
if not isinstance(key, str) or key not in supplied_data:
|
||||
raise ValueError("Configuration fragment contains an unresolved $data reference.")
|
||||
return supplied_data[key]
|
||||
return {
|
||||
str(key): _resolve_data_reference_value(item, supplied_data)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return [_resolve_data_reference_value(item, supplied_data) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _installed_core_version() -> str:
|
||||
try:
|
||||
return package_version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "workspace"
|
||||
|
||||
|
||||
def _catalog_source(path: Path | str | None) -> Path | str | None:
|
||||
if path is not None:
|
||||
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
|
||||
|
||||
@@ -101,6 +101,8 @@ class DatasourceGovernance:
|
||||
transfer_agreement_ref: str | None = None
|
||||
freshness_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
quality_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
approval_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
retention_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
known_limits: tuple[str, ...] = ()
|
||||
correction_procedure_ref: str | None = None
|
||||
affected_refs: tuple[str, ...] = ()
|
||||
@@ -179,6 +181,8 @@ class DatasourceGovernance:
|
||||
),
|
||||
freshness_policy=_governance_mapping(source.get("freshness_policy")),
|
||||
quality_policy=_governance_mapping(source.get("quality_policy")),
|
||||
approval_policy=_governance_mapping(source.get("approval_policy")),
|
||||
retention_policy=_governance_mapping(source.get("retention_policy")),
|
||||
known_limits=_governance_texts(source.get("known_limits")),
|
||||
correction_procedure_ref=_optional_governance_text(
|
||||
source.get("correction_procedure_ref")
|
||||
@@ -210,6 +214,8 @@ class DatasourceGovernance:
|
||||
"transfer_agreement_ref": self.transfer_agreement_ref,
|
||||
"freshness_policy": dict(self.freshness_policy),
|
||||
"quality_policy": dict(self.quality_policy),
|
||||
"approval_policy": dict(self.approval_policy),
|
||||
"retention_policy": dict(self.retention_policy),
|
||||
"known_limits": list(self.known_limits),
|
||||
"correction_procedure_ref": self.correction_procedure_ref,
|
||||
"affected_refs": list(self.affected_refs),
|
||||
@@ -289,6 +295,8 @@ class DatasourceMaterialization:
|
||||
frozen_label: str | None = None
|
||||
source_timestamp: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
disposed_at: datetime | None = None
|
||||
disposition: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||
@@ -309,6 +317,7 @@ class DatasourceStage:
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
validation: Mapping[str, object] = field(default_factory=dict)
|
||||
approval: Mapping[str, object] = field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
promoted_at: datetime | None = None
|
||||
promoted_materialization_ref: str | None = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.information_governance import ModuleInformationGovernance
|
||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
PermissionLevel = Literal["system", "tenant"]
|
||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||
@@ -114,6 +115,55 @@ class ProductAreaContribution:
|
||||
order: int = 100
|
||||
|
||||
|
||||
ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"]
|
||||
ProductAvailabilityReason = Literal[
|
||||
"authorization",
|
||||
"policy",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"capability",
|
||||
"offline",
|
||||
"provider_degraded",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductAvailabilityExplanation:
|
||||
"""Explain a product outcome without making package topology user-facing."""
|
||||
|
||||
reason: ProductAvailabilityReason
|
||||
title: str
|
||||
description: str
|
||||
resolution: str
|
||||
responsible_role: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductSurfaceContribution:
|
||||
"""Bind an owner route to a stable, cross-module product identity."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
label: str
|
||||
icon: str
|
||||
entry_path: str
|
||||
route_path: str
|
||||
surface_ids: tuple[str, ...]
|
||||
unavailable: ProductAvailabilityExplanation
|
||||
description: str | None = None
|
||||
degraded: ProductAvailabilityExplanation | None = None
|
||||
presentations: tuple[ProductSurfacePresentation, ...] = ("task",)
|
||||
capability_ids: tuple[str, ...] = ()
|
||||
search_source_ids: tuple[str, ...] = ()
|
||||
help_context_ids: tuple[str, ...] = ()
|
||||
documentation_topic_ids: tuple[str, ...] = ()
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
aliases: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuickAccessTool:
|
||||
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
||||
@@ -153,6 +203,7 @@ class FrontendModule:
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
|
||||
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||
|
||||
|
||||
@@ -289,6 +340,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||
|
||||
|
||||
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1"
|
||||
DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset(
|
||||
{
|
||||
"admin_explanation",
|
||||
"consequence_classes",
|
||||
"consequences",
|
||||
"constraints",
|
||||
"current_configuration",
|
||||
"fields",
|
||||
"limitations",
|
||||
"operational_consequences",
|
||||
"outcome",
|
||||
"prerequisites",
|
||||
"privacy_notes",
|
||||
"purpose",
|
||||
"result",
|
||||
"steps",
|
||||
"user_explanation",
|
||||
"verification",
|
||||
"when_used",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationLink:
|
||||
label: str
|
||||
@@ -324,12 +399,142 @@ class DocumentationTopic:
|
||||
configuration_keys: tuple[str, ...] = ()
|
||||
i18n_key: str | None = None
|
||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||
structured_translation_version: str | None = None
|
||||
structured_translations: Mapping[str, Mapping[str, Any]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
source_module_id: str | None = None
|
||||
version_min: str | None = None
|
||||
version_max_exclusive: str | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def localizable_documentation_metadata_keys(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return structured metadata keys whose values are public prose."""
|
||||
|
||||
return tuple(
|
||||
sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata))
|
||||
)
|
||||
|
||||
|
||||
def localized_documentation_metadata(
|
||||
topic: DocumentationTopic,
|
||||
locale: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Overlay one validated structured translation onto source metadata."""
|
||||
|
||||
localized = dict(topic.metadata)
|
||||
translation = topic.structured_translations.get(locale)
|
||||
if translation:
|
||||
localized.update(translation)
|
||||
return localized
|
||||
|
||||
|
||||
def documentation_structured_translation_issues(
|
||||
topic: DocumentationTopic,
|
||||
) -> tuple[str, ...]:
|
||||
"""Validate the opt-in, versioned structured-documentation translation."""
|
||||
|
||||
version = topic.structured_translation_version
|
||||
translations = topic.structured_translations
|
||||
if version is None:
|
||||
if translations:
|
||||
return (
|
||||
"structured_translations require structured_translation_version",
|
||||
)
|
||||
return ()
|
||||
if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION:
|
||||
return (
|
||||
"unsupported structured_translation_version "
|
||||
f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}",
|
||||
)
|
||||
|
||||
localizable_keys = set(localizable_documentation_metadata_keys(topic))
|
||||
issues: list[str] = []
|
||||
for locale, translation in translations.items():
|
||||
if not locale.strip():
|
||||
issues.append("structured translation locale must not be empty")
|
||||
continue
|
||||
translated_keys = set(translation)
|
||||
for key in sorted(translated_keys - localizable_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys - translated_keys):
|
||||
issues.append(
|
||||
f"structured translation {locale!r} is missing metadata key {key!r}"
|
||||
)
|
||||
for key in sorted(localizable_keys & translated_keys):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
topic.metadata[key],
|
||||
translation[key],
|
||||
path=f"{locale}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _structured_translation_shape_issues(
|
||||
source: object,
|
||||
translated: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(source, str):
|
||||
if not isinstance(translated, str) or not translated.strip():
|
||||
return (f"structured translation {path} must be a non-empty string",)
|
||||
return ()
|
||||
if isinstance(source, Mapping):
|
||||
if not isinstance(translated, Mapping):
|
||||
return (f"structured translation {path} must preserve object shape",)
|
||||
issues: list[str] = []
|
||||
source_keys = {str(key) for key in source}
|
||||
translated_keys = {str(key) for key in translated}
|
||||
if source_keys != translated_keys:
|
||||
issues.append(
|
||||
f"structured translation {path} must preserve object keys"
|
||||
)
|
||||
return tuple(issues)
|
||||
for key, value in source.items():
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
value,
|
||||
translated[key],
|
||||
path=f"{path}.{key}",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if isinstance(source, Sequence) and not isinstance(
|
||||
source, (str, bytes, bytearray)
|
||||
):
|
||||
if not isinstance(translated, Sequence) or isinstance(
|
||||
translated, (str, bytes, bytearray)
|
||||
):
|
||||
return (f"structured translation {path} must preserve list shape",)
|
||||
if len(source) != len(translated):
|
||||
return (f"structured translation {path} must preserve list length",)
|
||||
issues: list[str] = []
|
||||
for index, (source_item, translated_item) in enumerate(
|
||||
zip(source, translated, strict=True)
|
||||
):
|
||||
issues.extend(
|
||||
_structured_translation_shape_issues(
|
||||
source_item,
|
||||
translated_item,
|
||||
path=f"{path}[{index}]",
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
if translated != source:
|
||||
return (
|
||||
f"structured translation {path} must preserve non-text value {source!r}",
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
@@ -533,3 +738,53 @@ class ModuleManifest:
|
||||
# runtime module ID changes.
|
||||
permission_namespace: str | None = None
|
||||
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
|
||||
|
||||
|
||||
def with_documentation_structured_translations(
|
||||
manifest: ModuleManifest,
|
||||
*,
|
||||
locale: str,
|
||||
translations: Mapping[str, Mapping[str, Any]],
|
||||
) -> ModuleManifest:
|
||||
"""Merge module-owned structured documentation translations by topic id.
|
||||
|
||||
The helper keeps feature prose in its owning module while giving every
|
||||
manifest the same fail-closed merge behavior. Unknown topic ids and
|
||||
incomplete or shape-changing locale maps are rejected immediately.
|
||||
"""
|
||||
|
||||
locale = locale.strip()
|
||||
if not locale:
|
||||
raise ValueError("structured documentation locale must not be empty")
|
||||
|
||||
topics_by_id = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topic_ids = sorted(set(translations) - set(topics_by_id))
|
||||
if unknown_topic_ids:
|
||||
raise ValueError(
|
||||
"structured documentation translations reference unknown topic ids: "
|
||||
+ ", ".join(unknown_topic_ids)
|
||||
)
|
||||
|
||||
localized_topics: list[DocumentationTopic] = []
|
||||
for topic in manifest.documentation:
|
||||
translation = translations.get(topic.id)
|
||||
if translation is None:
|
||||
localized_topics.append(topic)
|
||||
continue
|
||||
|
||||
structured_translations = dict(topic.structured_translations)
|
||||
structured_translations[locale] = translation
|
||||
localized_topic = replace(
|
||||
topic,
|
||||
structured_translation_version=DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
|
||||
structured_translations=structured_translations,
|
||||
)
|
||||
issues = documentation_structured_translation_issues(localized_topic)
|
||||
if issues:
|
||||
raise ValueError(
|
||||
f"invalid {locale!r} structured documentation translation for "
|
||||
f"{topic.id!r}: {'; '.join(issues)}"
|
||||
)
|
||||
localized_topics.append(localized_topic)
|
||||
|
||||
return replace(manifest, documentation=tuple(localized_topics))
|
||||
|
||||
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
|
||||
"navigation",
|
||||
"permission",
|
||||
"product_area",
|
||||
"product_surface",
|
||||
"provided_interface",
|
||||
"public_route",
|
||||
"search_provider",
|
||||
@@ -237,6 +238,41 @@ def manifest_interface_declarations(
|
||||
},
|
||||
)
|
||||
)
|
||||
for surface in frontend.product_surfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.{surface.id}",
|
||||
module_id=manifest.id,
|
||||
kind="product_surface",
|
||||
label=surface.label,
|
||||
path=surface.route_path,
|
||||
required_all=surface.required_all,
|
||||
required_any=surface.required_any,
|
||||
metadata={
|
||||
"contract_version": surface.contract_version,
|
||||
"product_surface_id": surface.id,
|
||||
"description": surface.description,
|
||||
"icon": surface.icon,
|
||||
"entry_path": surface.entry_path,
|
||||
"surface_ids": list(surface.surface_ids),
|
||||
"presentations": list(surface.presentations),
|
||||
"capability_ids": list(surface.capability_ids),
|
||||
"search_source_ids": list(surface.search_source_ids),
|
||||
"help_context_ids": list(surface.help_context_ids),
|
||||
"documentation_topic_ids": list(
|
||||
surface.documentation_topic_ids
|
||||
),
|
||||
"aliases": list(surface.aliases),
|
||||
"order": surface.order,
|
||||
"unavailable_reason": surface.unavailable.reason,
|
||||
"degraded_reason": (
|
||||
surface.degraded.reason
|
||||
if surface.degraded is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
for tool in frontend.quick_access_tools:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
|
||||
@@ -16,16 +16,20 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
|
||||
TenantSummaryBatchProvider,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
documentation_structured_translation_issues,
|
||||
)
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
TenantModuleEntitlementResolver,
|
||||
@@ -89,6 +93,9 @@ _WILDCARD_RE = re.compile(
|
||||
)
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
|
||||
_PRODUCT_SURFACE_ID_RE = re.compile(
|
||||
r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||
)
|
||||
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
|
||||
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||
)
|
||||
@@ -962,6 +969,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
for issue in documentation_structured_translation_issues(topic):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
_validate_documentation_extensions(manifest)
|
||||
_validate_architecture_declarations(manifest)
|
||||
_validate_workflow_definition_contributions(manifest)
|
||||
@@ -969,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
|
||||
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
area_definitions: dict[str, tuple[str, str]] = {}
|
||||
surface_definitions: dict[str, tuple[str, str, str, str | None]] = {}
|
||||
product_paths: dict[str, str] = {}
|
||||
tool_owners: dict[str, str] = {}
|
||||
concrete_paths = {
|
||||
route.path: manifest.id
|
||||
for manifest in manifests
|
||||
if manifest.frontend is not None
|
||||
for route in (
|
||||
*manifest.frontend.routes,
|
||||
*manifest.frontend.settings_routes,
|
||||
*manifest.frontend.public_routes,
|
||||
)
|
||||
}
|
||||
for manifest in manifests:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
@@ -982,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non
|
||||
f"Product area {area.id!r} has conflicting labels or icons"
|
||||
)
|
||||
area_definitions[area.id] = definition
|
||||
for surface in frontend.product_surfaces:
|
||||
definition = (
|
||||
surface.label,
|
||||
surface.icon,
|
||||
surface.entry_path,
|
||||
surface.description,
|
||||
)
|
||||
previous = surface_definitions.get(surface.id)
|
||||
if previous is not None and previous != definition:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has conflicting product identity metadata"
|
||||
)
|
||||
surface_definitions[surface.id] = definition
|
||||
for path in (surface.entry_path, *surface.aliases):
|
||||
concrete_owner = concrete_paths.get(path)
|
||||
if concrete_owner is not None:
|
||||
raise RegistryError(
|
||||
f"Product path {path!r} collides with a concrete route "
|
||||
f"owned by module {concrete_owner!r}"
|
||||
)
|
||||
previous_id = product_paths.get(path)
|
||||
if previous_id is not None and previous_id != surface.id:
|
||||
raise RegistryError(
|
||||
f"Product path {path!r} is shared by product surfaces "
|
||||
f"{previous_id!r} and {surface.id!r}"
|
||||
)
|
||||
product_paths[path] = surface.id
|
||||
for tool in frontend.quick_access_tools:
|
||||
previous_owner = tool_owners.get(tool.id)
|
||||
if previous_owner is not None:
|
||||
@@ -1469,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
|
||||
f"in module {manifest.id!r}"
|
||||
)
|
||||
seen_area_memberships.add(membership)
|
||||
seen_product_surfaces: set[str] = set()
|
||||
for surface in frontend.product_surfaces:
|
||||
_validate_product_surface(manifest, surface, known_surface_ids)
|
||||
if surface.id in seen_product_surfaces:
|
||||
raise RegistryError(
|
||||
f"Duplicate product surface {surface.id!r} in module {manifest.id!r}"
|
||||
)
|
||||
seen_product_surfaces.add(surface.id)
|
||||
seen_tools: set[str] = set()
|
||||
for tool in frontend.quick_access_tools:
|
||||
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
||||
@@ -1507,6 +1565,151 @@ def _validate_product_area(
|
||||
)
|
||||
|
||||
|
||||
def _validate_product_surface(
|
||||
manifest: ModuleManifest,
|
||||
surface: ProductSurfaceContribution,
|
||||
known_surface_ids: set[str],
|
||||
) -> None:
|
||||
module_id = manifest.id
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
if surface.module_id != module_id:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||
f"not module {module_id!r}"
|
||||
)
|
||||
if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id):
|
||||
raise RegistryError(f"Invalid product surface id: {surface.id!r}")
|
||||
if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} uses unsupported contract version "
|
||||
f"{surface.contract_version!r}"
|
||||
)
|
||||
if not surface.label.strip() or not surface.icon.strip():
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} needs a label and icon"
|
||||
)
|
||||
for label, path in (
|
||||
("entry", surface.entry_path),
|
||||
("owner", surface.route_path),
|
||||
*(("alias", alias) for alias in surface.aliases),
|
||||
):
|
||||
if not path.startswith("/") or "?" in path or "#" in path:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has an invalid {label} path {path!r}"
|
||||
)
|
||||
if (
|
||||
surface.entry_path == surface.route_path
|
||||
or surface.entry_path in surface.aliases
|
||||
or surface.route_path in surface.aliases
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths"
|
||||
)
|
||||
if len(set(surface.aliases)) != len(surface.aliases):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} contains duplicate aliases"
|
||||
)
|
||||
route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)}
|
||||
if surface.route_path not in route_paths:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown owner route "
|
||||
f"{surface.route_path!r}"
|
||||
)
|
||||
if not surface.surface_ids:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has no owner surfaces"
|
||||
)
|
||||
unknown_surfaces = set(surface.surface_ids) - known_surface_ids
|
||||
if unknown_surfaces:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown surfaces: "
|
||||
+ ", ".join(sorted(unknown_surfaces))
|
||||
)
|
||||
allowed_presentations = {"task", "reader", "admin", "operator"}
|
||||
if (
|
||||
not surface.presentations
|
||||
or len(set(surface.presentations)) != len(surface.presentations)
|
||||
or set(surface.presentations) - allowed_presentations
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} has invalid presentations"
|
||||
)
|
||||
declared_capabilities = {
|
||||
*manifest.required_capabilities,
|
||||
*manifest.optional_capabilities,
|
||||
*manifest.capability_factories,
|
||||
*(provider.name for provider in manifest.provides_interfaces),
|
||||
*(requirement.name for requirement in manifest.requires_interfaces),
|
||||
}
|
||||
unknown_capabilities = set(surface.capability_ids) - declared_capabilities
|
||||
if unknown_capabilities:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references undeclared capabilities: "
|
||||
+ ", ".join(sorted(unknown_capabilities))
|
||||
)
|
||||
search_source_ids = {source.id for source in manifest.search_sources}
|
||||
unknown_search_sources = set(surface.search_source_ids) - search_source_ids
|
||||
if unknown_search_sources:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown search sources: "
|
||||
+ ", ".join(sorted(unknown_search_sources))
|
||||
)
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
unknown_topics = set(surface.documentation_topic_ids) - set(topics)
|
||||
if unknown_topics:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references unknown documentation topics: "
|
||||
+ ", ".join(sorted(unknown_topics))
|
||||
)
|
||||
documented_help_contexts: set[str] = set()
|
||||
for topic in manifest.documentation:
|
||||
contexts = topic.metadata.get("help_contexts", ())
|
||||
if isinstance(contexts, (list, tuple, set, frozenset)):
|
||||
documented_help_contexts.update(
|
||||
context for context in contexts if isinstance(context, str)
|
||||
)
|
||||
unknown_help = set(surface.help_context_ids) - documented_help_contexts
|
||||
if unknown_help:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface.id!r} references undocumented help contexts: "
|
||||
+ ", ".join(sorted(unknown_help))
|
||||
)
|
||||
_validate_product_availability_explanation(surface.id, surface.unavailable)
|
||||
if surface.degraded is not None:
|
||||
_validate_product_availability_explanation(surface.id, surface.degraded)
|
||||
|
||||
|
||||
def _validate_product_availability_explanation(
|
||||
surface_id: str,
|
||||
explanation: ProductAvailabilityExplanation,
|
||||
) -> None:
|
||||
allowed_reasons = {
|
||||
"authorization",
|
||||
"policy",
|
||||
"configuration",
|
||||
"disabled",
|
||||
"capability",
|
||||
"offline",
|
||||
"provider_degraded",
|
||||
}
|
||||
if explanation.reason not in allowed_reasons:
|
||||
raise RegistryError(
|
||||
f"Product surface {surface_id!r} has an invalid availability reason"
|
||||
)
|
||||
if any(
|
||||
not value.strip()
|
||||
for value in (
|
||||
explanation.title,
|
||||
explanation.description,
|
||||
explanation.resolution,
|
||||
)
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Product surface {surface_id!r} has an incomplete availability explanation"
|
||||
)
|
||||
|
||||
|
||||
def _validate_quick_access_tool(
|
||||
module_id: str,
|
||||
tool: QuickAccessTool,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
@@ -13,6 +14,9 @@ from govoplan_core.security.outbound_http import (
|
||||
|
||||
|
||||
MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES = 1_000_000
|
||||
_STANDARD_REDIRECT_SENSITIVE_HEADERS = frozenset(
|
||||
{"authorization", "proxy-authorization", "cookie", "cookie2"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -51,6 +55,7 @@ def fetch_http(
|
||||
headers: Mapping[str, str] | None = None,
|
||||
body: bytes | None = None,
|
||||
max_bytes: int | None = None,
|
||||
redirect_sensitive_headers: Iterable[str] = (),
|
||||
) -> HttpFetchResponse:
|
||||
if body is not None and len(body) > MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES:
|
||||
raise ValueError(
|
||||
@@ -63,7 +68,12 @@ def fetch_http(
|
||||
headers=dict(headers or {}),
|
||||
method=method,
|
||||
)
|
||||
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
|
||||
opener = build_outbound_http_opener(
|
||||
_PolicyRedirectHandler(
|
||||
label=label,
|
||||
sensitive_headers=redirect_sensitive_headers,
|
||||
)
|
||||
)
|
||||
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
response_headers = dict(response.headers.items())
|
||||
return HttpFetchResponse(
|
||||
@@ -88,6 +98,7 @@ def fetch_http_text(
|
||||
body: bytes | None = None,
|
||||
encoding: str = "utf-8",
|
||||
max_bytes: int | None = None,
|
||||
redirect_sensitive_headers: Iterable[str] = (),
|
||||
) -> str:
|
||||
return fetch_http(
|
||||
url,
|
||||
@@ -97,13 +108,22 @@ def fetch_http_text(
|
||||
headers=headers,
|
||||
body=body,
|
||||
max_bytes=max_bytes,
|
||||
redirect_sensitive_headers=redirect_sensitive_headers,
|
||||
).text(encoding)
|
||||
|
||||
|
||||
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, *, label: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
sensitive_headers: Iterable[str] = (),
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._label = label
|
||||
self._sensitive_headers = _STANDARD_REDIRECT_SENSITIVE_HEADERS | {
|
||||
value.strip().lower() for value in sensitive_headers if value.strip()
|
||||
}
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
|
||||
@@ -113,8 +133,9 @@ class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
return None
|
||||
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
|
||||
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
|
||||
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
|
||||
new_request.remove_header(header)
|
||||
for header in tuple(new_request.headers) + tuple(new_request.unredirected_hdrs):
|
||||
if header.lower() in self._sensitive_headers:
|
||||
new_request.remove_header(header)
|
||||
return new_request
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ from govoplan_core.core.modules import (
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||
@@ -254,6 +256,47 @@ def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _product_availability_payload(
|
||||
explanation: ProductAvailabilityExplanation,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"reason": explanation.reason,
|
||||
"title": explanation.title,
|
||||
"description": explanation.description,
|
||||
"resolution": explanation.resolution,
|
||||
"responsible_role": explanation.responsible_role,
|
||||
}
|
||||
|
||||
|
||||
def _product_surface_payload(surface: ProductSurfaceContribution) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": surface.contract_version,
|
||||
"id": surface.id,
|
||||
"module_id": surface.module_id,
|
||||
"label": surface.label,
|
||||
"description": surface.description,
|
||||
"icon": surface.icon,
|
||||
"entry_path": surface.entry_path,
|
||||
"route_path": surface.route_path,
|
||||
"surface_ids": list(surface.surface_ids),
|
||||
"presentations": list(surface.presentations),
|
||||
"capability_ids": list(surface.capability_ids),
|
||||
"search_source_ids": list(surface.search_source_ids),
|
||||
"help_context_ids": list(surface.help_context_ids),
|
||||
"documentation_topic_ids": list(surface.documentation_topic_ids),
|
||||
"required_all": list(surface.required_all),
|
||||
"required_any": list(surface.required_any),
|
||||
"aliases": list(surface.aliases),
|
||||
"order": surface.order,
|
||||
"unavailable": _product_availability_payload(surface.unavailable),
|
||||
"degraded": (
|
||||
_product_availability_payload(surface.degraded)
|
||||
if surface.degraded is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
||||
return {
|
||||
"id": tool.id,
|
||||
@@ -372,6 +415,10 @@ def _frontend_payload(
|
||||
"product_areas": [
|
||||
_product_area_payload(area) for area in frontend.product_areas
|
||||
],
|
||||
"product_surfaces": [
|
||||
_product_surface_payload(surface)
|
||||
for surface in frontend.product_surfaces
|
||||
],
|
||||
"quick_access_tools": [
|
||||
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
||||
],
|
||||
|
||||
@@ -3,13 +3,22 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationApplyResult,
|
||||
ConfigurationExportResult,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationModuleRequirement,
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPackageEvidence,
|
||||
ConfigurationPackageManifest,
|
||||
ConfigurationPackageParent,
|
||||
ConfigurationPlanItem,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationPreflightResult,
|
||||
ConfigurationProviderExpectation,
|
||||
ConfigurationRequiredData,
|
||||
apply_configuration_package,
|
||||
dry_run_configuration_package,
|
||||
export_configuration_package,
|
||||
validate_configuration_package_derivation,
|
||||
)
|
||||
|
||||
@@ -27,6 +36,121 @@ def _evidence(*kinds: str) -> tuple[ConfigurationPackageEvidence, ...]:
|
||||
|
||||
|
||||
class ConfigurationPackageArchitectureTests(unittest.TestCase):
|
||||
def test_deployment_data_references_are_declared_resolved_and_never_exported(self) -> None:
|
||||
class Provider:
|
||||
module_id = "forms"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.preflight_payloads: list[dict[str, object]] = []
|
||||
|
||||
def describe(self):
|
||||
from govoplan_core.core.configuration_packages import ConfigurationProviderDescription
|
||||
|
||||
return ConfigurationProviderDescription(
|
||||
module_id=self.module_id,
|
||||
fragment_types=("definition",),
|
||||
)
|
||||
|
||||
def preflight(self, fragment, context):
|
||||
del context
|
||||
self.preflight_payloads.append(dict(fragment.payload))
|
||||
return ConfigurationPreflightResult(plan=(ConfigurationPlanItem(
|
||||
action="create",
|
||||
module_id=self.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
),))
|
||||
|
||||
def apply(self, fragment, supplied_data, context):
|
||||
del supplied_data, context
|
||||
return ConfigurationApplyResult(
|
||||
created_refs={fragment.fragment_id or "definition": "form:resident-parking"}
|
||||
)
|
||||
|
||||
def export(self, selection, context):
|
||||
del selection, context
|
||||
return ConfigurationExportResult(
|
||||
fragments=(ConfigurationPackageFragment(
|
||||
module_id=self.module_id,
|
||||
fragment_type="definition",
|
||||
payload={"name": "Resident parking permit"},
|
||||
),),
|
||||
data_requirements=(ConfigurationRequiredData(
|
||||
key="payment_credential_ref",
|
||||
label="Payment credential reference",
|
||||
secret=True,
|
||||
),),
|
||||
)
|
||||
|
||||
def health(self, import_result, context):
|
||||
del import_result, context
|
||||
return ()
|
||||
|
||||
provider = Provider()
|
||||
package = ConfigurationPackageManifest(
|
||||
package_id="product.resident-parking",
|
||||
name="Resident parking permit",
|
||||
version="1.0.0",
|
||||
required_modules=(ConfigurationModuleRequirement("forms"),),
|
||||
data_requirements=({
|
||||
"key": "service_name",
|
||||
"label": "Public service name",
|
||||
},),
|
||||
fragments=(ConfigurationPackageFragment(
|
||||
module_id="forms",
|
||||
fragment_type="definition",
|
||||
fragment_id="resident-parking",
|
||||
payload={
|
||||
"definition": {
|
||||
"title": {"$data": "service_name"},
|
||||
}
|
||||
},
|
||||
),),
|
||||
)
|
||||
missing_context = ConfigurationPreflightContext(
|
||||
installed_modules={"forms": "0.1.0"},
|
||||
)
|
||||
|
||||
missing = dry_run_configuration_package(package, (provider,), missing_context)
|
||||
|
||||
self.assertEqual([], provider.preflight_payloads)
|
||||
self.assertIn(
|
||||
"fragment_data_reference_missing",
|
||||
{item.code for item in missing.diagnostics},
|
||||
)
|
||||
|
||||
ready_context = ConfigurationPreflightContext(
|
||||
installed_modules={"forms": "0.1.0"},
|
||||
supplied_data={"service_name": "Anwohnerparkausweis"},
|
||||
operator_user_id="operator-1",
|
||||
)
|
||||
ready = dry_run_configuration_package(package, (provider,), ready_context)
|
||||
applied = apply_configuration_package(package, (provider,), ready_context)
|
||||
exported = export_configuration_package(
|
||||
(provider,),
|
||||
ConfigurationExportSelection(
|
||||
tenant_id="tenant-1",
|
||||
module_ids=("forms",),
|
||||
),
|
||||
ready_context,
|
||||
)
|
||||
|
||||
self.assertFalse(any(item.severity == "blocker" for item in ready.diagnostics))
|
||||
self.assertEqual(
|
||||
"Anwohnerparkausweis",
|
||||
provider.preflight_payloads[-1]["definition"]["title"], # type: ignore[index]
|
||||
)
|
||||
self.assertIsNotNone(applied.rollback)
|
||||
assert applied.rollback is not None
|
||||
self.assertEqual("database_restore_required", applied.rollback.status)
|
||||
self.assertIsNotNone(exported.provenance)
|
||||
assert exported.provenance is not None
|
||||
self.assertEqual("operator-1", exported.provenance.exporter_id)
|
||||
self.assertEqual(
|
||||
("payment_credential_ref",),
|
||||
exported.provenance.redacted_secret_keys,
|
||||
)
|
||||
|
||||
def test_legacy_package_defaults_to_product_and_round_trips(self) -> None:
|
||||
package = ConfigurationPackageManifest.from_mapping(
|
||||
{"package_id": "example", "name": "Example", "version": "1.0.0"}
|
||||
|
||||
@@ -13,6 +13,7 @@ from govoplan_core.core.datasources import (
|
||||
DatasourceArtifactBackendProvider,
|
||||
DatasourceDescriptor,
|
||||
DatasourceField,
|
||||
DatasourceGovernance,
|
||||
DatasourceLifecycleProvider,
|
||||
DatasourceMaterialization,
|
||||
DatasourceOrigin,
|
||||
@@ -212,6 +213,55 @@ class DatasourceContractTests(unittest.TestCase):
|
||||
self.assertEqual("upload", descriptor.kind)
|
||||
self.assertEqual("tabular", descriptor.shape)
|
||||
|
||||
def test_lifecycle_governance_round_trips_without_provider_specific_types(self) -> None:
|
||||
governance = DatasourceGovernance.from_mapping(
|
||||
{
|
||||
"approval_policy": {
|
||||
"version": "approval-v2",
|
||||
"required": True,
|
||||
"required_approvals": 2,
|
||||
},
|
||||
"retention_policy": {
|
||||
"version": "retention-v3",
|
||||
"enabled": True,
|
||||
"stage_days": 30,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("approval-v2", governance.approval_policy["version"])
|
||||
self.assertEqual(30, governance.retention_policy["stage_days"])
|
||||
self.assertEqual(
|
||||
governance.approval_policy,
|
||||
governance.to_dict()["approval_policy"],
|
||||
)
|
||||
self.assertEqual(
|
||||
governance.retention_policy,
|
||||
governance.to_dict()["retention_policy"],
|
||||
)
|
||||
|
||||
stage = DatasourceStage(
|
||||
ref="stage:governed",
|
||||
name="Governed stage",
|
||||
source_name="governed",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
state="awaiting_approval",
|
||||
approval={"status": "pending", "policy_version": "approval-v2"},
|
||||
)
|
||||
materialization = DatasourceMaterialization(
|
||||
ref="materialization:disposed",
|
||||
datasource_ref="datasource:governed",
|
||||
revision=1,
|
||||
state="disposed",
|
||||
fingerprint="abc123",
|
||||
disposition={"reason": "retention_policy", "policy_version": "retention-v3"},
|
||||
)
|
||||
|
||||
self.assertEqual("pending", stage.approval["status"])
|
||||
self.assertEqual("retention_policy", materialization.disposition["reason"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -10,7 +10,9 @@ from govoplan_core.core.modules import (
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
localized_documentation_metadata,
|
||||
user_workflow_scope_condition_issues,
|
||||
with_documentation_structured_translations,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
@@ -83,6 +85,97 @@ class DocumentationTopicContractTests(unittest.TestCase):
|
||||
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||
|
||||
def test_versioned_structured_translation_preserves_metadata_shape(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.workflow.localized",
|
||||
title="Run task",
|
||||
summary="Run the task.",
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"steps": ["Review", "Execute"],
|
||||
"verification": "Confirm the result.",
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"steps": ["Prüfen", "Ausführen"],
|
||||
"verification": "Das Ergebnis bestätigen.",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
registry_for(topic).validate()
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
localized_documentation_metadata(topic, "de")["steps"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"workflow", localized_documentation_metadata(topic, "de")["kind"]
|
||||
)
|
||||
|
||||
def test_structured_translation_requires_version_and_complete_shape(self) -> None:
|
||||
missing_version = DocumentationTopic(
|
||||
id="example.localized.missing-version",
|
||||
title="Localized",
|
||||
summary="Invalid contract.",
|
||||
metadata={"limitations": ["One", "Two"]},
|
||||
structured_translations={"de": {"limitations": ["Eins", "Zwei"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RegistryError, "require structured_translation_version"
|
||||
):
|
||||
registry_for(missing_version).validate()
|
||||
|
||||
incomplete_shape = DocumentationTopic(
|
||||
id="example.localized.incomplete",
|
||||
title="Localized",
|
||||
summary="Invalid shape.",
|
||||
metadata={"limitations": ["One", "Two"]},
|
||||
structured_translation_version="1",
|
||||
structured_translations={"de": {"limitations": ["Eins"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "preserve list length"):
|
||||
registry_for(incomplete_shape).validate()
|
||||
|
||||
def test_manifest_helper_merges_and_validates_owner_translations(self) -> None:
|
||||
topic = DocumentationTopic(
|
||||
id="example.workflow.localized",
|
||||
title="Run task",
|
||||
summary="Run the task.",
|
||||
metadata={"steps": ["Review", "Execute"]},
|
||||
)
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=(topic,),
|
||||
)
|
||||
|
||||
localized = with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={
|
||||
topic.id: {"steps": ["Prüfen", "Ausführen"]},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["Prüfen", "Ausführen"],
|
||||
localized.documentation[0].structured_translations["de"]["steps"],
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "unknown topic ids"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={"missing.topic": {"steps": ["Prüfen", "Ausführen"]}},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "preserve list length"):
|
||||
with_documentation_structured_translations(
|
||||
manifest,
|
||||
locale="de",
|
||||
translations={topic.id: {"steps": ["Prüfen"]}},
|
||||
)
|
||||
|
||||
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
|
||||
resolver = lambda _context, keys: { # noqa: E731
|
||||
key: DocumentationConfigurationDecision(key=key, state="enabled")
|
||||
|
||||
@@ -239,9 +239,17 @@ class HttpFetchTests(unittest.TestCase):
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://catalog.example.test/releases",
|
||||
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
|
||||
headers={
|
||||
"Authorization": "Bearer secret",
|
||||
"Cookie": "session=secret",
|
||||
"X-OTRS-Header-Password": "secret",
|
||||
"X-Request-ID": "request-1",
|
||||
},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(
|
||||
label="Catalog URL",
|
||||
sensitive_headers=("X-OTRS-Header-Password",),
|
||||
)
|
||||
handler = _PolicyRedirectHandler(label="Catalog URL")
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
@@ -265,9 +273,38 @@ class HttpFetchTests(unittest.TestCase):
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertIsNone(redirected.get_header("Authorization"))
|
||||
self.assertIsNone(redirected.get_header("Cookie"))
|
||||
self.assertIsNone(redirected.get_header("X-otrs-header-password"))
|
||||
self.assertEqual("request-1", redirected.get_header("X-request-id"))
|
||||
self.assertIsNone(downgrade)
|
||||
|
||||
def test_core_redirects_preserve_caller_sensitive_headers_on_the_same_origin(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://desk.example.test/original",
|
||||
headers={"X-OTRS-Header-SessionID": "secret"},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(
|
||||
label="Service-desk URL",
|
||||
sensitive_headers=("X-OTRS-Header-SessionID",),
|
||||
)
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
):
|
||||
redirected = handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://desk.example.test/final",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertEqual("secret", redirected.get_header("X-otrs-header-sessionid"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -10,7 +11,9 @@ from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessTool,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
@@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest:
|
||||
surface_ids=("example.route.main",),
|
||||
),
|
||||
),
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id="work.examples",
|
||||
module_id="example",
|
||||
label="Examples",
|
||||
description="Review and update governed examples.",
|
||||
icon="list-checks",
|
||||
entry_path="/work/examples",
|
||||
route_path="/example",
|
||||
surface_ids=("example.route.main",),
|
||||
presentations=("task", "reader"),
|
||||
unavailable=ProductAvailabilityExplanation(
|
||||
reason="authorization",
|
||||
title="Examples are unavailable",
|
||||
description="Your current responsibility does not include examples.",
|
||||
resolution="Ask the responsible administrator to review your assignment.",
|
||||
responsible_role="Access administrator",
|
||||
),
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="example.summary",
|
||||
@@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase):
|
||||
frontend = response.json()["modules"][0]["frontend"]
|
||||
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
||||
product_surface = frontend["product_surfaces"][0]
|
||||
self.assertEqual("1", product_surface["contract_version"])
|
||||
self.assertEqual("work.examples", product_surface["id"])
|
||||
self.assertEqual("/work/examples", product_surface["entry_path"])
|
||||
self.assertEqual("/example", product_surface["route_path"])
|
||||
self.assertEqual(["task", "reader"], product_surface["presentations"])
|
||||
self.assertEqual(
|
||||
"authorization",
|
||||
product_surface["unavailable"]["reason"],
|
||||
)
|
||||
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
||||
self.assertEqual(
|
||||
@@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase):
|
||||
frontend["quick_access_tools"][0]["help_context_id"],
|
||||
)
|
||||
|
||||
def test_registry_rejects_product_surface_without_owner_route(self) -> None:
|
||||
manifest = presentation_manifest()
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
surface = frontend.product_surfaces[0]
|
||||
invalid = ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
frontend=FrontendModule(
|
||||
module_id=manifest.id,
|
||||
routes=frontend.routes,
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id=surface.id,
|
||||
module_id=surface.module_id,
|
||||
label=surface.label,
|
||||
description=surface.description,
|
||||
icon=surface.icon,
|
||||
entry_path=surface.entry_path,
|
||||
route_path="/missing",
|
||||
surface_ids=surface.surface_ids,
|
||||
unavailable=surface.unavailable,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(invalid)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "unknown owner route"):
|
||||
registry.validate()
|
||||
|
||||
def test_registry_rejects_product_alias_that_shadows_a_route(self) -> None:
|
||||
manifest = presentation_manifest()
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
surface = frontend.product_surfaces[0]
|
||||
invalid = replace(
|
||||
manifest,
|
||||
frontend=replace(
|
||||
frontend,
|
||||
routes=(
|
||||
*frontend.routes,
|
||||
FrontendRoute(path="/shortcut", component="ShortcutPage"),
|
||||
),
|
||||
product_surfaces=(replace(surface, aliases=("/shortcut",)),),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(invalid)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "collides with a concrete route"):
|
||||
registry.validate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileText, GitBranch, Inbox, 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";
|
||||
import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage";
|
||||
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
|
||||
import ActionToolbar from "../src/components/ActionToolbar";
|
||||
import Button from "../src/components/Button";
|
||||
@@ -40,6 +43,16 @@ export default function ConformanceApp() {
|
||||
const [editorDirty, setEditorDirty] = useState(true);
|
||||
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||
|
||||
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 (
|
||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||
<PageLayout
|
||||
@@ -290,6 +303,16 @@ const CONFORMANCE_AUTH = {
|
||||
groups_loaded: true
|
||||
} satisfies AuthInfo;
|
||||
|
||||
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 = {
|
||||
apiBaseUrl: "",
|
||||
apiKey: "",
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
// Narrow facade used only by the conformance build. It lets the optional
|
||||
// Quick Access module exercise its real rail without pulling the composed
|
||||
// application's generated module catalogue into this isolated test bundle.
|
||||
export { apiFetch } from "../src/api/client";
|
||||
// Narrow facade used only by the conformance build. It lets optional modules
|
||||
// exercise their real task surfaces without pulling the composed application's
|
||||
// generated module catalogue into this isolated test bundle.
|
||||
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 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 LoadingFrame } from "../src/components/LoadingFrame";
|
||||
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
export { usePlatformLanguage } from "../src/i18n/LanguageContext";
|
||||
export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
|
||||
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 {
|
||||
dispatchQuickAccessResult,
|
||||
quickAccessLaunchState
|
||||
} 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 {
|
||||
ApiSettings,
|
||||
PlatformRouteContext,
|
||||
PlatformTranslations,
|
||||
QuickAccessRailProps,
|
||||
QuickAccessToolsUiCapability
|
||||
} from "../src/types";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import ConformanceApp from "./ConformanceApp";
|
||||
import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations";
|
||||
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
@@ -14,6 +15,7 @@ import "../src/styles/badges.css";
|
||||
import "../src/styles/components.css";
|
||||
import "../src/styles/dialogs.css";
|
||||
import "@govoplan/quick-access-webui/styles/quick-access.css";
|
||||
import "../../../govoplan-forms-runtime/webui/src/styles/forms-runtime.css";
|
||||
import "./conformance.css";
|
||||
|
||||
const theme = new URLSearchParams(window.location.search).get("theme");
|
||||
@@ -35,9 +37,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
|
||||
<PlatformLanguageProvider preferredLanguageCode="de">
|
||||
<PlatformLanguageProvider
|
||||
preferredLanguageCode="de"
|
||||
moduleTranslations={[formsRuntimeTranslations]}>
|
||||
<UnsavedChangesProvider>
|
||||
<ConformanceApp />
|
||||
<Routes>
|
||||
<Route path="/forms/public/:publicId" element={<ConformanceApp />} />
|
||||
<Route path="/forms-runtime/:instanceId" element={<ConformanceApp />} />
|
||||
<Route path="*" element={<ConformanceApp />} />
|
||||
</Routes>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>
|
||||
|
||||
@@ -19,6 +19,125 @@ async function expectNoAccessibilityViolations(page: import("@playwright/test").
|
||||
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 }) => {
|
||||
await page.goto("/?theme=light");
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
|
||||
@@ -229,6 +348,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 });
|
||||
});
|
||||
|
||||
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) {
|
||||
const files = {
|
||||
id: "files",
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.41",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"../../govoplan-access/webui": {
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
@@ -120,12 +120,12 @@
|
||||
},
|
||||
"../../govoplan-admin/webui": {
|
||||
"name": "@govoplan/admin-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.35",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -243,7 +243,7 @@
|
||||
},
|
||||
"../../govoplan-connectors/webui": {
|
||||
"name": "@govoplan/connectors-webui",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.22",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"react": ">=19.2.7 <20",
|
||||
@@ -378,7 +378,7 @@
|
||||
},
|
||||
"../../govoplan-forms-runtime/webui": {
|
||||
"name": "@govoplan/forms-runtime-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -394,7 +394,7 @@
|
||||
},
|
||||
"../../govoplan-forms/webui": {
|
||||
"name": "@govoplan/forms-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -475,7 +475,7 @@
|
||||
},
|
||||
"../../govoplan-mail/webui": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.22",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
|
||||
+191
-191
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.41",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.41",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.19",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18",
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23",
|
||||
"@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/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.18",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.22",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22",
|
||||
"@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.18",
|
||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.18",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.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.23",
|
||||
"@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.19",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.18",
|
||||
"@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/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.18",
|
||||
"@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/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.20",
|
||||
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.20",
|
||||
"@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",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
@@ -757,8 +757,8 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@govoplan/access-webui": {
|
||||
"version": "0.1.19",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#2d1b1e356ecb8726219d4a502db78e61f435a88f",
|
||||
"version": "0.1.23",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#e55434f406e953a2fa9e881abb8fb6dccbabd812",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -773,10 +773,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/admin-webui": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#218f94fa23a2b1386ac89c001d9d69d155934ef0",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#3b450640690c5f1e123db4095663c7d5287873bb",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.35",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -805,8 +805,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/calendar-webui": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#089d07b60a99f76f2ef6d72de8c33fd263bc7bac",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3792e9ab8a10ff6e301c59df7a3fa47d5ab14952",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -824,8 +824,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/campaign-webui": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#4f52f010ee6a117b7b3ad605a59b1da0a459a0c6",
|
||||
"version": "0.1.27",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#1b32427813b435fd47c92c95242d6029741743ec",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
@@ -875,8 +875,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/docs-webui": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#30ea95854b9c65cb5d0fdd1d281a07ad1330e290",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#9055f3437f0296f15f63a6fc89ff65f133a7bfaf",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -894,8 +894,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/files-webui": {
|
||||
"version": "0.1.20",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#378f4d6ac5525d0f07f38eefce893bfa2924f2b9",
|
||||
"version": "0.1.23",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#11b9b7c4c6d919b9f6649034445edfea0c77d042",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -929,8 +929,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/idm-webui": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#b0eda351957526bc7e59270c7a42016aa12bc1a3",
|
||||
"version": "0.1.24",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#5c7586f6d9a0d0b3e8d36ab47dca586e0a34a498",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -948,8 +948,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/mail-webui": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#62721d204d946fa06160a5437d51de53428e1222",
|
||||
"version": "0.1.25",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#ecc1283de76f154919c786cb84354afb8f2299c1",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -983,8 +983,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/organizations-webui": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#30fcdbe8327ca56f6561b00a04ecca4572fcb69e",
|
||||
"version": "0.1.20",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#b23e6e4eeedd17d322744a765a473b5f77c08b61",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -1018,8 +1018,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/tickets-webui": {
|
||||
"version": "0.1.20",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#c6c643778bc73d0504e9f1ef5b710ca98436ae41",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#0ad2ef96b43a83ea7e0e9188487392db735b4a85",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.30",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -1034,8 +1034,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@govoplan/wiki-webui": {
|
||||
"version": "0.1.20",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#66c91351c9eb693ace606c9b69dd5cd804d7531b",
|
||||
"version": "0.1.22",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#705255f378edf7643cd84fe5692cf2a0378b91a2",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.31",
|
||||
"lucide-react": "^1.23.0",
|
||||
@@ -1484,49 +1484,49 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.2.tgz",
|
||||
"integrity": "sha512-QbZC/s1OOqcoUdkhIY16TjR/gCtR0qAk9e4bJwUqOJqZuv5ozqCL5hzWm22jjTPp6c6Ei2tPd6t30VwfIKW4lQ==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.3.tgz",
|
||||
"integrity": "sha512-kDD8KY99lBCKntCqTBE9eNR1ul/i/wPFw2METWT+LYZvifljXq2oiX6JaGF1Sk59efe7+sq9IISxOk47YlBiWQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.30.2.tgz",
|
||||
"integrity": "sha512-BOkwhZenek7vzXBOgKppSrlx4YryBdAYu1p1MXKn0R9A9eNmE2HVhmm0gG49+E8BhsE/TG8wKVclwET42JJiIg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.30.3.tgz",
|
||||
"integrity": "sha512-Dh8yfEqBKTqEdBKZ4Ta3DkTecP0VJxvRNA5b1LCF7gBbe54Lm3A28UuVvK/Sdhh9FJDrIVCQTDS0yHGbi6G9ew==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.30.2.tgz",
|
||||
"integrity": "sha512-MsvJhPgYejY2D9MhwYJv8AmscozvLBI8qtJ7YLdYZBWkMR4bgmxHq5+xqEfBsao9bOMMwBon9p3+P+/Tq5ReWA==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.30.3.tgz",
|
||||
"integrity": "sha512-a4BSAjWRN4mWklRTdDCIaG9R+PqyUxXNYGQ2CLtOhO51Wq6daw2qFTHR+cTqAjaiiUSuDYII86UChb2qQRq8Lg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.30.2.tgz",
|
||||
"integrity": "sha512-oS0WiWNXHKpiPYMkcnHm1j7iEvufTGGtLFtcJJd3olb5OS6V2acoXVDL0nNJDmRQ32K3QXut3fbRcMseMxT+lw==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.30.3.tgz",
|
||||
"integrity": "sha512-YryCf9fq+9n1XIW6f5weSCD2MbL/LhRQ7jArRToQQ/oNxc422LqYVjijQQTNuk2cZEqE9fdO3Hw27tmwEEs9UQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1537,80 +1537,80 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.2.tgz",
|
||||
"integrity": "sha512-+awIL/TUz4aB3rL68igU1rWfaaoBIAkPcakkktkRq8gYf0bd9eSb48P6kHkpx/3q+JyK7g9vsnltLNHNh6twnA==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.3.tgz",
|
||||
"integrity": "sha512-Z/ZqUfrd3Fd8hHpHEPv3XRzqwqVLD4CP67xFMyjy0cqEgnjgvd1iR6QzJsKD79V9zyu0Agu7ab9ZBf4NktpWcw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
"@tiptap/extension-list": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.30.2.tgz",
|
||||
"integrity": "sha512-r8EZk3R9yGpF6v5xxafAU1HwrD/e+RpbfnmVi2TeB/ZHAsVO62fW96E32G0t6IdaCtOFtAd85hkDAaOfCT4yGg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.30.3.tgz",
|
||||
"integrity": "sha512-mOV4Fg+ji6uXmFxTMuug9WK6zk0ksNQepnuvoDR4g4d+tnz2y9iEbgOXFohVxhOXOLDLdNeKdwHxbHQdi5Ivbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.30.2.tgz",
|
||||
"integrity": "sha512-9otGKaQZmePHrLXFtCtz+BYDn5z4sSumTkUqQIQHz0gVxwPoTi7g51RedwxvViTb/zu2XV5ROXYLHIxKxypMPg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.30.3.tgz",
|
||||
"integrity": "sha512-xT2cDil/ipy/LklPgv/JqSsXys1mjcqlIPDUetK3llB2DT7XZssYPV3qv278tqRO4pJDbrXk82I6Lh7vLwmIAg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.30.2.tgz",
|
||||
"integrity": "sha512-+xIv67V+/2L1uvz98FAT5W7kWEfHwfNV3MD7b4UsKPU0lhcCWuVOXy0JB8yYmdNExqpI7xT9g3MWzREoBvBQSg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.30.3.tgz",
|
||||
"integrity": "sha512-B9gqrgM1uHjCKr/PSnnSl+bS+YQb5EN/PUW9KWp9nSgmIaqxXws2ufqowS65uTLJLqd7pwF4l5lUrsfhq0EKzw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.30.2.tgz",
|
||||
"integrity": "sha512-nyRKUmItATnKI9AiRChmjhcBbCEsNxRu+AaCz+cx8EvnAcNHsVRdNYL5PmBs3WlNA/Et4Eb2DG0hVQDnNF61eg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.30.3.tgz",
|
||||
"integrity": "sha512-xdY66lcQakBLvWbJQHeJy/Td0f10YRXLyFeOFzt9PODsX4nqwJMju9tpqcLIGp0waaztu8PGwZ81oSCdQldIHQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.30.2"
|
||||
"@tiptap/extensions": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.30.2.tgz",
|
||||
"integrity": "sha512-A8PLvvh8W6PUMrqh+EpBerxm+Ucr0irGxJvwAnzYQmNGNIJ9U4OVgw4OcEU+9JH0gMmEzDcHzMPP2s/s1lIcyw==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.30.3.tgz",
|
||||
"integrity": "sha512-4l5Qee1wBk2YBFEFbWHFhUt++6hDY7Yp81BkO3wY6KFToxs+cm9JIm3xr3KF9UJ/vnUpIhBWIbZl3uKf37SJLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
@@ -1619,93 +1619,93 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.30.2.tgz",
|
||||
"integrity": "sha512-7Xk0ut6FM+RAsvKxDN3bAtk7zvYZ6Aa8pawJ6s7dLAmLR9JwrZevlcL4FSrj4bR7rqKOj92RYCFxzgTEbWimag==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.30.3.tgz",
|
||||
"integrity": "sha512-+mwm64+RiArd1G8xSgtaTrvx1X5Lzz6sJ93zTmaiMVNc2N29AN77BVPTJ0e+853+/F6Bec/vOnVkIjTLg/ZBRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.30.2"
|
||||
"@tiptap/extensions": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.30.2.tgz",
|
||||
"integrity": "sha512-IxSNgmG3d4OZdUTeebrOI7SxdIWXXJqlcGiSNDabWqxipUitfy3mZ3gDDE6G01koKxZRbhz4KIplAZlpxnTFSg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.30.3.tgz",
|
||||
"integrity": "sha512-/tX+IFW2C4RJltbwcn8/4zoHwj5YycRNOugb4Ul9Fl1AOJqjNwRx8xc0NpaOjuADhlMYMczEtSaOiDh5ZEOmjg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.30.2.tgz",
|
||||
"integrity": "sha512-PblDvgSJ05p1t6hzyPi02xeiBjB0M2abReoGEImqSWCy79UqnAGacgsZo4EeEawtJV1NEP8chhvmX+nRtzdT1A==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.30.3.tgz",
|
||||
"integrity": "sha512-y+0u9qdrUAOGNrP52hAEiH1mvr1ZmzmkCBICICiXRH4+QNXpKkiBK3zlbDOPVQj0g0YIg6XIIU0vIaUBDPIaMQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.30.2.tgz",
|
||||
"integrity": "sha512-j8aswLTsuEdJKC62DF+kw0EgvIRL7QMUyAVp2fdjR0qgM0ZVlEwCC4qIEq3kK9tFVU4kRtQ5BSj/jn6QwrlbCA==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.30.3.tgz",
|
||||
"integrity": "sha512-J/ioKlXu5oJ+pPtybFonByx1LusbR0PUd1nkx6XsZqY9wyW6uecZQgAyI8KUWIXqOfhnAW38UTsHMBRdm2XupA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.30.2.tgz",
|
||||
"integrity": "sha512-K/BPlWauHXI6Y4s7se2A1BLcZ2pnWmRQTEDkPJYe/8kmv1GyJzPu34sZed5Mvfu5chUH8u6aNZ/utZbvSbI/0Q==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.30.3.tgz",
|
||||
"integrity": "sha512-vbKMliRLXkABwnPenPlQDEVp3MFN7Hs+WUh1eliVmYfsBfhEo596ahahv+JVoR9JW6HSM+LVdf2TT19Iqmh0sg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.30.2.tgz",
|
||||
"integrity": "sha512-pp8uaiuXsUbLm5rYzR1jlWbwm1mAahRajdHwAKBtthFRB2rDvC7ZWhKaCSoKhZvfIDRmu9/B67+uAHoutL0dCA==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.30.3.tgz",
|
||||
"integrity": "sha512-dVWqJ/kDXdPHRBp4nU7bwi2G+c3oNruIgQHYVRgYlWkwW/opvnlACZLBUgEhihH7ANiSaGW1zLQ7HuUDSL3vog==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.30.2.tgz",
|
||||
"integrity": "sha512-jwdcymKcrbFpj5hRAuGVLCq8FieVkGFnENyroYmvkad+XAt8ZLy/MTFYRN6SK3ukH6PZMY7H4iObGtciQaC5nw==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.30.3.tgz",
|
||||
"integrity": "sha512-MAAXfLJNf6ZFiFK3w51yiLKc31HAQWJWNV+nQ1dAdRpa6WiXdjpkcrWTuxmPd/o6Qt+yNv+hoItAVxDPEox8qQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
@@ -1715,133 +1715,133 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.30.2.tgz",
|
||||
"integrity": "sha512-MIUpo1Bd9Rf1Qg+TNYNwDZ4xsfFeQahjU9Xhy6UcaszKQzbAM7KCzn5BObNytK1NdcqNHsC8Wj5vFvMKEzrXdw==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.30.3.tgz",
|
||||
"integrity": "sha512-cCX99WoVb4UyXkamSh0vlXiRFhlZnwk3WXWdnXpR1E9+auxsQewk2Q9NDW1Ra0vwgzer1gkFhlOl9TCVzH2VVg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.30.2.tgz",
|
||||
"integrity": "sha512-HWgRCRlGxulE+hN1VUcnWD6P2NE08VBgGtcaxOfdXVqaI93BCK6AhRQZGpLsfKgajLk+5DXTBraaitwnBqzCxg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.30.3.tgz",
|
||||
"integrity": "sha512-RhSyDlAATvXDTg/1VOw5aFTaewdCm+BAU3EG3eUCw4e5eoZ5pbhPOdOPTtaO45UR4i/AvjQNAZQ9LMDWX5WZFQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
"@tiptap/extension-list": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.2.tgz",
|
||||
"integrity": "sha512-TTve3WOlQaYu1ahMqsQ/T0wzaxfgZvcOl3/OuPyInOi8QtxXhqGhFjmYe5jOr56G9W2QDuFWVsZecVwfDte9zg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.3.tgz",
|
||||
"integrity": "sha512-u6tOpQFnE1pOyvnzI2MvhW/Yw0omAIMlMfkykYx4N4zB1JDi+16OeOuKf3rX6G3V9VUu+qeOIGpSkifkME6H9Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
"@tiptap/extension-list": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.2.tgz",
|
||||
"integrity": "sha512-Z7OO1HcF0idda1n6vodXeQ3h2ylN9JR4IfIGUYkar5Xl9JusK8PDETTBQZQn//96p49I2d+GoWsD2LXPtjHXXg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.3.tgz",
|
||||
"integrity": "sha512-lct/FV8vm9Y5kZFcF5TXIM18opi6m7AAkGoChgGP/A7LYalaniX1X6Ja2j13Y22ZdGaOoPTgd8oKRZXP1/miaA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
"@tiptap/extension-list": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.30.2.tgz",
|
||||
"integrity": "sha512-ulEu3LNt+kPVAWEnrhoz13Fs8Q/v/8NUxQbAeteuBchQ8joxJXuWExhpy1fUfZir5+b+W5z7/NesgPjZQfv47w==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.30.3.tgz",
|
||||
"integrity": "sha512-Z9OHCX9b2bDcMDeR3ODCFRAyHT5eNGO4AsDFeAJS/XSitffMyjFHtOkXlyyZ3+pIHxxX6xkUXAwaLMjyATRo1A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.30.2.tgz",
|
||||
"integrity": "sha512-fBLxMXz6hYIURzLOD+/L6aVATztsKham00ANWmGi13vN0hx2lQMYZffN+gR+QqiCDfMQxBXzrf5a7tJuDiQHLQ==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.30.3.tgz",
|
||||
"integrity": "sha512-e9BI8Hzei23GjmYysXxL+CHq3fIgOm29BbSUMbYXxT2cJ+TkkgJp7EvHf74gSABNME0gUN+mG0pRR5wTFoOCcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.30.2.tgz",
|
||||
"integrity": "sha512-n/iZnirgRmXet6f97kolAnP3j8DsgLSiTbz/KLWc8eBYiFmkjRzkuisOm5xuGdfGIxwpB4x3tlSF4ef4DLnbRg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.30.3.tgz",
|
||||
"integrity": "sha512-Jh8ZyI0HLOJOIPZl5a5XRJbTs4pAUakYIWCZ3Hv3lF9ouXW/C0bY67XQU7bv6Rvu8AHzDszhKklnOZ6z1NQPXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.30.2.tgz",
|
||||
"integrity": "sha512-SZiTMnvqXcnrtJX+X25ZbYsuDO83haGOVMBD/O+mAWYNYXhaSc5Rkph5czzItxrd+Yyp/vs4PiwD7XTNbfqmpA==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.30.3.tgz",
|
||||
"integrity": "sha512-TiZ+b524Ee4PNlW+HNmkg3x4btR1K6btJy9pEjMuDf0Rx01UaTBvHpHmABR/DbbcC1CuZiQ3ylJwLL+90PaEoA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2"
|
||||
"@tiptap/core": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.30.2.tgz",
|
||||
"integrity": "sha512-2LqAHXk26QDsryW+beECxYeBzv5Ylk4GuB3cOmfghS7/G37R2W+Te3TkUK7BT0EWoDryvBT57/5q0DEFIhfZZg==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.30.3.tgz",
|
||||
"integrity": "sha512-vig8ZjUL/NFnniEVbYDYj2UOIdkwZVha+9myFZkanShedviE3kl7c4uCY3fMl5EnqgyFIy2TQSKbg/i99xpztA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.2.tgz",
|
||||
"integrity": "sha512-BJN8tUx4ppFN3R3cV/FJfrJbJkvo1lj4uciq+nwpjwzdRvFzqIuglWf+HLcJ6CwlYpLOHp7ArgkBg4Q5e60Gog==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.3.tgz",
|
||||
"integrity": "sha512-VheaqLAFUe+PCYEgHubM96Z1OAiluFhijQ2Cy1Ghiozoxm5OzevSaKhqm8SQ1VGfzZZko59oUjRzOmu8tB8yfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
@@ -1864,9 +1864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.30.2.tgz",
|
||||
"integrity": "sha512-7hGaTstpUeTmQ008mCPkjz+GSlChWhucgy+PeX0z93v4+nh7qM5F+0lh+kJ9zo6Os5abO7v36GtgHRZdQI6+FQ==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.30.3.tgz",
|
||||
"integrity": "sha512-gyK8UXFQlm2XgXEynNz5SzsxnTKtdBUp/PKeTeqFrk/m7L8p1eV+gcdAK+arTX+slubXRwLUm1lXrAQXRuiaTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
@@ -1878,12 +1878,12 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.30.2",
|
||||
"@tiptap/extension-floating-menu": "^3.30.2"
|
||||
"@tiptap/extension-bubble-menu": "^3.30.3",
|
||||
"@tiptap/extension-floating-menu": "^3.30.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2",
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
@@ -1891,35 +1891,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.30.2.tgz",
|
||||
"integrity": "sha512-fJSrhW1CyD4sjYA20evSP4Cp13B/HhbxCdM974K0xpHOVqvCtNU9w2s9hfq9mg2yGoU7MSNHKNYMkJjIi2/Xyw==",
|
||||
"version": "3.30.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.30.3.tgz",
|
||||
"integrity": "sha512-hj4rAhAoQm+wRk8eLNTyK+wK81+epbuo4O9PvPRv7GJGwYycej+xUN7jGM7Tney7GuTZpHoDCuwLKD2n1oG+pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/extension-blockquote": "3.30.2",
|
||||
"@tiptap/extension-bold": "3.30.2",
|
||||
"@tiptap/extension-bullet-list": "3.30.2",
|
||||
"@tiptap/extension-code": "3.30.2",
|
||||
"@tiptap/extension-code-block": "3.30.2",
|
||||
"@tiptap/extension-document": "3.30.2",
|
||||
"@tiptap/extension-dropcursor": "3.30.2",
|
||||
"@tiptap/extension-gapcursor": "3.30.2",
|
||||
"@tiptap/extension-hard-break": "3.30.2",
|
||||
"@tiptap/extension-heading": "3.30.2",
|
||||
"@tiptap/extension-horizontal-rule": "3.30.2",
|
||||
"@tiptap/extension-italic": "3.30.2",
|
||||
"@tiptap/extension-link": "3.30.2",
|
||||
"@tiptap/extension-list": "3.30.2",
|
||||
"@tiptap/extension-list-item": "3.30.2",
|
||||
"@tiptap/extension-list-keymap": "3.30.2",
|
||||
"@tiptap/extension-ordered-list": "3.30.2",
|
||||
"@tiptap/extension-paragraph": "3.30.2",
|
||||
"@tiptap/extension-strike": "3.30.2",
|
||||
"@tiptap/extension-text": "3.30.2",
|
||||
"@tiptap/extension-underline": "3.30.2",
|
||||
"@tiptap/extensions": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
"@tiptap/core": "3.30.3",
|
||||
"@tiptap/extension-blockquote": "3.30.3",
|
||||
"@tiptap/extension-bold": "3.30.3",
|
||||
"@tiptap/extension-bullet-list": "3.30.3",
|
||||
"@tiptap/extension-code": "3.30.3",
|
||||
"@tiptap/extension-code-block": "3.30.3",
|
||||
"@tiptap/extension-document": "3.30.3",
|
||||
"@tiptap/extension-dropcursor": "3.30.3",
|
||||
"@tiptap/extension-gapcursor": "3.30.3",
|
||||
"@tiptap/extension-hard-break": "3.30.3",
|
||||
"@tiptap/extension-heading": "3.30.3",
|
||||
"@tiptap/extension-horizontal-rule": "3.30.3",
|
||||
"@tiptap/extension-italic": "3.30.3",
|
||||
"@tiptap/extension-link": "3.30.3",
|
||||
"@tiptap/extension-list": "3.30.3",
|
||||
"@tiptap/extension-list-item": "3.30.3",
|
||||
"@tiptap/extension-list-keymap": "3.30.3",
|
||||
"@tiptap/extension-ordered-list": "3.30.3",
|
||||
"@tiptap/extension-paragraph": "3.30.3",
|
||||
"@tiptap/extension-strike": "3.30.3",
|
||||
"@tiptap/extension-text": "3.30.3",
|
||||
"@tiptap/extension-underline": "3.30.3",
|
||||
"@tiptap/extensions": "3.30.3",
|
||||
"@tiptap/pm": "3.30.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -1983,9 +1983,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2027,9 +2027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.17",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz",
|
||||
"integrity": "sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==",
|
||||
"version": "2.11.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
|
||||
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -42,7 +42,7 @@
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
|
||||
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
||||
"test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/layout-primitives.test.js",
|
||||
"test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && printf 'module.exports = {};\\n' > .component-test-build/src/components/ProductAvailabilityState.css && node .component-test-build/tests/layout-primitives.test.js",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js",
|
||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -26,23 +26,23 @@
|
||||
"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.19",
|
||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18",
|
||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23",
|
||||
"@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/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.18",
|
||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22",
|
||||
"@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/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.18",
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.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.23",
|
||||
"@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.19",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.18",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.22",
|
||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.18",
|
||||
"@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/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/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18",
|
||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.18",
|
||||
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.20",
|
||||
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.20",
|
||||
"@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",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-image": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
|
||||
@@ -15,12 +15,14 @@ const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true })
|
||||
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
|
||||
.filter((sourceRoot) => fs.existsSync(sourceRoot));
|
||||
|
||||
const generatedCatalogs = sourceRoots
|
||||
.map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts"))
|
||||
.filter((file) => fs.existsSync(file));
|
||||
const generatedCatalogs = sourceRoots.flatMap((sourceRoot) =>
|
||||
fs.existsSync(path.join(sourceRoot, "i18n"))
|
||||
? rgFiles(path.join(sourceRoot, "i18n")).filter((file) => /Translations\.ts$/.test(file))
|
||||
: []
|
||||
);
|
||||
|
||||
function scanStructuralSourcePositions(roots) {
|
||||
const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !file.endsWith("/i18n/generatedTranslations.ts")));
|
||||
const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !/\/i18n\/[^/]*Translations\.ts$/.test(file)));
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
|
||||
|
||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||
compact_tables: false,
|
||||
@@ -579,7 +580,7 @@ export default function App() {
|
||||
|
||||
)}
|
||||
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
||||
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
||||
<Route path="*" element={<ProductSurfaceRoute auth={auth} />} />
|
||||
</Routes>
|
||||
</ModuleLoadBoundary>
|
||||
{reloginMessage &&
|
||||
|
||||
@@ -35,7 +35,7 @@ export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, stri
|
||||
|
||||
export type MailConnectionTestResponse = {
|
||||
ok: boolean;
|
||||
protocol: "smtp" | "imap";
|
||||
protocol: "smtp" | "imap" | "jmap" | "pop3";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
@@ -52,7 +52,7 @@ export type MailImapFolderResponse = {
|
||||
|
||||
export type MailImapFolderListResponse = {
|
||||
ok: boolean;
|
||||
protocol: "imap";
|
||||
protocol: "imap" | "jmap";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
@@ -69,6 +69,7 @@ export type MailImapFolderListResponse = {
|
||||
export const mailProfilePatternKeys = [
|
||||
"smtp_hosts",
|
||||
"imap_hosts",
|
||||
"jmap_hosts",
|
||||
"envelope_senders",
|
||||
"from_headers",
|
||||
"recipient_domains"
|
||||
@@ -82,11 +83,13 @@ export const mailProfilePolicyLimitKeys = [
|
||||
"imap_credentials.inherit",
|
||||
"whitelist.smtp_hosts",
|
||||
"whitelist.imap_hosts",
|
||||
"whitelist.jmap_hosts",
|
||||
"whitelist.envelope_senders",
|
||||
"whitelist.from_headers",
|
||||
"whitelist.recipient_domains",
|
||||
"blacklist.smtp_hosts",
|
||||
"blacklist.imap_hosts",
|
||||
"blacklist.jmap_hosts",
|
||||
"blacklist.envelope_senders",
|
||||
"blacklist.from_headers",
|
||||
"blacklist.recipient_domains"
|
||||
|
||||
@@ -207,6 +207,7 @@ export default function HoverTooltip({
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={className}
|
||||
role={ariaLabel ? "button" : undefined}
|
||||
tabIndex={triggerTabIndex}
|
||||
aria-label={translatedAriaLabel}
|
||||
aria-describedby={isOpen ? tooltipId : undefined}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Dice5, Eye, EyeOff } from "lucide-react";
|
||||
import PasswordGeneratorDialog from "./PasswordGeneratorDialog";
|
||||
import type { PasswordGeneratorOptions } from "./passwordGenerator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
|
||||
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & PlatformInterfaceIdentityProps & {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
saved?: boolean;
|
||||
@@ -32,6 +33,10 @@ export default function PasswordField({
|
||||
className = "",
|
||||
inputClassName = "",
|
||||
id,
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
helpModuleId,
|
||||
helpTopicId,
|
||||
...inputProps
|
||||
}: PasswordFieldProps) {
|
||||
const generatedId = useId();
|
||||
@@ -50,10 +55,20 @@ export default function PasswordField({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`password-field ${canReveal || canGenerate ? "has-actions" : ""} ${canGenerate ? "has-generator" : ""} ${canReveal ? "has-reveal" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}>
|
||||
<div
|
||||
className={`password-field ${canReveal || canGenerate ? "has-actions" : ""} ${canGenerate ? "has-generator" : ""} ${canReveal ? "has-reveal" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
id={inputId}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
className={inputClassName}
|
||||
type={inputType}
|
||||
value={value}
|
||||
@@ -72,6 +87,9 @@ export default function PasswordField({
|
||||
className="password-field-action"
|
||||
aria-label={translatedGeneratorLabel}
|
||||
title={translatedGeneratorLabel}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
onClick={() => setGeneratorOpen(true)}
|
||||
>
|
||||
<Dice5 size={17} aria-hidden="true" />
|
||||
@@ -83,6 +101,9 @@ export default function PasswordField({
|
||||
className="password-field-action"
|
||||
aria-label={visible ? translatedHideLabel : translatedRevealLabel}
|
||||
title={visible ? translatedHideLabel : translatedRevealLabel}
|
||||
data-help-context-id={helpContextId}
|
||||
data-help-module-id={helpModuleId}
|
||||
data-help-topic-id={helpTopicId}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
>
|
||||
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
|
||||
@@ -95,6 +116,9 @@ export default function PasswordField({
|
||||
<PasswordGeneratorDialog
|
||||
open={generatorOpen}
|
||||
initialOptions={generatorOptions}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
onUse={(password) => {
|
||||
onValueChange(password);
|
||||
setVisible(false);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type PasswordGeneratorOptions
|
||||
} from "./passwordGenerator";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||
|
||||
const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = {
|
||||
"invalid-length": "i18n:govoplan-core.password_length_must_be_between_12_and_128_character.4d147c07",
|
||||
@@ -23,7 +24,7 @@ const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = {
|
||||
"secure-random-unavailable": "i18n:govoplan-core.secure_browser_password_generation_is_unavailable.55275f10"
|
||||
};
|
||||
|
||||
export type PasswordGeneratorDialogProps = {
|
||||
export type PasswordGeneratorDialogProps = PlatformInterfaceIdentityProps & {
|
||||
open: boolean;
|
||||
initialOptions?: Partial<PasswordGeneratorOptions>;
|
||||
onUse: (password: string) => void;
|
||||
@@ -33,6 +34,9 @@ export type PasswordGeneratorDialogProps = {
|
||||
export default function PasswordGeneratorDialog({
|
||||
open,
|
||||
initialOptions,
|
||||
helpContextId,
|
||||
helpModuleId,
|
||||
helpTopicId,
|
||||
onUse,
|
||||
onClose
|
||||
}: PasswordGeneratorDialogProps) {
|
||||
@@ -95,6 +99,9 @@ export default function PasswordGeneratorDialog({
|
||||
bodyClassName="password-generator-body"
|
||||
footerClassName="button-row compact-actions"
|
||||
portal
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
@@ -103,6 +110,9 @@ export default function PasswordGeneratorDialog({
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!candidate}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
onClick={() => {
|
||||
if (!candidate) return;
|
||||
onUse(candidate);
|
||||
@@ -116,7 +126,7 @@ export default function PasswordGeneratorDialog({
|
||||
>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<div className="password-generator-options">
|
||||
<FormField label="i18n:govoplan-core.length.adc95605">
|
||||
<FormField label="i18n:govoplan-core.length.adc95605" helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId}>
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
@@ -127,13 +137,13 @@ export default function PasswordGeneratorDialog({
|
||||
/>
|
||||
</FormField>
|
||||
<div className="password-generator-character-sets" aria-label={translateText("i18n:govoplan-core.character_sets.db6efda2")}>
|
||||
<ToggleSwitch label="i18n:govoplan-core.lowercase.3b677a18" checked={options.lowercase} onChange={(checked) => setOption("lowercase", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.uppercase.b463d690" checked={options.uppercase} onChange={(checked) => setOption("uppercase", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.digits.9cd500d3" checked={options.digits} onChange={(checked) => setOption("digits", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.symbols.9491fc41" checked={options.symbols} onChange={(checked) => setOption("symbols", checked)} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.lowercase.3b677a18" checked={options.lowercase} onChange={(checked) => setOption("lowercase", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.uppercase.b463d690" checked={options.uppercase} onChange={(checked) => setOption("uppercase", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.digits.9cd500d3" checked={options.digits} onChange={(checked) => setOption("digits", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
<ToggleSwitch label="i18n:govoplan-core.symbols.9491fc41" checked={options.symbols} onChange={(checked) => setOption("symbols", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="i18n:govoplan-core.generated_password.78461854">
|
||||
<FormField label="i18n:govoplan-core.generated_password.78461854" helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId}>
|
||||
<div className="password-generator-result">
|
||||
<input
|
||||
type="text"
|
||||
@@ -147,11 +157,17 @@ export default function PasswordGeneratorDialog({
|
||||
icon={<Copy size={16} />}
|
||||
onClick={() => void copy()}
|
||||
disabled={!candidate || typeof navigator === "undefined" || !navigator.clipboard?.writeText}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
/>
|
||||
<IconButton
|
||||
label="i18n:govoplan-core.generate_another_password.d99fc019"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={generate}
|
||||
helpContextId={helpContextId}
|
||||
helpModuleId={helpModuleId}
|
||||
helpTopicId={helpTopicId}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.product-availability-resolution,
|
||||
.product-availability-owner { margin: 0; }
|
||||
.product-availability-technical { width: min(100%, 560px); margin-top: 4px; color: var(--muted); text-align: start; }
|
||||
.product-availability-technical summary { cursor: pointer; color: var(--text); font-weight: 700; }
|
||||
.product-availability-technical dl { display: grid; gap: 6px; margin: 10px 0 0; }
|
||||
.product-availability-technical dl > div { display: grid; grid-template-columns: minmax(110px, .4fr) minmax(0, 1fr); gap: 10px; }
|
||||
.product-availability-technical dt { color: var(--muted); font-weight: 700; }
|
||||
.product-availability-technical dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--text); font-family: var(--font-mono, monospace); font-size: 12px; }
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CircleOff, TriangleAlert } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type { ProductAvailabilityExplanation } from "../types";
|
||||
import StatePanel, { type StatePanelProps } from "./StatePanel";
|
||||
import "./ProductAvailabilityState.css";
|
||||
|
||||
export type ProductTechnicalProvenance = {
|
||||
moduleId?: string | null;
|
||||
capabilityId?: string | null;
|
||||
providerId?: string | null;
|
||||
correlationId?: string | null;
|
||||
};
|
||||
|
||||
export type ProductAvailabilityStateProps = {
|
||||
state: "unavailable" | "degraded";
|
||||
explanation: ProductAvailabilityExplanation;
|
||||
actions?: ReactNode;
|
||||
technical?: ProductTechnicalProvenance | null;
|
||||
size?: StatePanelProps["size"];
|
||||
surface?: StatePanelProps["surface"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ProductAvailabilityState({
|
||||
state,
|
||||
explanation,
|
||||
actions,
|
||||
technical,
|
||||
size = "default",
|
||||
surface = "subtle",
|
||||
className = ""
|
||||
}: ProductAvailabilityStateProps) {
|
||||
const { language, translateText } = usePlatformLanguage();
|
||||
const labels = AVAILABILITY_LABELS[language.split("-", 1)[0] === "de" ? "de" : "en"];
|
||||
const title = translateText(explanation.title);
|
||||
const description = translateText(explanation.description);
|
||||
const resolution = translateText(explanation.resolution);
|
||||
const responsibleRole = explanation.responsibleRole
|
||||
? translateText(explanation.responsibleRole)
|
||||
: null;
|
||||
const technicalEntries = technical ? Object.entries(technical).filter((entry) => Boolean(entry[1])) : [];
|
||||
|
||||
return (
|
||||
<StatePanel
|
||||
aria-live="polite"
|
||||
className={["product-availability-state", `product-availability-${state}`, className].filter(Boolean).join(" ")}
|
||||
icon={state === "degraded" ? <TriangleAlert size={24} /> : <CircleOff size={24} />}
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
size={size}
|
||||
surface={surface}
|
||||
tone="warning"
|
||||
>
|
||||
<p className="product-availability-resolution">{resolution}</p>
|
||||
{responsibleRole ? (
|
||||
<p className="product-availability-owner">
|
||||
<strong>{labels.responsibleRole}: </strong>
|
||||
{responsibleRole}
|
||||
</p>
|
||||
) : null}
|
||||
{technicalEntries.length ? (
|
||||
<details className="product-availability-technical">
|
||||
<summary>{labels.technicalDetails}</summary>
|
||||
<dl>
|
||||
{technicalEntries.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{technicalLabel(key, labels)}</dt>
|
||||
<dd>{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
) : null}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
type AvailabilityLabels = {
|
||||
responsibleRole: string;
|
||||
technicalDetails: string;
|
||||
module: string;
|
||||
capability: string;
|
||||
provider: string;
|
||||
correlationId: string;
|
||||
};
|
||||
|
||||
const AVAILABILITY_LABELS: Record<"en" | "de", AvailabilityLabels> = {
|
||||
en: {
|
||||
responsibleRole: "Responsible role",
|
||||
technicalDetails: "Technical details",
|
||||
module: "Module",
|
||||
capability: "Capability",
|
||||
provider: "Provider",
|
||||
correlationId: "Correlation ID"
|
||||
},
|
||||
de: {
|
||||
responsibleRole: "Zuständige Rolle",
|
||||
technicalDetails: "Technische Details",
|
||||
module: "Modul",
|
||||
capability: "Fähigkeit",
|
||||
provider: "Anbieter",
|
||||
correlationId: "Korrelations-ID"
|
||||
}
|
||||
};
|
||||
|
||||
function technicalLabel(key: string, labels: AvailabilityLabels): string {
|
||||
if (key === "moduleId") return labels.module;
|
||||
if (key === "capabilityId") return labels.capability;
|
||||
if (key === "providerId") return labels.provider;
|
||||
if (key === "correlationId") return labels.correlationId;
|
||||
return key;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { lazy, useEffect, useMemo } from "react";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
import type { AuthInfo } from "../types";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
import { firstAccessibleRoute } from "../platform/modules";
|
||||
import {
|
||||
availableProductSurfaceContributors,
|
||||
composeProductSurfaces,
|
||||
dispatchProductSurfaceRouteResolved
|
||||
} from "../platform/productSurfaces";
|
||||
import { useEffectiveView } from "../platform/ViewContext";
|
||||
|
||||
const ProductAvailabilityState = lazy(() => import("./ProductAvailabilityState"));
|
||||
|
||||
export default function ProductSurfaceRoute({
|
||||
auth
|
||||
}: {
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const modules = usePlatformModules();
|
||||
const projection = useEffectiveView();
|
||||
const surface = useMemo(
|
||||
() => composeProductSurfaces(modules).find((candidate) =>
|
||||
candidate.entryPath === location.pathname || candidate.aliases.includes(location.pathname)
|
||||
) ?? null,
|
||||
[location.pathname, modules]
|
||||
);
|
||||
const contributors = useMemo(
|
||||
() => surface ? availableProductSurfaceContributors(surface, auth, modules, projection) : [],
|
||||
[auth, modules, projection, surface]
|
||||
);
|
||||
const target = contributors[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!surface || !target) return;
|
||||
dispatchProductSurfaceRouteResolved({
|
||||
contractVersion: "1",
|
||||
productSurfaceId: surface.id,
|
||||
requestedPath: location.pathname,
|
||||
targetPath: target.routePath,
|
||||
contributorModuleId: target.moduleId,
|
||||
usedAlias: location.pathname !== surface.entryPath
|
||||
});
|
||||
}, [location.pathname, surface, target]);
|
||||
|
||||
if (target) {
|
||||
return <Navigate to={`${target.routePath}${location.search}${location.hash}`} replace />;
|
||||
}
|
||||
|
||||
if (!surface) {
|
||||
return <Navigate to={firstAccessibleRoute(auth, modules, projection)} replace />;
|
||||
}
|
||||
|
||||
const explanation = surface.contributors[0]?.unavailable;
|
||||
return explanation ? (
|
||||
<ProductAvailabilityState
|
||||
state="unavailable"
|
||||
explanation={explanation}
|
||||
size="fill"
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export default function InlineHelp({ children, className = "" }: InlineHelpProps
|
||||
content={children}
|
||||
className={`inline-help ${className}`.trim()}
|
||||
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
|
||||
triggerTabIndex={-1}>
|
||||
triggerTabIndex={0}>
|
||||
<span className="inline-help-mark" aria-hidden="true">?</span>
|
||||
</HoverTooltip>
|
||||
);
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function LoginModal({
|
||||
<FormLayout columns={1} collapseAt="standard" id={formId} className="" onSubmit={submit}>
|
||||
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2">
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
<FormField label="i18n:govoplan-core.email.84add5b2" helpContextId="access.authentication.email" helpModuleId="access">
|
||||
<input data-help-context-id="access.authentication.email" data-help-module-id="access" type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943">
|
||||
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
<FormField label="i18n:govoplan-core.password.8be3c943" helpContextId="access.authentication.password" helpModuleId="access">
|
||||
<PasswordField helpContextId="access.authentication.password" helpModuleId="access" value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
</FormField>
|
||||
</FormLayout>
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,8 +572,10 @@ export default function SettingsPage({
|
||||
<FormField label="i18n:govoplan-core.api_base_url.1358fba4" help="i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7">
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70">
|
||||
<FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70" helpContextId="access.settings.automation-api-key" helpModuleId="access">
|
||||
<PasswordField
|
||||
helpContextId="access.settings.automation-api-key"
|
||||
helpModuleId="access"
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} />
|
||||
|
||||
@@ -1276,8 +1276,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"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.settings.c7f73bb5": "Einstellungen",
|
||||
"i18n:govoplan-core.show_content.0528d8d2": "Show content",
|
||||
"i18n:govoplan-core.show_field_help.e3dfe98f": "Show field help",
|
||||
"i18n:govoplan-core.show_content.0528d8d2": "Inhalt anzeigen",
|
||||
"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_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.",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { PlatformTranslations } from "../types";
|
||||
|
||||
export const generatedTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-core.product_surface.messages": "Messages",
|
||||
"i18n:govoplan-core.product_surface.messages_description": "Read and act on messages without merging channel custody, policy, or delivery state.",
|
||||
"i18n:govoplan-core.product_surface.messages_unavailable": "Messages are unavailable",
|
||||
"i18n:govoplan-core.product_surface.messages_unavailable_description": "No message source is available for your current responsibility and permissions.",
|
||||
"i18n:govoplan-core.product_surface.messages_unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.",
|
||||
"i18n:govoplan-core.product_surface.messages_degraded": "Messages are temporarily limited",
|
||||
"i18n:govoplan-core.product_surface.messages_degraded_description": "Saved messages remain available, but a channel or provider may not be current.",
|
||||
"i18n:govoplan-core.product_surface.messages_degraded_resolution": "Retry later or ask the integration operator to review provider health.",
|
||||
"i18n:govoplan-core.access_administrator": "Access administrator",
|
||||
"i18n:govoplan-core.integration_operator": "Integration operator"
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-core.product_surface.messages": "Nachrichten",
|
||||
"i18n:govoplan-core.product_surface.messages_description": "Nachrichten lesen und bearbeiten, ohne Verwahrung, Regeln oder Zustellstatus der Kanäle zusammenzuführen.",
|
||||
"i18n:govoplan-core.product_surface.messages_unavailable": "Nachrichten sind nicht verfügbar",
|
||||
"i18n:govoplan-core.product_surface.messages_unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist keine Nachrichtenquelle verfügbar.",
|
||||
"i18n:govoplan-core.product_surface.messages_unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.",
|
||||
"i18n:govoplan-core.product_surface.messages_degraded": "Nachrichten sind vorübergehend eingeschränkt",
|
||||
"i18n:govoplan-core.product_surface.messages_degraded_description": "Gespeicherte Nachrichten bleiben verfügbar, ein Kanal oder Anbieter ist jedoch möglicherweise nicht aktuell.",
|
||||
"i18n:govoplan-core.product_surface.messages_degraded_resolution": "Versuchen Sie es später erneut oder bitten Sie die Integrationsadministration, den Anbieterstatus zu prüfen.",
|
||||
"i18n:govoplan-core.access_administrator": "Zugriffsadministration",
|
||||
"i18n:govoplan-core.integration_operator": "Integrationsadministration"
|
||||
}
|
||||
} satisfies PlatformTranslations;
|
||||
@@ -32,6 +32,8 @@ export * from "./platform/ModuleContext";
|
||||
export * from "./platform/moduleEvents";
|
||||
export * from "./platform/ViewContext";
|
||||
export * from "./platform/views";
|
||||
export * from "./platform/productSurfaces";
|
||||
export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/productSurfaceTranslations";
|
||||
export * from "./platform/temporal";
|
||||
export * from "./platform/TemporalContext";
|
||||
export * from "./platform/ActiveObjectContext";
|
||||
@@ -215,6 +217,8 @@ export { default as SelectionList, SelectionListItem, SelectionListItemContent }
|
||||
export type { SelectionListItemContentProps, SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
|
||||
export { default as StatePanel } from "./components/StatePanel";
|
||||
export type { StatePanelProps, StatePanelSize, StatePanelSurface, StatePanelTone } from "./components/StatePanel";
|
||||
export { default as ProductAvailabilityState } from "./components/ProductAvailabilityState";
|
||||
export type { ProductAvailabilityStateProps, ProductTechnicalProvenance } from "./components/ProductAvailabilityState";
|
||||
export { default as StatusBadge } from "./components/StatusBadge";
|
||||
export { default as StageRail } from "./components/StageRail";
|
||||
export type {
|
||||
|
||||
@@ -237,6 +237,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
viewSurfaces: mergeViewSurfaces(module, info),
|
||||
productAreas: productAreasFromMetadata(info),
|
||||
productSurfaceMetadata: info.frontend?.product_surfaces,
|
||||
quickAccessTools: quickAccessToolsFromMetadata(info),
|
||||
helpContexts: info.help_contexts ?? module.helpContexts,
|
||||
uiCapabilities: {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
AuthInfo,
|
||||
ComposedProductSurface,
|
||||
EffectiveViewProjection,
|
||||
ProductSurfaceMetadata,
|
||||
PlatformWebModule,
|
||||
ProductSurfaceContribution
|
||||
} from "../types";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
import { isViewSurfaceVisible, viewSurfaceCatalogueForModules } from "./views";
|
||||
|
||||
export const PRODUCT_SURFACE_ROUTE_RESOLVED_EVENT = "govoplan:product-surface-route-resolved";
|
||||
|
||||
export type ProductSurfaceRouteResolvedEventDetail = {
|
||||
contractVersion: "1";
|
||||
productSurfaceId: string;
|
||||
requestedPath: string;
|
||||
targetPath: string;
|
||||
contributorModuleId: string;
|
||||
usedAlias: boolean;
|
||||
};
|
||||
|
||||
export function composeProductSurfaces(
|
||||
modules: readonly PlatformWebModule[]
|
||||
): ComposedProductSurface[] {
|
||||
const composed = new Map<string, ComposedProductSurface>();
|
||||
const contributions = modules.flatMap((module) => [
|
||||
...(module.productSurfaces ?? []),
|
||||
...(module.productSurfaceMetadata ?? []).map(productSurfaceFromMetadata)
|
||||
]);
|
||||
for (const contribution of contributions) {
|
||||
const existing = composed.get(contribution.id);
|
||||
if (!existing) {
|
||||
composed.set(contribution.id, {
|
||||
contractVersion: contribution.contractVersion,
|
||||
id: contribution.id,
|
||||
label: contribution.label,
|
||||
description: contribution.description,
|
||||
iconName: contribution.iconName,
|
||||
entryPath: contribution.entryPath,
|
||||
presentations: [...contribution.presentations],
|
||||
contributors: [contribution],
|
||||
aliases: [...contribution.aliases],
|
||||
order: contribution.order
|
||||
});
|
||||
continue;
|
||||
}
|
||||
assertSharedIdentity(existing, contribution);
|
||||
existing.contributors.push(contribution);
|
||||
existing.aliases = [...new Set([...existing.aliases, ...contribution.aliases])];
|
||||
existing.order = Math.min(existing.order, contribution.order);
|
||||
}
|
||||
return [...composed.values()]
|
||||
.map((surface) => ({
|
||||
...surface,
|
||||
contributors: [...surface.contributors].sort(compareContributions),
|
||||
aliases: [...surface.aliases].sort()
|
||||
}))
|
||||
.sort((left, right) => left.order - right.order || left.label.localeCompare(right.label));
|
||||
}
|
||||
|
||||
function productSurfaceFromMetadata(surface: ProductSurfaceMetadata): ProductSurfaceContribution {
|
||||
return {
|
||||
contractVersion: surface.contract_version,
|
||||
id: surface.id,
|
||||
moduleId: surface.module_id,
|
||||
label: surface.label,
|
||||
description: surface.description,
|
||||
iconName: surface.icon,
|
||||
entryPath: surface.entry_path,
|
||||
routePath: surface.route_path,
|
||||
surfaceIds: surface.surface_ids,
|
||||
presentations: surface.presentations,
|
||||
capabilityIds: surface.capability_ids,
|
||||
searchSourceIds: surface.search_source_ids,
|
||||
helpContextIds: surface.help_context_ids,
|
||||
documentationTopicIds: surface.documentation_topic_ids,
|
||||
allOf: surface.required_all,
|
||||
anyOf: surface.required_any,
|
||||
aliases: surface.aliases,
|
||||
order: surface.order,
|
||||
unavailable: {
|
||||
...surface.unavailable,
|
||||
responsibleRole: surface.unavailable.responsible_role
|
||||
},
|
||||
degraded: surface.degraded ? {
|
||||
...surface.degraded,
|
||||
responsibleRole: surface.degraded.responsible_role
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
export function availableProductSurfaceContributors(
|
||||
surface: ComposedProductSurface,
|
||||
auth: AuthInfo | null | undefined,
|
||||
modules: readonly PlatformWebModule[],
|
||||
projection?: EffectiveViewProjection | null
|
||||
): ProductSurfaceContribution[] {
|
||||
const catalogue = viewSurfaceCatalogueForModules([...modules]);
|
||||
return surface.contributors.filter((contribution) => {
|
||||
if (contribution.allOf.length && !contribution.allOf.every((scope) => hasScope(auth, scope))) {
|
||||
return false;
|
||||
}
|
||||
if (contribution.anyOf.length && !hasAnyScope(auth, contribution.anyOf)) {
|
||||
return false;
|
||||
}
|
||||
return contribution.surfaceIds.some((surfaceId) =>
|
||||
isViewSurfaceVisible(projection, surfaceId, catalogue)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function dispatchProductSurfaceRouteResolved(
|
||||
detail: ProductSurfaceRouteResolvedEventDetail
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<ProductSurfaceRouteResolvedEventDetail>(
|
||||
PRODUCT_SURFACE_ROUTE_RESOLVED_EVENT,
|
||||
{ detail }
|
||||
));
|
||||
}
|
||||
|
||||
function assertSharedIdentity(
|
||||
existing: ComposedProductSurface,
|
||||
contribution: ProductSurfaceContribution
|
||||
): void {
|
||||
if (
|
||||
existing.contractVersion !== contribution.contractVersion
|
||||
|| existing.label !== contribution.label
|
||||
|| existing.iconName !== contribution.iconName
|
||||
|| existing.entryPath !== contribution.entryPath
|
||||
|| existing.description !== contribution.description
|
||||
) {
|
||||
throw new Error(`Conflicting product surface identity: ${contribution.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function compareContributions(
|
||||
left: ProductSurfaceContribution,
|
||||
right: ProductSurfaceContribution
|
||||
): number {
|
||||
return left.order - right.order || left.moduleId.localeCompare(right.moduleId);
|
||||
}
|
||||
@@ -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-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-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); }
|
||||
|
||||
+104
-3
@@ -330,6 +330,59 @@ export type ProductAreaContribution = {
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type ProductSurfacePresentation = "task" | "reader" | "admin" | "operator";
|
||||
|
||||
export type ProductAvailabilityReason =
|
||||
| "authorization"
|
||||
| "policy"
|
||||
| "configuration"
|
||||
| "disabled"
|
||||
| "capability"
|
||||
| "offline"
|
||||
| "provider_degraded";
|
||||
|
||||
export type ProductAvailabilityExplanation = {
|
||||
reason: ProductAvailabilityReason;
|
||||
title: string;
|
||||
description: string;
|
||||
resolution: string;
|
||||
responsibleRole?: string | null;
|
||||
};
|
||||
|
||||
export type ProductSurfaceContribution = {
|
||||
contractVersion: "1";
|
||||
id: string;
|
||||
moduleId: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
iconName: PlatformIconName;
|
||||
entryPath: string;
|
||||
routePath: string;
|
||||
surfaceIds: string[];
|
||||
presentations: ProductSurfacePresentation[];
|
||||
capabilityIds: string[];
|
||||
searchSourceIds: string[];
|
||||
helpContextIds: string[];
|
||||
documentationTopicIds: string[];
|
||||
allOf: string[];
|
||||
anyOf: string[];
|
||||
aliases: string[];
|
||||
order: number;
|
||||
unavailable: ProductAvailabilityExplanation;
|
||||
degraded?: ProductAvailabilityExplanation | null;
|
||||
};
|
||||
|
||||
export type ComposedProductSurface = Omit<
|
||||
ProductSurfaceContribution,
|
||||
"moduleId" | "routePath" | "surfaceIds" | "capabilityIds" |
|
||||
"searchSourceIds" | "helpContextIds" | "documentationTopicIds" |
|
||||
"allOf" | "anyOf" | "aliases" | "order" | "unavailable" | "degraded"
|
||||
> & {
|
||||
contributors: ProductSurfaceContribution[];
|
||||
aliases: string[];
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type QuickAccessToolMetadata = {
|
||||
contractVersion: "1";
|
||||
id: string;
|
||||
@@ -507,6 +560,8 @@ export type PlatformWebModule = {
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
viewSurfaces?: PlatformViewSurface[];
|
||||
productAreas?: ProductAreaContribution[];
|
||||
productSurfaces?: ProductSurfaceContribution[];
|
||||
productSurfaceMetadata?: ProductSurfaceMetadata[];
|
||||
quickAccessTools?: QuickAccessToolMetadata[];
|
||||
helpContexts?: PlatformDocumentationHelpContext[];
|
||||
};
|
||||
@@ -847,6 +902,16 @@ export type MailImapTransportSettings = MailTransportSettings & {
|
||||
folder_mappings?: MailImapFolderMappings | null;
|
||||
};
|
||||
|
||||
export type MailJmapTransportSettings = {
|
||||
session_url: string;
|
||||
account_id?: string | null;
|
||||
auth_scheme?: "bearer" | "basic";
|
||||
timeout_seconds?: number | null;
|
||||
max_response_bytes?: number | null;
|
||||
max_body_value_bytes?: number | null;
|
||||
allowed_api_origins?: string[];
|
||||
};
|
||||
|
||||
export type MailServerProfileCredentials = {
|
||||
smtp?: MailTransportCredentials | null;
|
||||
imap?: MailTransportCredentials | null;
|
||||
@@ -883,9 +948,9 @@ export type MailServerEndpoint = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
tenant_id?: string | null;
|
||||
protocol: "smtp" | "imap";
|
||||
protocol: "smtp" | "imap" | "jmap" | "pop3";
|
||||
name: string;
|
||||
config: MailTransportSettings | MailImapTransportSettings;
|
||||
config: MailTransportSettings | MailImapTransportSettings | MailJmapTransportSettings;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
inherit_to_lower_scopes: boolean;
|
||||
@@ -922,7 +987,7 @@ export type MailCredentialPolicy = {
|
||||
allow_override?: boolean | null;
|
||||
};
|
||||
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "jmap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
allowed_profile_ids?: string[] | null;
|
||||
@@ -1245,6 +1310,40 @@ export type PlatformFrontendModuleInfo = {
|
||||
surface_ids: string[];
|
||||
order: number;
|
||||
}>;
|
||||
product_surfaces?: Array<{
|
||||
contract_version: "1";
|
||||
id: string;
|
||||
module_id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
icon: string;
|
||||
entry_path: string;
|
||||
route_path: string;
|
||||
surface_ids: string[];
|
||||
presentations: ProductSurfacePresentation[];
|
||||
capability_ids: string[];
|
||||
search_source_ids: string[];
|
||||
help_context_ids: string[];
|
||||
documentation_topic_ids: string[];
|
||||
required_all: string[];
|
||||
required_any: string[];
|
||||
aliases: string[];
|
||||
order: number;
|
||||
unavailable: {
|
||||
reason: ProductAvailabilityReason;
|
||||
title: string;
|
||||
description: string;
|
||||
resolution: string;
|
||||
responsible_role?: string | null;
|
||||
};
|
||||
degraded?: {
|
||||
reason: ProductAvailabilityReason;
|
||||
title: string;
|
||||
description: string;
|
||||
resolution: string;
|
||||
responsible_role?: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
quick_access_tools?: Array<{
|
||||
id: string;
|
||||
module_id: string;
|
||||
@@ -1267,6 +1366,8 @@ export type PlatformFrontendModuleInfo = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ProductSurfaceMetadata = NonNullable<PlatformFrontendModuleInfo["product_surfaces"]>[number];
|
||||
|
||||
export type PlatformDocumentationHelpContext = {
|
||||
id: string;
|
||||
topic_id: string;
|
||||
|
||||
@@ -19,6 +19,7 @@ import MetricGrid from "../src/components/MetricGrid";
|
||||
import PageActionBar from "../src/components/PageActionBar";
|
||||
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
||||
import StatePanel from "../src/components/StatePanel";
|
||||
import ProductAvailabilityState from "../src/components/ProductAvailabilityState";
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
@@ -183,6 +184,27 @@ assert(workspaceMarkup.includes("selection-list-navigation"), "resource navigati
|
||||
assert(workspaceMarkup.includes("selection-list-item-content"), "selection-list copy owns title and description typography");
|
||||
assert(workspaceMarkup.includes("state-panel-size-fill"), "whole-surface states share sizing and action anatomy");
|
||||
|
||||
const availabilityMarkup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<ProductAvailabilityState
|
||||
state="degraded"
|
||||
explanation={{
|
||||
reason: "provider_degraded",
|
||||
title: "Messages are delayed",
|
||||
description: "Saved messages remain available, but new provider results may be delayed.",
|
||||
resolution: "Retry later or contact the integration operator.",
|
||||
responsibleRole: "Integration operator"
|
||||
}}
|
||||
technical={{ moduleId: "mail", providerId: "smtp-primary", correlationId: "event-1" }}
|
||||
actions={<button type="button">Retry</button>}
|
||||
/>
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(availabilityMarkup.includes("product-availability-degraded"), "product availability uses one semantic state primitive");
|
||||
assert(availabilityMarkup.includes("Retry later or contact the integration operator."), "availability states include an actionable recovery path");
|
||||
assert(availabilityMarkup.includes("<details"), "technical provenance remains available on demand");
|
||||
assert(availabilityMarkup.includes("smtp-primary"), "technical details preserve exact provider provenance");
|
||||
|
||||
const frameMarkup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider><WorkspaceFrame as="main" height="viewport" label="Planning workspace" surface="panel"><span>Body</span></WorkspaceFrame></PlatformLanguageProvider>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
visibleRoutesForProjection
|
||||
} from "../src/platform/views";
|
||||
import { groupNavigationItems } from "../src/platform/productAreas";
|
||||
import { composeProductSurfaces } from "../src/platform/productSurfaces";
|
||||
import { hasAnyScope, scopeGrants } from "../src/utils/permissions";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
@@ -193,6 +194,72 @@ assert(
|
||||
"flat navigation should retain every authorized destination"
|
||||
);
|
||||
|
||||
const productSurfaceExplanation = {
|
||||
reason: "authorization" as const,
|
||||
title: "Messages are unavailable",
|
||||
description: "No message source is available for the current responsibility.",
|
||||
resolution: "Ask the responsible administrator to review the assignment."
|
||||
};
|
||||
const messageSurfaceModules: PlatformWebModule[] = [
|
||||
{
|
||||
id: "mail",
|
||||
label: "Mail",
|
||||
version: "test",
|
||||
productSurfaces: [{
|
||||
contractVersion: "1",
|
||||
id: "communication.messages",
|
||||
moduleId: "mail",
|
||||
label: "Messages",
|
||||
description: "Read messages without merging channel custody.",
|
||||
iconName: "mail",
|
||||
entryPath: "/messages",
|
||||
routePath: "/mail",
|
||||
surfaceIds: ["mail.route.mail"],
|
||||
presentations: ["task", "reader"],
|
||||
capabilityIds: [],
|
||||
searchSourceIds: ["mail.mailbox_messages"],
|
||||
helpContextIds: ["mail.quick_access.messages"],
|
||||
documentationTopicIds: ["mail.quick-access-and-product-area"],
|
||||
allOf: [],
|
||||
anyOf: ["mail:mailbox:read"],
|
||||
aliases: ["/inbox"],
|
||||
order: 10,
|
||||
unavailable: productSurfaceExplanation
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: "postbox",
|
||||
label: "Postbox",
|
||||
version: "test",
|
||||
productSurfaceMetadata: [{
|
||||
contract_version: "1",
|
||||
id: "communication.messages",
|
||||
module_id: "postbox",
|
||||
label: "Messages",
|
||||
description: "Read messages without merging channel custody.",
|
||||
icon: "mail",
|
||||
entry_path: "/messages",
|
||||
route_path: "/postbox",
|
||||
surface_ids: ["postbox.route.postbox"],
|
||||
presentations: ["task", "reader"],
|
||||
capability_ids: [],
|
||||
search_source_ids: ["postbox.messages"],
|
||||
help_context_ids: ["postbox.quick_access.messages"],
|
||||
documentation_topic_ids: ["postbox.quick-access-and-product-area"],
|
||||
required_all: [],
|
||||
required_any: ["postbox:message:read"],
|
||||
aliases: ["/inbox"],
|
||||
order: 20,
|
||||
unavailable: productSurfaceExplanation
|
||||
}]
|
||||
}
|
||||
];
|
||||
const composedMessages = composeProductSurfaces(messageSurfaceModules);
|
||||
assert(composedMessages.length === 1, "related owner routes should compose into one product identity");
|
||||
assert(composedMessages[0]?.entryPath === "/messages", "the composed identity should keep its stable entry path");
|
||||
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 viewAwareFiles: PlatformWebModule = {
|
||||
...files,
|
||||
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
|
||||
|
||||
@@ -4,6 +4,7 @@ function assert(condition: unknown, message = "assertion failed"): asserts condi
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import PasswordGeneratorDialog from "../src/components/PasswordGeneratorDialog";
|
||||
import {
|
||||
DEFAULT_PASSWORD_GENERATOR_OPTIONS,
|
||||
generateSecurePassword,
|
||||
@@ -56,11 +57,27 @@ for (const [options, expected] of [
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<PasswordField value="" onValueChange={() => undefined} generator />
|
||||
<PasswordField value="" onValueChange={() => undefined} generator helpContextId="access.authentication.password" helpModuleId="access" />
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(markup.includes('aria-label="Generate password"'), "the opt-in generator action is accessible");
|
||||
assert(markup.includes("lucide-dice-5"), "the familiar generator icon is used");
|
||||
assert(!markup.includes("password-generator-dialog"), "the generator dialog stays closed until explicitly requested");
|
||||
assert(markup.includes('data-help-context-id="access.authentication.password"'), "the owner context reaches the password field and its actions");
|
||||
assert(markup.includes('data-help-module-id="access"'), "the password field retains its documentation owner");
|
||||
|
||||
const dialogMarkup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<PasswordGeneratorDialog
|
||||
open
|
||||
helpContextId="access.authentication.password"
|
||||
helpModuleId="access"
|
||||
onUse={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(dialogMarkup.includes('data-help-context-id="access.authentication.password"'), "the generator dialog inherits the calling credential context");
|
||||
assert(dialogMarkup.includes('data-help-module-id="access"'), "generated-password controls retain the credential owner's module");
|
||||
|
||||
console.log("Password generator contract passed.");
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"tests/definition-graph.test.ts",
|
||||
"src/platform/moduleLogic.ts",
|
||||
"src/platform/productAreas.ts",
|
||||
"src/platform/productSurfaces.ts",
|
||||
"src/platform/launchContext.ts",
|
||||
"src/utils/helpContext.ts",
|
||||
"src/features/privacy/policyLogic.ts",
|
||||
|
||||
@@ -214,6 +214,10 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: deferredVendorChunk,
|
||||
// Dynamic chunk names are implementation details. Keeping only the
|
||||
// content hash avoids shipping every source/module name in Vite's
|
||||
// preload table, which is part of the initial application payload.
|
||||
chunkFileNames: "assets/c-[hash].js",
|
||||
// Keep dependencies of deferred BPMN packages in their lazy graph. The
|
||||
// legacy Rollup behavior merged those dependencies into manual chunks
|
||||
// and hoisted the properties-panel runtime into the application entry.
|
||||
|
||||
Reference in New Issue
Block a user