From 89a142ead1707b6077b29a6ef87c5982f6ab2681 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 12:33:34 +0200 Subject: [PATCH] feat: add resident application status portal --- README.md | 5 + docs/SERVICE_DIRECTORY_CONCEPT.md | 25 +++ src/govoplan_portal/backend/manifest.py | 83 ++++++- tests/test_service_directory.py | 9 + webui/src/api/portal.ts | 79 +++++++ .../src/features/portal/PortalStatusPage.tsx | 207 ++++++++++++++++++ webui/src/module.ts | 17 +- webui/src/styles/portal.css | 90 ++++++++ 8 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 webui/src/features/portal/PortalStatusPage.tsx diff --git a/README.md b/README.md index b4e3bcd..914fe37 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,9 @@ replay-safe. A missing owner launcher makes the entry explainably unavailable. Form launch resolves an exact published `/` and returns the 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. + See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md). diff --git a/docs/SERVICE_DIRECTORY_CONCEPT.md b/docs/SERVICE_DIRECTORY_CONCEPT.md index 706d87c..7b09c3e 100644 --- a/docs/SERVICE_DIRECTORY_CONCEPT.md +++ b/docs/SERVICE_DIRECTORY_CONCEPT.md @@ -124,3 +124,28 @@ and Forms Runtime are active. It resolves an exact published `/`, validates launch values, and retains Service/binding provenance. Reduced installations still fail closed rather than simulating a 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. diff --git a/src/govoplan_portal/backend/manifest.py b/src/govoplan_portal/backend/manifest.py index 441e02f..b3431d5 100644 --- a/src/govoplan_portal/backend/manifest.py +++ b/src/govoplan_portal/backend/manifest.py @@ -1,5 +1,9 @@ from __future__ import annotations +from govoplan_core.core.application_status import ( + CAPABILITY_APPLICATION_STATUS_PROJECTION, + application_status_projection_provider, +) from govoplan_core.core.institutional import ( CAPABILITY_SERVICE_AVAILABILITY, CAPABILITY_SERVICE_DEFINITIONS, @@ -19,6 +23,7 @@ from govoplan_core.core.modules import ( NavItem, PermissionDefinition, ProductAreaContribution, + PublicFrontendRoute, RoleTemplate, ) from govoplan_core.core.provider_governance import ( @@ -52,6 +57,27 @@ def _router(context: ModuleContext): 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( id=MODULE_ID, name="Portal", @@ -70,6 +96,7 @@ manifest = ModuleManifest( CAPABILITY_SERVICE_AVAILABILITY, *SERVICE_LAUNCH_CAPABILITIES, CAPABILITY_POSTBOX_PORTAL, + CAPABILITY_APPLICATION_STATUS_PROJECTION, ), permissions=( PermissionDefinition( @@ -113,11 +140,18 @@ manifest = ModuleManifest( 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_PORTAL_SERVICE_DIRECTORY: _service_directory, }, route_factory=_router, + public_tenant_resolver=_public_tenant_resolver, nav_items=( NavItem( path="/portal", @@ -138,6 +172,13 @@ manifest = ModuleManifest( order=25, ), ), + public_routes=( + PublicFrontendRoute( + path="/portal/status/:trackingId", + component="PortalStatusPage", + order=12, + ), + ), nav_items=( NavItem( path="/portal", @@ -173,6 +214,13 @@ manifest = ModuleManifest( label="Service directory", order=20, ), + ViewSurface( + id="portal.application-status", + module_id=MODULE_ID, + kind="route", + label="Applicant status", + order=30, + ), ), ), capability_documentation={ @@ -211,6 +259,36 @@ manifest = ModuleManifest( 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." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("public", "user", "operator", "module_admin"), + 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( id="portal.service-directory", title="Service directory", @@ -258,9 +336,10 @@ manifest = ModuleManifest( known_limits=( "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.", + "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.", ), - owned_concepts=("service discovery", "service presentation", "channel entry"), - non_owned_concepts=("institutional service definition", "case lifecycle"), + owned_concepts=("service discovery", "service presentation", "channel entry", "applicant status presentation"), + non_owned_concepts=("institutional service definition", "case lifecycle", "applicant status access decision"), reference_packages=("product.service-to-decision",), documentation=ModuleArchitectureDocumentation( security=("docs/SERVICE_DIRECTORY_CONCEPT.md",), diff --git a/tests/test_service_directory.py b/tests/test_service_directory.py index dd81cef..2dff6ce 100644 --- a/tests/test_service_directory.py +++ b/tests/test_service_directory.py @@ -431,6 +431,15 @@ class PortalServiceDirectoryTests(unittest.TestCase): {item.scope for item in manifest.permissions}, ) 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}, + ) if __name__ == "__main__": diff --git a/webui/src/api/portal.ts b/webui/src/api/portal.ts index 7a67f3b..8150612 100644 --- a/webui/src/api/portal.ts +++ b/webui/src/api/portal.ts @@ -1,6 +1,11 @@ import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; +function publicSettings(settings: ApiSettings): ApiSettings { + return { ...settings, accessToken: "", apiKey: "" }; +} + + export type PortalServiceBinding = { kind: string; reference: string; @@ -65,6 +70,26 @@ export type PortalServiceLaunchResult = { metadata: Record; }; +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( settings: ApiSettings, options: { @@ -115,3 +140,57 @@ export function launchPortalService( } ); } + +export function getApplicationStatusAccess( + settings: ApiSettings, + trackingId: string, + signal?: AbortSignal +): Promise { + 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 { + 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 { + 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 + }) + } + ); +} diff --git a/webui/src/features/portal/PortalStatusPage.tsx b/webui/src/features/portal/PortalStatusPage.tsx new file mode 100644 index 0000000..1648bce --- /dev/null +++ b/webui/src/features/portal/PortalStatusPage.tsx @@ -0,0 +1,207 @@ +import { Clock3, LogIn, RefreshCw, Send } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { useParams, useSearchParams } from "react-router"; +import { + ActionToolbar, + Button, + Card, + DescriptionItem, + DescriptionList, + DismissibleAlert, + FormField, + LoadingIndicator, + PageScrollViewport, + StatusBadge, + 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(null); + const [status, setStatus] = useState(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 ( +
+ + + Application status + + + + {error && {error}} + {notice && {notice}} + {loading && } + {!loading && status && } + {!loading && !status && access?.mode === "authenticated" && + +

This application is configured for authenticated access only. Sign in with the account linked to the submission.

+ + +
+ } + {!loading && !status && access?.mode === "email_link" && + +

Enter the email address linked to the application. The response is identical whether or not the details match.

+
{ event.preventDefault(); void requestLink(); }}> + + setEmail(event.target.value)} disabled={sending} required autoComplete="email" /> + + +
+ {access.token_ttl_seconds && +

+ } +
+ } +
+
+
+ ); +} + +function StatusProjection({ status }: { status: PortalApplicationStatus }) { + return ( +
+ }> + + {status.tracking_id} + {formatDate(status.updated_at)} + {status.receipt_id && {status.receipt_id}} + + + + {status.timeline.length === 0 &&

No public status event is available yet.

} +
    + {status.timeline.map((item, index) => +
  1. +
  2. + )} +
+
+ + This page intentionally shows only the public lifecycle, update times, and receipt reference. Form values, evidence, internal notes, actors, and handoff details remain private. + +
+ ); +} + +function statusLabel(value: string): string { + const labels: Record = { + 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`; +} diff --git a/webui/src/module.ts b/webui/src/module.ts index c1f3549..fdb83d1 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -4,12 +4,13 @@ import "./styles/portal.css"; const PortalPage = lazy(() => import("./features/portal/PortalPage")); +const PortalStatusPage = lazy(() => import("./features/portal/PortalStatusPage")); export const portalModule: PlatformWebModule = { id: "portal", label: "Services", version: "0.1.8", - optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"], + optionalDependencies: ["access", "services", "cases", "forms", "forms_runtime", "workflow_engine"], routes: [ { path: "/portal", @@ -19,6 +20,13 @@ export const portalModule: PlatformWebModule = { render: (context) => createElement(PortalPage, context) } ], + publicRoutes: [ + { + path: "/portal/status/:trackingId", + order: 12, + render: (context) => createElement(PortalStatusPage, context) + } + ], navItems: [ { to: "/portal", @@ -43,6 +51,13 @@ export const portalModule: PlatformWebModule = { kind: "route", label: "Service directory", order: 20 + }, + { + id: "portal.application-status", + moduleId: "portal", + kind: "route", + label: "Applicant status", + order: 30 } ] }; diff --git a/webui/src/styles/portal.css b/webui/src/styles/portal.css index 82d7f8e..1f74340 100644 --- a/webui/src/styles/portal.css +++ b/webui/src/styles/portal.css @@ -4,6 +4,96 @@ overflow: hidden; } +.portal-status-page { + height: 100%; + min-height: 0; +} + +.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; + align-items: center; + gap: 6px; + color: var(--text-soft); +} + +.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); +} + +.portal-status-timeline li > div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.portal-status-timeline time { + color: var(--text-soft); + font-size: 0.82rem; +} + .portal-search { flex: 1 1 620px; } .portal-result-summary {