Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5a69034a | ||
|
|
d2a19248c4 | ||
|
|
14808037fb | ||
|
|
e0326d3394 | ||
|
|
91f5a332a8 | ||
|
|
6f671f60f5 | ||
|
|
ed0357b146 | ||
|
|
89a142ead1 | ||
|
|
a15f599756 | ||
|
|
df1a7d3bb1 | ||
|
|
ca30928380 | ||
|
|
fea8e4b5e6 | ||
|
|
1bc367a454 |
@@ -33,4 +33,48 @@ replay-safe. A missing owner launcher makes the entry explainably unavailable.
|
|||||||
Form launch resolves an exact published `<form-id>/<revision>` and returns the
|
Form launch resolves an exact published `<form-id>/<revision>` and returns the
|
||||||
owner's Form-instance route rather than persisting values in Portal.
|
owner's Form-instance route rather than persisting values in Portal.
|
||||||
|
|
||||||
|
The public `/portal/status/:trackingId` surface presents the bounded
|
||||||
|
`application_status.projection` owned by Forms Runtime. It supports the
|
||||||
|
configured authenticated, short-lived email-link, and permanent-link modes,
|
||||||
|
while Portal owns only the accessible presentation and reload/request actions.
|
||||||
|
|
||||||
|
Portal deliberately does not publish a DSAR provider because it persists no
|
||||||
|
service-directory, launch, Postbox, application-status, applicant, or session
|
||||||
|
records. Services owns definitions, each launch target owns its effects,
|
||||||
|
Postbox owns mailbox data, and Forms Runtime owns status grants and submission
|
||||||
|
data. Core and the deployment operator remain responsible for request/security
|
||||||
|
logs. This reviewed boundary avoids duplicate or contradictory privacy exports.
|
||||||
|
|
||||||
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
|
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
|
||||||
|
|
||||||
|
## Heading localization
|
||||||
|
|
||||||
|
Portal owns the English/German catalog entries for the function-postbox heading,
|
||||||
|
authenticated-access heading, short-lived status-link request heading, and
|
||||||
|
timeline heading. The native postbox heading translates during React rendering;
|
||||||
|
status cards use Core's shared title translation contract. Provider-owned names
|
||||||
|
and status access rules are not changed by this localization pass.
|
||||||
|
|
||||||
|
From the Meta checkout, run `./devkit docs audit --repo portal` and
|
||||||
|
`./devkit check --profile ui --repo portal`. The latter discovers Portal's
|
||||||
|
`test:translations` script, which verifies module registration, both languages,
|
||||||
|
shared card rendering, and first-render native heading translation. These checks
|
||||||
|
do not claim that every Portal sentence or dynamic value is localized.
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/portal-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/portal-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -124,3 +124,51 @@ and Forms Runtime are active. It resolves an exact published
|
|||||||
`<form-id>/<revision>`, validates launch values, and retains Service/binding
|
`<form-id>/<revision>`, validates launch values, and retains Service/binding
|
||||||
provenance. Reduced installations still fail closed rather than simulating a
|
provenance. Reduced installations still fail closed rather than simulating a
|
||||||
submission in Portal.
|
submission in Portal.
|
||||||
|
|
||||||
|
## Applicant Status Presentation
|
||||||
|
|
||||||
|
Portal also presents Forms Runtime's bounded applicant-status projection at
|
||||||
|
`/portal/status/:trackingId`. It does not persist a status, inspect a Form
|
||||||
|
submission, or decide the disclosure policy. Forms Runtime resolves the tenant
|
||||||
|
through the Core `application_status.projection` contract and remains
|
||||||
|
authoritative for all access decisions.
|
||||||
|
|
||||||
|
The page adapts to the configured grant:
|
||||||
|
|
||||||
|
- authenticated-only access offers sign-in and then uses the applicant-bound
|
||||||
|
status endpoint;
|
||||||
|
- email-link access accepts the linked email address and always reports the
|
||||||
|
same request outcome, whether or not it matched; a delivered link carries a
|
||||||
|
short-lived secret that can be resent and replaces its predecessor; and
|
||||||
|
- permanent-link access loads from the high-entropy tracking URL without
|
||||||
|
authentication.
|
||||||
|
|
||||||
|
All modes render only title, current lifecycle state, update time, receipt
|
||||||
|
identifier, and the bounded public timeline supplied by Forms Runtime. Portal
|
||||||
|
must not infer missing milestones or expose values, people, evidence, internal
|
||||||
|
notes, or handoff details. A reload action re-fetches the authoritative
|
||||||
|
projection. Missing, disabled, revoked, expired, or unauthorized grants share
|
||||||
|
a non-enumerating unavailable state.
|
||||||
|
|
||||||
|
## Data-subject request ownership
|
||||||
|
|
||||||
|
Portal has no module-owned persistence and therefore does not contribute a
|
||||||
|
`privacy.dsar.portal` provider. Its routes resolve and render bounded
|
||||||
|
provider-owned projections during each request; they do not copy service
|
||||||
|
definitions, launch parameters or results, Postbox entries, status grants,
|
||||||
|
submission values, email addresses, or applicant identities into Portal.
|
||||||
|
|
||||||
|
Data-subject request coverage follows the authoritative owner:
|
||||||
|
|
||||||
|
- Services covers configuration-author attribution for versioned service
|
||||||
|
definitions;
|
||||||
|
- Cases, Forms Runtime, and Workflow Engine cover launch effects and domain
|
||||||
|
instances;
|
||||||
|
- Postbox covers mailbox records; and
|
||||||
|
- Forms Runtime covers status-access grants, token lifecycle, confirmations,
|
||||||
|
acknowledgements, and submitted Form data.
|
||||||
|
|
||||||
|
Authentication state, request/security logs, and infrastructure telemetry are
|
||||||
|
Core or deployment-operator concerns, not Portal records. If Portal later gains
|
||||||
|
durable personalization, analytics, saved searches, contact data, or session
|
||||||
|
persistence, that change must add a tenant-scoped DSAR provider before release.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/portal-webui",
|
||||||
|
"version": "0.1.22",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/portal.css": "./webui/src/styles/portal.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-portal"
|
name = "govoplan-portal"
|
||||||
version = "0.1.17"
|
version = "0.1.22"
|
||||||
description = "GovOPlaN service discovery and public portal module."
|
description = "GovOPlaN service discovery and public portal module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = ["govoplan-core>=0.1.17"]
|
dependencies = ["govoplan-core>=0.1.18"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'portal.application-status': {'privacy_notes': ['Der Besitz eines permanenten Statuslinks gewährt '
|
||||||
|
'Zugriff auf die begrenzte Projektion, bis der '
|
||||||
|
'Eigentümer die Police aussetzt oder den Zuschuss '
|
||||||
|
'widerruft.',
|
||||||
|
'Die Oberfläche der E-Mail-Anfrage zeigt nicht '
|
||||||
|
'an, ob die Tracking-Kennung, die E-Mail-Adresse, '
|
||||||
|
'der Anbieter oder der Lieferversuch '
|
||||||
|
'übereinstimmen.']},
|
||||||
|
'portal.service-directory': {'steps': ['Öffnen Sie das Dienstverzeichnis und wählen Sie einen '
|
||||||
|
'entsprechenden veröffentlichten Dienst aus.',
|
||||||
|
'Überprüfen Sie alle erklärten '
|
||||||
|
'Verfügbarkeitsbeschränkungen, bevor Sie fortfahren.',
|
||||||
|
'Öffnen Sie den Dienst, damit Portal die genaue '
|
||||||
|
'Überarbeitung erneut überprüft und den Start an sein '
|
||||||
|
'eigenes Modul übergibt.']}}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_portal.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
|
from govoplan_core.core.application_status import (
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
|
application_status_projection_provider,
|
||||||
|
)
|
||||||
from govoplan_core.core.institutional import (
|
from govoplan_core.core.institutional import (
|
||||||
CAPABILITY_SERVICE_AVAILABILITY,
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
CAPABILITY_SERVICE_DEFINITIONS,
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
service_launch_capability,
|
service_launch_capability,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_PORTAL
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -17,6 +26,8 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
PublicFrontendRoute,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
@@ -32,7 +43,7 @@ from govoplan_core.core.views import ViewSurface
|
|||||||
|
|
||||||
|
|
||||||
MODULE_ID = "portal"
|
MODULE_ID = "portal"
|
||||||
MODULE_VERSION = "0.1.17"
|
MODULE_VERSION = "0.1.22"
|
||||||
READ_SCOPE = "portal:service:read"
|
READ_SCOPE = "portal:service:read"
|
||||||
SERVICE_LAUNCH_CAPABILITIES = tuple(
|
SERVICE_LAUNCH_CAPABILITIES = tuple(
|
||||||
service_launch_capability(kind) for kind in ("case", "form", "workflow")
|
service_launch_capability(kind) for kind in ("case", "form", "workflow")
|
||||||
@@ -50,6 +61,27 @@ def _router(context: ModuleContext):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _public_tenant_resolver(request: object, session: object) -> str | None:
|
||||||
|
path = str(getattr(getattr(request, "url", None), "path", ""))
|
||||||
|
if "/portal/status/" not in path:
|
||||||
|
return None
|
||||||
|
path_params = getattr(request, "path_params", {})
|
||||||
|
tracking_id = str(
|
||||||
|
path_params.get("trackingId")
|
||||||
|
or path_params.get("tracking_id")
|
||||||
|
or path.rsplit("/", 1)[-1]
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not tracking_id:
|
||||||
|
return None
|
||||||
|
app = getattr(request, "app", None)
|
||||||
|
registry = getattr(getattr(app, "state", None), "govoplan_registry", None)
|
||||||
|
provider = application_status_projection_provider(registry)
|
||||||
|
if provider is None:
|
||||||
|
return None
|
||||||
|
return provider.tenant_id_for_tracking_id(session, tracking_id=tracking_id)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name="Portal",
|
name="Portal",
|
||||||
@@ -61,11 +93,14 @@ manifest = ModuleManifest(
|
|||||||
"forms",
|
"forms",
|
||||||
"forms_runtime",
|
"forms_runtime",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
|
"postbox",
|
||||||
),
|
),
|
||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_SERVICE_DEFINITIONS,
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
CAPABILITY_SERVICE_AVAILABILITY,
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
*SERVICE_LAUNCH_CAPABILITIES,
|
*SERVICE_LAUNCH_CAPABILITIES,
|
||||||
|
CAPABILITY_POSTBOX_PORTAL,
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
),
|
),
|
||||||
permissions=(
|
permissions=(
|
||||||
PermissionDefinition(
|
PermissionDefinition(
|
||||||
@@ -103,11 +138,24 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
for capability in SERVICE_LAUNCH_CAPABILITIES
|
for capability in SERVICE_LAUNCH_CAPABILITIES
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_POSTBOX_PORTAL,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
||||||
},
|
},
|
||||||
route_factory=_router,
|
route_factory=_router,
|
||||||
|
public_tenant_resolver=_public_tenant_resolver,
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(
|
NavItem(
|
||||||
path="/portal",
|
path="/portal",
|
||||||
@@ -128,6 +176,13 @@ manifest = ModuleManifest(
|
|||||||
order=25,
|
order=25,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/portal/status/:trackingId",
|
||||||
|
component="PortalStatusPage",
|
||||||
|
order=12,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(
|
NavItem(
|
||||||
path="/portal",
|
path="/portal",
|
||||||
@@ -137,6 +192,17 @@ manifest = ModuleManifest(
|
|||||||
order=25,
|
order=25,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="services-cases",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.services_cases",
|
||||||
|
icon="landmark",
|
||||||
|
description="i18n:govoplan-core.product_area.services_cases_description",
|
||||||
|
surface_ids=("portal.nav.portal", "portal.route.portal"),
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="portal.navigation",
|
id="portal.navigation",
|
||||||
@@ -152,6 +218,13 @@ manifest = ModuleManifest(
|
|||||||
label="Service directory",
|
label="Service directory",
|
||||||
order=20,
|
order=20,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="portal.application-status",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Applicant status",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
@@ -162,11 +235,130 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="portal.data-subject-requests",
|
||||||
|
title="Portal data-subject request boundary",
|
||||||
|
summary="Understand why Portal has no separate privacy export and which authoritative modules own the projected data.",
|
||||||
|
body=(
|
||||||
|
"Portal persists no service-directory, service-launch, Postbox, application-status, applicant, or session records, so it deliberately publishes no duplicate data-subject request provider. Services owns definition attribution; Cases, Forms Runtime, and Workflow Engine own launch effects; Postbox owns mailbox records; and Forms Runtime owns submission and status-access data. Core and the deployment operator own authentication state, request/security logs, and infrastructure telemetry. "
|
||||||
|
"If durable personalization, analytics, saved searches, contact data, or sessions are added to Portal, a tenant-scoped privacy provider is required before release."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzgrenze des Portals",
|
||||||
|
"summary": "Verstehen, warum das Portal keinen eigenen Datenschutzexport bereitstellt und welche maßgeblichen Module die projizierten Daten verwalten.",
|
||||||
|
"body": (
|
||||||
|
"Das Portal speichert weder Dienstverzeichnis-, Dienststart-, Postfach-, Antragsstatus-, Antragsteller- noch Sitzungsdaten und stellt deshalb bewusst keinen doppelten Auskunftsanbieter bereit. "
|
||||||
|
"Services verwaltet die Zuordnung von Dienstdefinitionen; Cases, Forms Runtime und Workflow Engine verwalten die Wirkungen eines Starts; Postbox verwaltet Postfachdaten; Forms Runtime verwaltet Einreichungs- und Statuszugriffsdaten. "
|
||||||
|
"Core und der Betriebsverantwortliche verwalten Authentifizierungszustand, Anfrage- und Sicherheitsprotokolle sowie Infrastrukturtelemetrie. "
|
||||||
|
"Werden dem Portal dauerhafte Personalisierung, Analysen, gespeicherte Suchen, Kontaktdaten oder Sitzungen hinzugefügt, ist vor der Freigabe ein mandantenbezogener Datenschutzanbieter erforderlich."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Portal ownership boundary",
|
||||||
|
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=(
|
||||||
|
"core",
|
||||||
|
"services",
|
||||||
|
"cases",
|
||||||
|
"forms_runtime",
|
||||||
|
"workflow_engine",
|
||||||
|
"postbox",
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["portal.data-subject-requests"],
|
||||||
|
"dsar_coverage": "not_applicable_no_persistence",
|
||||||
|
},
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="portal.function-postboxes",
|
||||||
|
title="Portal-facing function Postboxes",
|
||||||
|
summary="Open explicitly published function Postboxes without moving their access rules into Portal.",
|
||||||
|
body=(
|
||||||
|
"When Postbox is installed, Portal can display Postboxes whose exact definition or published template revision is marked portal-visible. "
|
||||||
|
"Postbox re-evaluates the current function assignment, classification, and read authority for every projection. Portal stores no Postbox ACL, "
|
||||||
|
"does not expose vacant or inaccessible addresses, and links back to the authoritative Postbox surface."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Portal-sichtbare Funktionspostfächer",
|
||||||
|
"summary": "Ausdrücklich veröffentlichte Funktionspostfächer öffnen, ohne ihre Zugriffsregeln in das Portal zu verlagern.",
|
||||||
|
"body": (
|
||||||
|
"Ist Postbox installiert, kann das Portal Postfächer anzeigen, deren exakte Definition oder veröffentlichte Vorlagenrevision als portalsichtbar markiert ist. "
|
||||||
|
"Postbox prüft für jede Projektion die aktuelle Funktionszuordnung, Klassifikation und Leseberechtigung erneut. Das Portal speichert keine Postfach-ACL, "
|
||||||
|
"zeigt keine unbesetzten oder nicht zugänglichen Adressen und verweist auf die maßgebliche Postbox-Oberfläche."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(DocumentationLink(label="Portal", href="/portal", kind="runtime"),),
|
||||||
|
related_modules=("postbox", "idm", "organizations"),
|
||||||
|
metadata={"kind": "guide", "help_contexts": ["portal.postboxes"]},
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="portal.application-status",
|
||||||
|
title="Track an application",
|
||||||
|
summary="View the bounded public lifecycle through the access profile configured for the exact submitted Form revision.",
|
||||||
|
body=(
|
||||||
|
"Portal presents the applicant status projection owned by Forms Runtime. The service administrator chooses authenticated-only access, a short-lived email link, or a permanent public bearer link for each exact published Form revision. "
|
||||||
|
"Authenticated access is checked against the applicant account. Email-link requests return the same response for matching and non-matching details, revoke the previous link when a new one is sent, and depend on configured Notifications and Mail delivery. A permanent link does not expire or require sign-in and must therefore be handled like a bearer secret. "
|
||||||
|
"The page exposes only lifecycle states, update times, a tracking identifier, and the submission receipt. It does not display Form values, evidence, internal notes, actors, decision reasoning, or module handoff details. "
|
||||||
|
"The sign-in, short-lived status-link request, and timeline headings follow the selected interface language; changing language does not change the configured access policy."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("public", "user", "operator", "module_admin"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Antrag verfolgen",
|
||||||
|
"summary": "Den begrenzten öffentlichen Verlauf über das für die genaue veröffentlichte Formularrevision konfigurierte Zugriffsprofil einsehen.",
|
||||||
|
"body": (
|
||||||
|
"Das Portal zeigt die von Forms Runtime verwaltete Antragsstatusprojektion. Für jede genaue veröffentlichte Formularrevision wählt die Dienstadministration zwischen ausschließlich authentifiziertem Zugriff, einem kurzlebigen E-Mail-Link und einem dauerhaften öffentlichen Inhaberlink. "
|
||||||
|
"Beim authentifizierten Zugriff wird das Antragstellerkonto geprüft. Anforderungen eines E-Mail-Links liefern für passende und unpassende Angaben dieselbe Antwort, widerrufen beim erneuten Versand den vorherigen Link und setzen konfigurierte Notifications- und Mail-Zustellung voraus. Ein dauerhafter Link läuft nicht ab und erfordert keine Anmeldung; er ist deshalb wie ein Inhabergeheimnis zu behandeln. "
|
||||||
|
"Die Seite zeigt ausschließlich Lebenszyklusstatus, Aktualisierungszeiten, eine Vorgangskennung und die Einreichungsbestätigung. Formularwerte, Nachweise, interne Notizen, handelnde Personen, Entscheidungsbegründungen und Details von Modulübergaben bleiben verborgen. "
|
||||||
|
"Die Überschriften für die Anmeldung, die Anforderung eines kurzlebigen Statuslinks und den Verlauf folgen der gewählten Oberflächensprache; ein Sprachwechsel ändert die konfigurierte Zugriffsregel nicht."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Applicant status",
|
||||||
|
href="/portal/status/:trackingId",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("forms_runtime", "notifications", "mail"),
|
||||||
|
metadata={
|
||||||
|
"kind": "guide",
|
||||||
|
"help_contexts": ["portal.application-status"],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Possession of a permanent status link grants access to the bounded projection until its owner suspends the policy or revokes the grant.",
|
||||||
|
"The email request surface does not reveal whether the tracking identifier, email address, provider, or delivery attempt matched."
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="portal.service-directory",
|
id="portal.service-directory",
|
||||||
title="Service directory",
|
title="Service directory",
|
||||||
summary="Find services available in the configured institution and understand relevant availability limits.",
|
summary="Find services available in the configured institution and understand relevant availability limits.",
|
||||||
body=(
|
body=(
|
||||||
|
"Documentation books sit immediately beside the visible heading or contextual label for "
|
||||||
|
"Services, not among operational action buttons. Field help remains beside its label. "
|
||||||
|
"The My function postboxes section heading follows the selected interface language; postbox and service names remain provider-owned data. "
|
||||||
"Portal presents provider-owned, versioned service definitions. "
|
"Portal presents provider-owned, versioned service definitions. "
|
||||||
"Published services may be available, unavailable with a reason, "
|
"Published services may be available, unavailable with a reason, "
|
||||||
"or undiscoverable when they do not apply to the current audience. "
|
"or undiscoverable when they do not apply to the current audience. "
|
||||||
@@ -176,6 +368,26 @@ manifest = ModuleManifest(
|
|||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "operator", "module_admin"),
|
audience=("user", "operator", "module_admin"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("portal",),
|
||||||
|
required_scopes=(READ_SCOPE,),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Dienstverzeichnis",
|
||||||
|
"summary": "Verfügbare Dienste der konfigurierten Institution finden und ihre maßgeblichen Verfügbarkeitsgrenzen verstehen.",
|
||||||
|
"body": (
|
||||||
|
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||||
|
"Kontextbezeichnung für Dienste, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe "
|
||||||
|
"bleibt neben der Feldbezeichnung. "
|
||||||
|
"Die Abschnittsüberschrift Meine Funktionspostfächer folgt der gewählten Oberflächensprache; Postfach- und Leistungsnamen bleiben Daten des jeweiligen Anbieters. "
|
||||||
|
"Das Portal zeigt versionierte Dienstdefinitionen ihrer jeweils verantwortlichen Anbieter. Veröffentlichte Dienste können verfügbar sein, mit einer Begründung als nicht verfügbar erscheinen oder unauffindbar bleiben, wenn sie für die aktuelle Zielgruppe nicht gelten. "
|
||||||
|
"Beim Öffnen wird die genaue Revision erneut geprüft und der Start eines Falls, Formulars oder Workflows an die installierte Besitzerfunktion übergeben."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Service directory architecture",
|
label="Service directory architecture",
|
||||||
@@ -188,6 +400,15 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["portal.service-directory"],
|
||||||
|
"steps": [
|
||||||
|
"Open the service directory and select an applicable published service.",
|
||||||
|
"Review any explained availability restriction before continuing.",
|
||||||
|
"Open the service so Portal rechecks the exact revision and hands the start to its owning module.",
|
||||||
|
],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
architecture=ModuleArchitectureDeclaration(
|
architecture=ModuleArchitectureDeclaration(
|
||||||
@@ -209,9 +430,11 @@ manifest = ModuleManifest(
|
|||||||
known_limits=(
|
known_limits=(
|
||||||
"Portal does not persist service definitions; the Services provider remains authoritative.",
|
"Portal does not persist service definitions; the Services provider remains authoritative.",
|
||||||
"Case, Forms Runtime, and Workflow Engine own launch effects. Portal keeps entries unavailable whenever the selected owner capability is absent.",
|
"Case, Forms Runtime, and Workflow Engine own launch effects. Portal keeps entries unavailable whenever the selected owner capability is absent.",
|
||||||
|
"Forms Runtime owns applicant-status access, redaction, and timeline semantics; Portal only presents that projection. Payment and decision-document actions are not yet included in the first status surface.",
|
||||||
|
"Portal owns no durable subject records and therefore has no DSAR provider; adding persistence requires introducing one before release.",
|
||||||
),
|
),
|
||||||
owned_concepts=("service discovery", "service presentation", "channel entry"),
|
owned_concepts=("service discovery", "service presentation", "channel entry", "applicant status presentation"),
|
||||||
non_owned_concepts=("institutional service definition", "case lifecycle"),
|
non_owned_concepts=("institutional service definition", "case lifecycle", "applicant status access decision"),
|
||||||
reference_packages=("product.service-to-decision",),
|
reference_packages=("product.service-to-decision",),
|
||||||
documentation=ModuleArchitectureDocumentation(
|
documentation=ModuleArchitectureDocumentation(
|
||||||
security=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
security=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
||||||
@@ -221,5 +444,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -10,11 +11,13 @@ from govoplan_core.core.institutional import (
|
|||||||
InstitutionalContextError,
|
InstitutionalContextError,
|
||||||
InstitutionalReference,
|
InstitutionalReference,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.postbox import postbox_portal_projection_provider
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_portal.backend.schemas import (
|
from govoplan_portal.backend.schemas import (
|
||||||
PortalServiceLaunchRequest,
|
PortalServiceLaunchRequest,
|
||||||
PortalServiceLaunchResponse,
|
PortalServiceLaunchResponse,
|
||||||
PortalServiceListResponse,
|
PortalServiceListResponse,
|
||||||
|
PortalPostboxListResponse,
|
||||||
)
|
)
|
||||||
from govoplan_portal.backend.service_directory import (
|
from govoplan_portal.backend.service_directory import (
|
||||||
PortalServiceDirectory,
|
PortalServiceDirectory,
|
||||||
@@ -66,6 +69,32 @@ def api_list_portal_services(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/postboxes", response_model=PortalPostboxListResponse)
|
||||||
|
def api_list_portal_postboxes(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PortalPostboxListResponse:
|
||||||
|
if not has_scope(principal, READ_SCOPE):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||||
|
provider = postbox_portal_projection_provider(_registry)
|
||||||
|
if provider is None:
|
||||||
|
return PortalPostboxListResponse(
|
||||||
|
provider_available=False,
|
||||||
|
postboxes=[],
|
||||||
|
)
|
||||||
|
entries = provider.list_portal_entries(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return PortalPostboxListResponse(
|
||||||
|
provider_available=True,
|
||||||
|
postboxes=[asdict(entry) for entry in entries],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/services/{service_id}/launch",
|
"/services/{service_id}/launch",
|
||||||
response_model=PortalServiceLaunchResponse,
|
response_model=PortalServiceLaunchResponse,
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ class PortalServiceListResponse(BaseModel):
|
|||||||
services: list[PortalServiceEntryResponse]
|
services: list[PortalServiceEntryResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class PortalPostboxEntryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
postbox: dict[str, Any]
|
||||||
|
unread_count: int = Field(default=0, ge=0)
|
||||||
|
latest_message_at: datetime | None = None
|
||||||
|
route_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class PortalPostboxListResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
provider_available: bool
|
||||||
|
postboxes: list[PortalPostboxEntryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PortalServiceLaunchRequest(BaseModel):
|
class PortalServiceLaunchRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -49,4 +65,6 @@ __all__ = [
|
|||||||
"PortalServiceLaunchRequest",
|
"PortalServiceLaunchRequest",
|
||||||
"PortalServiceLaunchResponse",
|
"PortalServiceLaunchResponse",
|
||||||
"PortalServiceListResponse",
|
"PortalServiceListResponse",
|
||||||
|
"PortalPostboxEntryResponse",
|
||||||
|
"PortalPostboxListResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.core.institutional import (
|
from govoplan_core.core.institutional import (
|
||||||
CAPABILITY_SERVICE_AVAILABILITY,
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
@@ -23,7 +25,10 @@ from govoplan_core.core.access import (
|
|||||||
FunctionRef,
|
FunctionRef,
|
||||||
PrincipalRef,
|
PrincipalRef,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxPortalEntryRef
|
||||||
from govoplan_portal.backend.manifest import get_manifest
|
from govoplan_portal.backend.manifest import get_manifest
|
||||||
|
from govoplan_portal.backend.router import api_list_portal_postboxes
|
||||||
from govoplan_portal.backend.service_directory import (
|
from govoplan_portal.backend.service_directory import (
|
||||||
PortalServiceDirectory,
|
PortalServiceDirectory,
|
||||||
principal_audiences,
|
principal_audiences,
|
||||||
@@ -189,6 +194,47 @@ class SemanticDirectory:
|
|||||||
|
|
||||||
|
|
||||||
class PortalServiceDirectoryTests(unittest.TestCase):
|
class PortalServiceDirectoryTests(unittest.TestCase):
|
||||||
|
def test_portal_postbox_endpoint_projects_optional_provider_entries(self) -> None:
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({"portal:service:read"}),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="membership-1"),
|
||||||
|
)
|
||||||
|
entry = PostboxPortalEntryRef(
|
||||||
|
postbox=PostboxDirectoryEntryRef(
|
||||||
|
id="postbox-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address="clerk.district",
|
||||||
|
address_key="clerk.district",
|
||||||
|
name="District / Clerk",
|
||||||
|
status="active",
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
unread_count=3,
|
||||||
|
)
|
||||||
|
provider = SimpleNamespace(
|
||||||
|
list_portal_entries=lambda *_args, **_kwargs: (entry,)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_portal.backend.router.postbox_portal_projection_provider",
|
||||||
|
return_value=provider,
|
||||||
|
):
|
||||||
|
response = api_list_portal_postboxes(
|
||||||
|
limit=100,
|
||||||
|
session=object(),
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(response.provider_available)
|
||||||
|
self.assertEqual("postbox-1", response.postboxes[0].postbox["id"])
|
||||||
|
self.assertEqual(3, response.postboxes[0].unread_count)
|
||||||
|
|
||||||
def test_provider_definition_is_available_when_requirements_exist(self) -> None:
|
def test_provider_definition_is_available_when_requirements_exist(self) -> None:
|
||||||
entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
|
entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
|
||||||
None,
|
None,
|
||||||
@@ -385,6 +431,52 @@ class PortalServiceDirectoryTests(unittest.TestCase):
|
|||||||
{item.scope for item in manifest.permissions},
|
{item.scope for item in manifest.permissions},
|
||||||
)
|
)
|
||||||
self.assertIn("portal.service_directory", manifest.capability_factories)
|
self.assertIn("portal.service_directory", manifest.capability_factories)
|
||||||
|
self.assertIsNotNone(manifest.public_tenant_resolver)
|
||||||
|
self.assertIn(
|
||||||
|
"/portal/status/:trackingId",
|
||||||
|
{route.path for route in manifest.frontend.public_routes},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"application_status.projection",
|
||||||
|
{item.name for item in manifest.requires_interfaces},
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
name.startswith("privacy.dsar.")
|
||||||
|
for name in manifest.capability_factories
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dsar_topic = next(
|
||||||
|
item
|
||||||
|
for item in manifest.documentation
|
||||||
|
if item.id == "portal.data-subject-requests"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"not_applicable_no_persistence",
|
||||||
|
dsar_topic.metadata["dsar_coverage"],
|
||||||
|
)
|
||||||
|
self.assertTrue({"admin", "user"}.issubset(dsar_topic.documentation_types))
|
||||||
|
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
all(
|
||||||
|
topic.translations.get("de", {}).get(field)
|
||||||
|
for field in ("title", "summary", "body")
|
||||||
|
)
|
||||||
|
for topic in topics.values()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflow = topics["portal.service-directory"]
|
||||||
|
self.assertEqual("workflow", workflow.metadata["kind"])
|
||||||
|
self.assertTrue(workflow.conditions)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
condition.required_scopes or condition.any_scopes
|
||||||
|
for condition in workflow.conditions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual("reference", dsar_topic.metadata["kind"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/portal-webui",
|
"name": "@govoplan/portal-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,10 +14,11 @@
|
|||||||
"./styles/portal.css": "./src/styles/portal.css"
|
"./styles/portal.css": "./src/styles/portal.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs",
|
||||||
|
"test:translations": "node --test scripts/test-translations.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.17",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
const page = fs.readFileSync("src/features/portal/PortalPage.tsx", "utf8");
|
const page = fs.readFileSync("src/features/portal/PortalPage.tsx", "utf8");
|
||||||
const styles = fs.readFileSync("src/styles/portal.css", "utf8");
|
|
||||||
|
|
||||||
assert.ok(page.includes("DocumentationHelpLink"), "Portal exposes configured-system help");
|
assert.ok(page.includes("DocumentationHelpLink"), "Portal exposes configured-system help");
|
||||||
assert.ok(page.includes("ActionBlockerHint"), "Unavailable launches expose the shared structured blocker");
|
assert.ok(page.includes("ActionBlockerHint"), "Unavailable launches expose the shared structured blocker");
|
||||||
@@ -12,6 +11,6 @@ assert.ok(page.includes('aria-live="polite"'), "Changing result counts are annou
|
|||||||
assert.ok(page.includes("useGuardedNavigate"), "Internal launch handoffs respect unsaved-work navigation");
|
assert.ok(page.includes("useGuardedNavigate"), "Internal launch handoffs respect unsaved-work navigation");
|
||||||
assert.ok(!page.includes("window.alert("), "Portal must not use browser alerts");
|
assert.ok(!page.includes("window.alert("), "Portal must not use browser alerts");
|
||||||
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Portal uses semantic interactive elements");
|
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Portal uses semantic interactive elements");
|
||||||
assert.ok(styles.includes("@media (max-width: 720px)"), "Portal retains a narrow-viewport toolbar layout");
|
assert.ok(page.includes("<WorkspaceActionBar"), "Portal delegates responsive toolbar layout to the shared semantic action bar");
|
||||||
|
|
||||||
console.log("Portal interface pattern contract passed.");
|
console.log("Portal interface pattern contract passed.");
|
||||||
|
|||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
const webui = resolve(import.meta.dirname, "..");
|
||||||
|
const core = resolve(webui, "../../govoplan-core/webui");
|
||||||
|
const require = createRequire(resolve(core, "package.json"));
|
||||||
|
const { buildSync } = require("esbuild");
|
||||||
|
const expected = {
|
||||||
|
"My function postboxes": "Meine Funktionspostfächer",
|
||||||
|
"Sign in to view status": "Anmelden, um den Status anzuzeigen",
|
||||||
|
"Request a short-lived status link": "Kurzlebigen Statuslink anfordern",
|
||||||
|
"Timeline": "Verlauf",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exercise the real locale provider and shared Card contract. Compile in memory:
|
||||||
|
// no server, shared build directory, optional module imports, or browser effects.
|
||||||
|
const { outputFiles } = buildSync({
|
||||||
|
stdin: {
|
||||||
|
contents: `
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { generatedTranslations } from ${JSON.stringify(resolve(webui, "src/i18n/generatedTranslations.ts"))};
|
||||||
|
import { PlatformLanguageProvider, usePlatformLanguage } from ${JSON.stringify(resolve(core, "src/i18n/LanguageContext.tsx"))};
|
||||||
|
import Card from ${JSON.stringify(resolve(core, "src/components/Card.tsx"))};
|
||||||
|
export const catalog = generatedTranslations;
|
||||||
|
function NativeHeading({ label }) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
return <h2 id="portal-postboxes-heading">{translateText(label)}</h2>;
|
||||||
|
}
|
||||||
|
export function heading(language, label, native) {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[catalog]}>
|
||||||
|
{native ? <NativeHeading label={label} /> : <Card title={label}>Fixture</Card>}
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
loader: "tsx",
|
||||||
|
resolveDir: core,
|
||||||
|
},
|
||||||
|
bundle: true,
|
||||||
|
write: false,
|
||||||
|
platform: "node",
|
||||||
|
format: "cjs",
|
||||||
|
jsx: "automatic",
|
||||||
|
external: ["react", "react-dom/server"],
|
||||||
|
logLevel: "silent",
|
||||||
|
});
|
||||||
|
const compiled = { exports: {} };
|
||||||
|
new Function("require", "module", "exports", outputFiles[0].text)(require, compiled, compiled.exports);
|
||||||
|
const { catalog, heading } = compiled.exports;
|
||||||
|
|
||||||
|
test("Portal registers and owns all four translations", () => {
|
||||||
|
const module = readFileSync(resolve(webui, "src/module.ts"), "utf8");
|
||||||
|
assert.match(module, /import\s*\{\s*generatedTranslations\s*\}\s*from\s*["']\.\/i18n\/generatedTranslations["']/);
|
||||||
|
assert.match(module, /translations:\s*generatedTranslations/);
|
||||||
|
for (const [english, german] of Object.entries(expected)) {
|
||||||
|
assert.equal(catalog.en[english], english);
|
||||||
|
assert.equal(catalog.de[english], german);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each heading renders in English and German without the DOM translation bridge", () => {
|
||||||
|
for (const [english, german] of Object.entries(expected)) {
|
||||||
|
for (const [language, translation] of [["en", english], ["de", german]]) {
|
||||||
|
const native = english === "My function postboxes";
|
||||||
|
const markup = heading(language, english, native);
|
||||||
|
const tag = native ? '<h2 id="portal-postboxes-heading">' : "<h2>";
|
||||||
|
assert.ok(markup.includes(`${tag}${translation}</h2>`), `${language}: ${english}`);
|
||||||
|
if (language === "de") assert.ok(!markup.includes(`>${english}</h2>`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Portal pages keep the verified native-heading and shared-card translation paths", () => {
|
||||||
|
const directory = readFileSync(resolve(webui, "src/features/portal/PortalPage.tsx"), "utf8");
|
||||||
|
assert.match(directory, /const\s*\{\s*translateText\s*\}\s*=\s*usePlatformLanguage\(\)/);
|
||||||
|
assert.ok(directory.includes('<h2 id="portal-postboxes-heading">{translateText("My function postboxes")}</h2>'));
|
||||||
|
const status = readFileSync(resolve(webui, "src/features/portal/PortalStatusPage.tsx"), "utf8");
|
||||||
|
for (const english of Object.keys(expected).slice(1)) {
|
||||||
|
assert.ok(status.includes(`<Card title="${english}"`), `Shared card title: ${english}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
function publicSettings(settings: ApiSettings): ApiSettings {
|
||||||
|
return { ...settings, accessToken: "", apiKey: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export type PortalServiceBinding = {
|
export type PortalServiceBinding = {
|
||||||
kind: string;
|
kind: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
@@ -35,6 +40,25 @@ export type PortalServiceListResponse = {
|
|||||||
services: PortalServiceEntry[];
|
services: PortalServiceEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PortalPostboxEntry = {
|
||||||
|
postbox: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
organization_unit_name?: string | null;
|
||||||
|
function_name?: string | null;
|
||||||
|
classification: string;
|
||||||
|
};
|
||||||
|
unread_count: number;
|
||||||
|
latest_message_at?: string | null;
|
||||||
|
route_path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalPostboxListResponse = {
|
||||||
|
provider_available: boolean;
|
||||||
|
postboxes: PortalPostboxEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PortalServiceLaunchResult = {
|
export type PortalServiceLaunchResult = {
|
||||||
service_ref: PortalServiceDefinition["reference"];
|
service_ref: PortalServiceDefinition["reference"];
|
||||||
binding: PortalServiceBinding;
|
binding: PortalServiceBinding;
|
||||||
@@ -46,6 +70,26 @@ export type PortalServiceLaunchResult = {
|
|||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PortalApplicationStatusAccess = {
|
||||||
|
tracking_id: string;
|
||||||
|
mode: "authenticated" | "email_link" | "permanent_link";
|
||||||
|
authenticated_available: boolean;
|
||||||
|
email_link_available: boolean;
|
||||||
|
token_ttl_seconds?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalApplicationStatus = {
|
||||||
|
tracking_id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
updated_at: string;
|
||||||
|
receipt_id?: string | null;
|
||||||
|
timeline: Array<{
|
||||||
|
status: string;
|
||||||
|
occurred_at: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export function listPortalServices(
|
export function listPortalServices(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
options: {
|
options: {
|
||||||
@@ -66,6 +110,17 @@ export function listPortalServices(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listPortalPostboxes(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalPostboxListResponse> {
|
||||||
|
return apiFetch<PortalPostboxListResponse>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/portal/postboxes",
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function launchPortalService(
|
export function launchPortalService(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
@@ -85,3 +140,57 @@ export function launchPortalService(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getApplicationStatusAccess(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalApplicationStatusAccess> {
|
||||||
|
return apiFetch(
|
||||||
|
publicSettings(settings),
|
||||||
|
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/access`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicApplicationStatus(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
token?: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalApplicationStatus> {
|
||||||
|
return apiFetch(
|
||||||
|
publicSettings(settings),
|
||||||
|
apiPath(`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}`, { token }),
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthenticatedApplicationStatus(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalApplicationStatus> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/status/${encodeURIComponent(trackingId)}`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestApplicationStatusEmailLink(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
email: string
|
||||||
|
): Promise<{ accepted: boolean; message: string }> {
|
||||||
|
return apiFetch(
|
||||||
|
publicSettings(settings),
|
||||||
|
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/email-links`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowUpRight, Search } from "lucide-react";
|
import { ArrowUpRight, Inbox, Search } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -6,34 +6,44 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type FormEvent
|
type FormEvent
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { ActionBlockerHint,
|
||||||
ActionBlockerHint,
|
CountBadge,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
Button,
|
Button,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
|
FilterBar,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
|
usePlatformLanguage,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
launchPortalService,
|
launchPortalService,
|
||||||
|
listPortalPostboxes,
|
||||||
listPortalServices,
|
listPortalServices,
|
||||||
|
type PortalPostboxEntry,
|
||||||
type PortalServiceEntry
|
type PortalServiceEntry
|
||||||
} from "../../api/portal";
|
} from "../../api/portal";
|
||||||
|
|
||||||
|
|
||||||
export default function PortalPage({ settings }: PlatformRouteContext) {
|
export default function PortalPage({ settings }: PlatformRouteContext) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||||
const [includeUnavailable, setIncludeUnavailable] = useState(true);
|
const [includeUnavailable, setIncludeUnavailable] = useState(true);
|
||||||
const [services, setServices] = useState<PortalServiceEntry[]>([]);
|
const [services, setServices] = useState<PortalServiceEntry[]>([]);
|
||||||
|
const [postboxes, setPostboxes] = useState<PortalPostboxEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [launchingId, setLaunchingId] = useState("");
|
const [launchingId, setLaunchingId] = useState("");
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
const launchAttempts = useRef(new Map<string, {
|
const launchAttempts = useRef(new Map<string, {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
@@ -60,7 +70,19 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
}).
|
}).
|
||||||
finally(() => setLoading(false));
|
finally(() => setLoading(false));
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [includeUnavailable, settings, submittedQuery]);
|
}, [includeUnavailable, reloadKey, settings, submittedQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
listPortalPostboxes(settings, controller.signal)
|
||||||
|
.then((response) => setPostboxes(response.postboxes))
|
||||||
|
.catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Postboxes could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [reloadKey, settings]);
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
available: services.filter((entry) => entry.state === "available").length,
|
available: services.filter((entry) => entry.state === "available").length,
|
||||||
@@ -111,9 +133,15 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="portal-page">
|
<main className="portal-page">
|
||||||
<div className="portal-shell">
|
<WorkspaceFrame className="portal-shell" label="Service directory" interfaceId="portal.service-directory" helpContextId="portal.page.directory" helpModuleId="portal">
|
||||||
<div className="portal-toolbar">
|
<WorkspaceActionBar
|
||||||
<form className="portal-search" onSubmit={submit}>
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => setReloadKey((value) => value + 1), loading }}
|
||||||
|
className="portal-toolbar"
|
||||||
|
contextActions={<>
|
||||||
|
<FilterBar as="form" surface="control" wrap="never" width="wide" className="portal-search" onSubmit={submit}>
|
||||||
<Search size={17} aria-hidden="true" />
|
<Search size={17} aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
@@ -122,17 +150,20 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
placeholder="Search services"
|
placeholder="Search services"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary">Search</Button>
|
<Button type="submit" variant="primary">Search</Button>
|
||||||
</form>
|
</FilterBar>
|
||||||
<DocumentationHelpLink
|
<ToggleSwitch
|
||||||
|
label="Show unavailable services"
|
||||||
|
checked={includeUnavailable}
|
||||||
|
onChange={setIncludeUnavailable}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
title="Service directory"
|
||||||
|
titleLevel={1}
|
||||||
|
titleHelp={<DocumentationHelpLink
|
||||||
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
|
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
|
||||||
label="Open service directory documentation"
|
label="Open service directory documentation"
|
||||||
/>
|
/>}
|
||||||
<ToggleSwitch
|
/>
|
||||||
label="Show unavailable services"
|
|
||||||
checked={includeUnavailable}
|
|
||||||
onChange={setIncludeUnavailable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="portal-result-summary" aria-live="polite">
|
<div className="portal-result-summary" aria-live="polite">
|
||||||
<strong>{counts.available}</strong> available
|
<strong>{counts.available}</strong> available
|
||||||
@@ -146,8 +177,39 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
}
|
}
|
||||||
{loading && <LoadingIndicator label="Loading services" />}
|
{loading && <LoadingIndicator label="Loading services" />}
|
||||||
|
{postboxes.length > 0 && (
|
||||||
|
<section className="portal-postboxes" aria-labelledby="portal-postboxes-heading">
|
||||||
|
<div className="portal-section-heading">
|
||||||
|
<Inbox size={18} aria-hidden="true" />
|
||||||
|
<h2 id="portal-postboxes-heading">{translateText("My function postboxes")}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="portal-postbox-list">
|
||||||
|
{postboxes.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.postbox.id}
|
||||||
|
type="button"
|
||||||
|
className="portal-postbox-entry"
|
||||||
|
onClick={() => navigate(entry.route_path)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{entry.postbox.name}</strong>
|
||||||
|
<small>
|
||||||
|
{[entry.postbox.organization_unit_name, entry.postbox.function_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ") || entry.postbox.address}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<CountBadge className="portal-postbox-count" aria-label={`${entry.unread_count} unread messages`}>
|
||||||
|
{entry.unread_count > 99 ? "99+" : entry.unread_count}
|
||||||
|
</CountBadge>
|
||||||
|
<ArrowUpRight size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{!loading && !error && services.length === 0 &&
|
{!loading && !error && services.length === 0 &&
|
||||||
<div className="portal-empty">No matching services.</div>
|
<StatePanel size="compact" description="No matching services." />
|
||||||
}
|
}
|
||||||
{!loading && services.length > 0 &&
|
{!loading && services.length > 0 &&
|
||||||
<div className="portal-service-list">
|
<div className="portal-service-list">
|
||||||
@@ -162,7 +224,7 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</div>
|
</WorkspaceFrame>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { Clock3, LogIn, Send } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams, useSearchParams } from "react-router";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DescriptionItem,
|
||||||
|
DescriptionList,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
getApplicationStatusAccess,
|
||||||
|
getAuthenticatedApplicationStatus,
|
||||||
|
getPublicApplicationStatus,
|
||||||
|
requestApplicationStatusEmailLink,
|
||||||
|
type PortalApplicationStatus,
|
||||||
|
type PortalApplicationStatusAccess
|
||||||
|
} from "../../api/portal";
|
||||||
|
|
||||||
|
|
||||||
|
export default function PortalStatusPage({ settings }: PlatformRouteContext) {
|
||||||
|
const { trackingId = "" } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get("token") ?? "";
|
||||||
|
const [access, setAccess] = useState<PortalApplicationStatusAccess | null>(null);
|
||||||
|
const [status, setStatus] = useState<PortalApplicationStatus | null>(null);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
if (!trackingId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const nextAccess = await getApplicationStatusAccess(settings, trackingId, signal);
|
||||||
|
setAccess(nextAccess);
|
||||||
|
if (token) {
|
||||||
|
setStatus(await getPublicApplicationStatus(settings, trackingId, token, signal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextAccess.mode === "permanent_link") {
|
||||||
|
setStatus(await getPublicApplicationStatus(settings, trackingId, undefined, signal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settings.accessToken || settings.apiKey) {
|
||||||
|
try {
|
||||||
|
setStatus(await getAuthenticatedApplicationStatus(settings, trackingId, signal));
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Application status could not be loaded.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings, token, trackingId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function requestLink() {
|
||||||
|
if (!email.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const response = await requestApplicationStatusEmailLink(settings, trackingId, email.trim());
|
||||||
|
setNotice(response.message);
|
||||||
|
} catch {
|
||||||
|
setNotice("If the application and email address match, a new short-lived status link will be sent.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="portal-status-page">
|
||||||
|
<WorkspaceFrame
|
||||||
|
height="container"
|
||||||
|
label="Application status"
|
||||||
|
interfaceId="portal.application-status"
|
||||||
|
helpModuleId="portal"
|
||||||
|
helpTopicId="portal.application-status"
|
||||||
|
helpContextId="portal.application-status">
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="workspace"
|
||||||
|
variant="detail"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void load(), loading }}
|
||||||
|
className="portal-status-toolbar"
|
||||||
|
contextActions={<strong>Application status</strong>}
|
||||||
|
/>
|
||||||
|
<PageScrollViewport className="portal-status-viewport">
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{notice && <DismissibleAlert tone="info" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
|
{loading && <LoadingIndicator label="Loading application status" />}
|
||||||
|
{!loading && status && <StatusProjection status={status} />}
|
||||||
|
{!loading && !status && access?.mode === "authenticated" &&
|
||||||
|
<Card title="Sign in to view status" className="portal-status-access-card">
|
||||||
|
<p>This application is configured for authenticated access only. Sign in with the account linked to the submission.</p>
|
||||||
|
<a className="btn btn-primary" href={`/login?next=${encodeURIComponent(window.location.pathname)}`}>
|
||||||
|
<LogIn size={16} aria-hidden="true" />
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
{!loading && !status && access?.mode === "email_link" &&
|
||||||
|
<Card title="Request a short-lived status link" className="portal-status-access-card">
|
||||||
|
<p>Enter the email address linked to the application. The response is identical whether or not the details match.</p>
|
||||||
|
<form onSubmit={(event) => { event.preventDefault(); void requestLink(); }}>
|
||||||
|
<FormField label="Linked email address">
|
||||||
|
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} disabled={sending} required autoComplete="email" />
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" variant="primary" helpContextId="portal.application-status" helpModuleId="portal" disabled={sending || !email.trim()}>
|
||||||
|
<Send size={16} aria-hidden="true" />
|
||||||
|
Send new link
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{access.token_ttl_seconds &&
|
||||||
|
<p className="portal-status-expiry"><Clock3 size={15} aria-hidden="true" />The link remains valid for {durationLabel(access.token_ttl_seconds)} and replaces the previous link.</p>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</WorkspaceFrame>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusProjection({ status }: { status: PortalApplicationStatus }) {
|
||||||
|
return (
|
||||||
|
<div className="portal-status-content">
|
||||||
|
<Card
|
||||||
|
title={status.title}
|
||||||
|
actions={<StatusBadge status={statusTone(status.status)} label={statusLabel(status.status)} />}>
|
||||||
|
<DescriptionList columns={3} collapseAt="workspace" density="compact">
|
||||||
|
<DescriptionItem term="Tracking ID"><code>{status.tracking_id}</code></DescriptionItem>
|
||||||
|
<DescriptionItem term="Last updated">{formatDate(status.updated_at)}</DescriptionItem>
|
||||||
|
{status.receipt_id && <DescriptionItem term="Submission receipt"><code>{status.receipt_id}</code></DescriptionItem>}
|
||||||
|
</DescriptionList>
|
||||||
|
</Card>
|
||||||
|
<Card title="Timeline">
|
||||||
|
{status.timeline.length === 0 && <p>No public status event is available yet.</p>}
|
||||||
|
<ol className="portal-status-timeline">
|
||||||
|
{status.timeline.map((item, index) =>
|
||||||
|
<li key={`${item.status}:${item.occurred_at}:${index}`}>
|
||||||
|
<span className="portal-status-marker" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{statusLabel(item.status)}</strong>
|
||||||
|
<time dateTime={item.occurred_at}>{formatDate(item.occurred_at)}</time>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</Card>
|
||||||
|
<DismissibleAlert tone="info">
|
||||||
|
This page intentionally shows only the public lifecycle, update times, and receipt reference. Form values, evidence, internal notes, actors, and handoff details remain private.
|
||||||
|
</DismissibleAlert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(value: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
submitted: "Application received",
|
||||||
|
validated: "Completeness checked",
|
||||||
|
needs_review: "Under review",
|
||||||
|
accepted: "Approved",
|
||||||
|
rejected: "Decision issued",
|
||||||
|
handed_off: "Further processing",
|
||||||
|
archived: "Procedure closed"
|
||||||
|
};
|
||||||
|
return labels[value] ?? value.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(value: string): string {
|
||||||
|
if (value === "accepted" || value === "archived") return "active";
|
||||||
|
if (value === "rejected") return "warning";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(seconds: number): string {
|
||||||
|
if (seconds % 3600 === 0) return `${seconds / 3600} hour${seconds === 3600 ? "" : "s"}`;
|
||||||
|
return `${Math.round(seconds / 60)} minutes`;
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
/** Module-owned translations for contextual headings. */
|
||||||
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
|
en: {
|
||||||
|
"Service directory": "Service directory",
|
||||||
|
"My function postboxes": "My function postboxes",
|
||||||
|
"Sign in to view status": "Sign in to view status",
|
||||||
|
"Request a short-lived status link": "Request a short-lived status link",
|
||||||
|
"Timeline": "Timeline",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
"Service directory": "Leistungsverzeichnis",
|
||||||
|
"My function postboxes": "Meine Funktionspostfächer",
|
||||||
|
"Sign in to view status": "Anmelden, um den Status anzuzeigen",
|
||||||
|
"Request a short-lived status link": "Kurzlebigen Statuslink anfordern",
|
||||||
|
"Timeline": "Verlauf",
|
||||||
|
},
|
||||||
|
};
|
||||||
+19
-2
@@ -1,15 +1,18 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/portal.css";
|
import "./styles/portal.css";
|
||||||
|
|
||||||
|
|
||||||
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
|
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
|
||||||
|
const PortalStatusPage = lazy(() => import("./features/portal/PortalStatusPage"));
|
||||||
|
|
||||||
export const portalModule: PlatformWebModule = {
|
export const portalModule: PlatformWebModule = {
|
||||||
|
translations: generatedTranslations,
|
||||||
id: "portal",
|
id: "portal",
|
||||||
label: "Services",
|
label: "Services",
|
||||||
version: "0.1.8",
|
version: "0.1.19",
|
||||||
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
|
optionalDependencies: ["access", "services", "cases", "forms", "forms_runtime", "workflow_engine"],
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: "/portal",
|
path: "/portal",
|
||||||
@@ -19,6 +22,13 @@ export const portalModule: PlatformWebModule = {
|
|||||||
render: (context) => createElement(PortalPage, context)
|
render: (context) => createElement(PortalPage, context)
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
publicRoutes: [
|
||||||
|
{
|
||||||
|
path: "/portal/status/:trackingId",
|
||||||
|
order: 12,
|
||||||
|
render: (context) => createElement(PortalStatusPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/portal",
|
to: "/portal",
|
||||||
@@ -43,6 +53,13 @@ export const portalModule: PlatformWebModule = {
|
|||||||
kind: "route",
|
kind: "route",
|
||||||
label: "Service directory",
|
label: "Service directory",
|
||||||
order: 20
|
order: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "portal.application-status",
|
||||||
|
moduleId: "portal",
|
||||||
|
kind: "route",
|
||||||
|
label: "Applicant status",
|
||||||
|
order: 30
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
+141
-36
@@ -4,37 +4,98 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-shell {
|
.portal-status-page {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: var(--surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-toolbar {
|
.portal-status-toolbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-viewport {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-content {
|
||||||
|
display: grid;
|
||||||
|
width: min(920px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-access-card {
|
||||||
|
width: min(620px, 100%);
|
||||||
|
margin: 32px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-access-card form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-access-card .btn,
|
||||||
|
.portal-status-access-card .btn-primary {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-expiry {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 6px;
|
||||||
gap: 16px;
|
color: var(--text-soft);
|
||||||
min-height: 58px;
|
}
|
||||||
padding: 10px 18px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
.portal-status-timeline {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-timeline::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 11px;
|
||||||
|
bottom: 11px;
|
||||||
|
left: 7px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-timeline li {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-marker {
|
||||||
|
z-index: 1;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
border: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius-round);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-search {
|
.portal-status-timeline li > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 2px;
|
||||||
width: min(620px, 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-search input {
|
.portal-status-timeline time {
|
||||||
min-width: 120px;
|
color: var(--text-soft);
|
||||||
flex: 1;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-search { flex: 1 1 620px; }
|
||||||
|
|
||||||
.portal-result-summary {
|
.portal-result-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -56,6 +117,67 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-postboxes {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry:hover,
|
||||||
|
.portal-postbox-entry:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry > span:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-service-entry {
|
.portal-service-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -63,7 +185,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +222,7 @@
|
|||||||
|
|
||||||
.portal-service-metadata span {
|
.portal-service-metadata span {
|
||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -131,20 +253,3 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-empty {
|
|
||||||
padding: 36px 0;
|
|
||||||
color: var(--text-soft);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.portal-toolbar {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user