feat: add resident application status portal
This commit is contained in:
@@ -33,4 +33,9 @@ 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.
|
||||||
|
|
||||||
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
|
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
|
||||||
|
|||||||
@@ -124,3 +124,28 @@ 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.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
from __future__ import annotations
|
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 (
|
from govoplan_core.core.institutional import (
|
||||||
CAPABILITY_SERVICE_AVAILABILITY,
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
CAPABILITY_SERVICE_DEFINITIONS,
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
@@ -19,6 +23,7 @@ from govoplan_core.core.modules import (
|
|||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
PublicFrontendRoute,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
@@ -52,6 +57,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",
|
||||||
@@ -70,6 +96,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_SERVICE_AVAILABILITY,
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
*SERVICE_LAUNCH_CAPABILITIES,
|
*SERVICE_LAUNCH_CAPABILITIES,
|
||||||
CAPABILITY_POSTBOX_PORTAL,
|
CAPABILITY_POSTBOX_PORTAL,
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
),
|
),
|
||||||
permissions=(
|
permissions=(
|
||||||
PermissionDefinition(
|
PermissionDefinition(
|
||||||
@@ -113,11 +140,18 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
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",
|
||||||
@@ -138,6 +172,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",
|
||||||
@@ -173,6 +214,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={
|
||||||
@@ -211,6 +259,36 @@ manifest = ModuleManifest(
|
|||||||
metadata={"kind": "guide", "help_contexts": ["portal.postboxes"]},
|
metadata={"kind": "guide", "help_contexts": ["portal.postboxes"]},
|
||||||
order=20,
|
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(
|
DocumentationTopic(
|
||||||
id="portal.service-directory",
|
id="portal.service-directory",
|
||||||
title="Service directory",
|
title="Service directory",
|
||||||
@@ -258,9 +336,10 @@ 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.",
|
||||||
),
|
),
|
||||||
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",),
|
||||||
|
|||||||
@@ -431,6 +431,15 @@ 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},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -65,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: {
|
||||||
@@ -115,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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<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">
|
||||||
|
<ActionToolbar surface="workspace" className="portal-status-toolbar">
|
||||||
|
<strong>Application status</strong>
|
||||||
|
<Button onClick={() => void load()} disabled={loading}>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
</ActionToolbar>
|
||||||
|
<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" 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`;
|
||||||
|
}
|
||||||
+16
-1
@@ -4,12 +4,13 @@ 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 = {
|
||||||
id: "portal",
|
id: "portal",
|
||||||
label: "Services",
|
label: "Services",
|
||||||
version: "0.1.8",
|
version: "0.1.8",
|
||||||
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
|
optionalDependencies: ["access", "services", "cases", "forms", "forms_runtime", "workflow_engine"],
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: "/portal",
|
path: "/portal",
|
||||||
@@ -19,6 +20,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 +51,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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,96 @@
|
|||||||
overflow: hidden;
|
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-search { flex: 1 1 620px; }
|
||||||
|
|
||||||
.portal-result-summary {
|
.portal-result-summary {
|
||||||
|
|||||||
Reference in New Issue
Block a user