feat: add configurable application status access

This commit is contained in:
2026-08-19 12:33:34 +02:00
parent d83bb92ec8
commit 87d12519b7
17 changed files with 2073 additions and 9 deletions
+57
View File
@@ -49,6 +49,27 @@ export type FormIntakeProfile = {
metadata: Record<string, unknown>;
};
export type FormStatusAccessMode = "authenticated" | "email_link" | "permanent_link";
export type FormStatusAccessPolicy = {
policy_id: string;
definition_ref: InstitutionalReference;
mode: FormStatusAccessMode;
enabled: boolean;
revision: number;
email_field_key?: string | null;
token_ttl_seconds: number;
request_limit_per_hour: number;
metadata: Record<string, unknown>;
};
export type FormStatusAccessSummary = {
tracking_id: string;
mode: FormStatusAccessMode;
href: string;
enabled: boolean;
};
export type PublicIntakeResult = {
session_id: string;
mode: "anonymous" | "invitation" | "assisted";
@@ -177,6 +198,7 @@ export type FormInstance = {
created_by: string;
changed_by: string;
metadata: Record<string, unknown>;
status_access?: FormStatusAccessSummary | null;
replayed: boolean;
};
@@ -407,6 +429,41 @@ export function createFormIntakeProfile(
});
}
export function listFormStatusAccessPolicies(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ policies: FormStatusAccessPolicy[] }> {
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", { signal });
}
export function saveFormStatusAccessPolicy(
settings: ApiSettings,
options: {
definitionRef: InstitutionalReference;
mode: FormStatusAccessMode;
enabled: boolean;
emailFieldKey?: string;
tokenTtlSeconds: number;
requestLimitPerHour: number;
expectedRevision?: number;
}
): Promise<FormStatusAccessPolicy> {
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", {
method: "PUT",
body: JSON.stringify({
definition_ref: options.definitionRef,
mode: options.mode,
enabled: options.enabled,
email_field_key: options.mode === "email_link" ? options.emailFieldKey?.trim() || null : null,
token_ttl_seconds: options.tokenTtlSeconds,
request_limit_per_hour: options.requestLimitPerHour,
expected_revision: options.expectedRevision ?? null,
recorded_at: new Date().toISOString(),
metadata: {}
})
});
}
export function listAssistedIntakeProfiles(
settings: ApiSettings,
signal?: AbortSignal
@@ -569,6 +569,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<code>{instance.receipt_id}</code>
</div>
}
{!editable && instance.status_access &&
<section className="form-status-access-receipt">
<div>
<strong>Applicant status access</strong>
<span>{statusAccessMessage(instance.status_access.mode)}</span>
</div>
<code>{instance.status_access.tracking_id}</code>
<a className="btn" href={instance.status_access.href}>Open status page</a>
</section>
}
{!editable && definition.handoff_kinds.length > 0 &&
<section className="form-handoffs">
<div className="form-handoff-heading">
@@ -938,6 +948,12 @@ function intakeContext(instance: FormInstance | null): AssistedIntakeContext | n
};
}
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
if (mode === "authenticated") return "Only the bound applicant account can open this status page.";
if (mode === "email_link") return "The applicant uses this tracking ID and linked email address to request an expiring link.";
return "This non-expiring bearer link can be opened by anyone who holds it.";
}
export function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
const fields = new Map(definition.fields.map((field) => [field.key, field]));
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
+10 -1
View File
@@ -1,4 +1,4 @@
import { Link2, RefreshCw, UserRoundPlus } from "lucide-react";
import { Link2, RefreshCw, ShieldCheck, UserRoundPlus } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ActionToolbar,
Button,
@@ -22,6 +22,7 @@ import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
import IntakeProfilesDialog from "./IntakeProfilesDialog";
import AssistedIntakeDialog from "./AssistedIntakeDialog";
import StatusAccessPoliciesDialog from "./StatusAccessPoliciesDialog";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
@@ -36,6 +37,7 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
const [error, setError] = useState("");
const [intakeOpen, setIntakeOpen] = useState(false);
const [assistedOpen, setAssistedOpen] = useState(false);
const [statusAccessOpen, setStatusAccessOpen] = useState(false);
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
const canAssist = hasScope(auth, "forms_runtime:submission:assist")
|| hasScope(auth, "forms_runtime:workspace:write");
@@ -78,6 +80,12 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
Public intake
</Button>
}
{canAdmin &&
<Button onClick={() => setStatusAccessOpen(true)}>
<ShieldCheck size={16} aria-hidden="true" />
Status access
</Button>
}
{canAssist &&
<Button onClick={() => setAssistedOpen(true)}>
<UserRoundPlus size={16} aria-hidden="true" />
@@ -127,6 +135,7 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
</PageScrollViewport>
</WorkspaceFrame>
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
<StatusAccessPoliciesDialog open={statusAccessOpen} settings={settings} onClose={() => setStatusAccessOpen(false)} />
<AssistedIntakeDialog
open={assistedOpen}
settings={settings}
@@ -323,6 +323,16 @@ export default function PublicFormPage({ settings }: PlatformRouteContext) {
<code>{instance.receipt_id}</code>
</div>
}
{!editable && instance.status_access &&
<section className="form-status-access-receipt">
<div>
<strong>Track this application</strong>
<span>{statusAccessMessage(instance.status_access.mode)}</span>
</div>
<code>{instance.status_access.tracking_id}</code>
<a className="btn" href={instance.status_access.href}>Open status page</a>
</section>
}
</div>
}
</PageScrollViewport>
@@ -389,3 +399,9 @@ function rememberSessionToken(publicId: string, token: string) {
function stateLabel(value: string): string {
return `i18n:govoplan-forms-runtime.state_${value}`;
}
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
if (mode === "authenticated") return "Sign in with the linked applicant account to view status.";
if (mode === "email_link") return "Use this tracking ID and the linked email address to request a short-lived status link.";
return "This permanent bearer link does not require sign-in. Store and share it carefully.";
}
@@ -0,0 +1,280 @@
import { Save } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DialogForm,
DialogSection,
DismissibleAlert,
FormField,
FormGrid,
LoadingIndicator,
StatusBadge,
ToggleSwitch,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listFormIntakeDefinitions,
listFormStatusAccessPolicies,
saveFormStatusAccessPolicy,
type FormDefinition,
type FormStatusAccessMode,
type FormStatusAccessPolicy
} from "../../api/formsRuntime";
type StatusAccessPoliciesDialogProps = {
open: boolean;
settings: PlatformRouteContext["settings"];
onClose: () => void;
};
export default function StatusAccessPoliciesDialog({
open,
settings,
onClose
}: StatusAccessPoliciesDialogProps) {
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
const [policies, setPolicies] = useState<FormStatusAccessPolicy[]>([]);
const [definitionKey, setDefinitionKey] = useState("");
const [mode, setMode] = useState<FormStatusAccessMode>("authenticated");
const [emailFieldKey, setEmailFieldKey] = useState("");
const [tokenMinutes, setTokenMinutes] = useState(60);
const [requestLimit, setRequestLimit] = useState(5);
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const [busyKey, setBusyKey] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const [definitionResult, policyResult] = await Promise.all([
listFormIntakeDefinitions(settings, signal),
listFormStatusAccessPolicies(settings, signal)
]);
setDefinitions(definitionResult.definitions);
setPolicies(policyResult.policies);
setDefinitionKey((current) => current || referenceKey(definitionResult.definitions[0]));
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
if (!open) return undefined;
const controller = new AbortController();
void load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Status access policies could not be loaded.");
}
});
return () => controller.abort();
}, [load, open]);
const definition = useMemo(
() => definitions.find((item) => referenceKey(item) === definitionKey),
[definitionKey, definitions]
);
const policy = useMemo(
() => policies.find((item) => referenceKey(item) === definitionKey),
[definitionKey, policies]
);
useEffect(() => {
if (!definitionKey) return;
if (policy) {
setMode(policy.mode);
setEmailFieldKey(policy.email_field_key ?? "");
setTokenMinutes(Math.max(5, Math.round(policy.token_ttl_seconds / 60)));
setRequestLimit(policy.request_limit_per_hour);
setEnabled(policy.enabled);
return;
}
setMode("authenticated");
setEmailFieldKey("");
setTokenMinutes(60);
setRequestLimit(5);
setEnabled(true);
}, [definitionKey, policy]);
async function save() {
if (!definition || (mode === "email_link" && !emailFieldKey)) return;
setBusyKey(definitionKey);
setError("");
setNotice("");
try {
await saveFormStatusAccessPolicy(settings, {
definitionRef: definition.reference,
mode,
enabled,
emailFieldKey,
tokenTtlSeconds: tokenMinutes * 60,
requestLimitPerHour: requestLimit,
expectedRevision: policy?.revision
});
await load();
setNotice("The applicant status access policy was saved.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The status access policy could not be saved.");
} finally {
setBusyKey("");
}
}
async function toggle(item: FormStatusAccessPolicy, nextEnabled: boolean) {
const exactDefinition = definitions.find((candidate) => referenceKey(candidate) === referenceKey(item));
if (!exactDefinition) return;
setBusyKey(item.policy_id);
setError("");
setNotice("");
try {
await saveFormStatusAccessPolicy(settings, {
definitionRef: exactDefinition.reference,
mode: item.mode,
enabled: nextEnabled,
emailFieldKey: item.email_field_key ?? undefined,
tokenTtlSeconds: item.token_ttl_seconds,
requestLimitPerHour: item.request_limit_per_hour,
expectedRevision: item.revision
});
await load();
setNotice(nextEnabled
? "Applicant status access was enabled for future submissions and existing grants."
: "Applicant status access and all existing grants for this exact Form revision were suspended."
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The status access policy could not be changed.");
} finally {
setBusyKey("");
}
}
return (
<Dialog
open={open}
title="Applicant status access"
description="Choose the access and disclosure profile independently for each exact published Form revision."
size="large"
closeDisabled={Boolean(busyKey)}
onClose={onClose}
helpContextId="forms_runtime.status-policy"
helpTopicId="forms_runtime.submissions"
footer={<Button onClick={onClose} disabled={Boolean(busyKey)}>Close</Button>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading applicant status policies" />}
{!loading && definitions.length === 0 &&
<DismissibleAlert tone="info">Publish a Form revision before configuring applicant status.</DismissibleAlert>
}
{!loading && definitions.length > 0 &&
<DialogForm onSubmit={(event) => { event.preventDefault(); void save(); }}>
<DialogSection title={policy ? "Edit policy" : "Add policy"}>
<FormGrid columns={2} gap="small" collapseAt="workspace">
<FormField label="Published Form">
<select value={definitionKey} onChange={(event) => setDefinitionKey(event.target.value)} disabled={Boolean(busyKey)}>
{definitions.map((item) =>
<option key={referenceKey(item)} value={referenceKey(item)}>
{item.title} · revision {item.reference.version}
</option>
)}
</select>
</FormField>
<FormField label="Access profile">
<select value={mode} onChange={(event) => setMode(event.target.value as FormStatusAccessMode)} disabled={Boolean(busyKey)}>
<option value="authenticated">Authenticated applicant only</option>
<option value="email_link">Short-lived link to linked email</option>
<option value="permanent_link">Permanent public bearer link</option>
</select>
</FormField>
{mode === "email_link" &&
<FormField label="Linked email field" help="The final submitted value is compared without disclosing whether a request matched.">
<select value={emailFieldKey} onChange={(event) => setEmailFieldKey(event.target.value)} disabled={Boolean(busyKey)} required>
<option value="">Select a field</option>
{definition?.fields.filter((field) => field.value_type === "email").map((field) =>
<option key={field.key} value={field.key}>{field.label} ({field.key})</option>
)}
</select>
</FormField>
}
{mode === "email_link" &&
<FormField label="Link validity (minutes)">
<input type="number" min={5} max={10_080} value={tokenMinutes} onChange={(event) => setTokenMinutes(Number(event.target.value))} disabled={Boolean(busyKey)} />
</FormField>
}
{mode === "email_link" &&
<FormField label="Requests per hour">
<input type="number" min={1} max={60} value={requestLimit} onChange={(event) => setRequestLimit(Number(event.target.value))} disabled={Boolean(busyKey)} />
</FormField>
}
<FormField label="Policy state">
<ToggleSwitch label="Applicant status enabled" checked={enabled} onChange={setEnabled} disabled={Boolean(busyKey)} />
</FormField>
</FormGrid>
<AccessConsequence mode={mode} />
<div className="form-status-policy-save">
<Button
variant="primary"
type="submit"
disabled={Boolean(busyKey) || !definition || (mode === "email_link" && !emailFieldKey)}>
<Save size={16} aria-hidden="true" />
Save policy
</Button>
</div>
</DialogSection>
<DialogSection title="Configured policies" variant="inset">
{policies.length === 0 && <p className="form-intake-empty">No applicant status policy has been configured.</p>}
<div className="form-status-policy-list">
{policies.map((item) =>
<div className="form-status-policy-row" key={item.policy_id}>
<span>
<strong>{policyTitle(item)}</strong>
<small>Revision {item.definition_ref.version} · {modeLabel(item.mode)}</small>
</span>
<StatusBadge status={item.enabled ? "active" : "inactive"} label={item.enabled ? "Enabled" : "Suspended"} />
<ToggleSwitch
label="Policy enabled"
checked={item.enabled}
disabled={Boolean(busyKey)}
onChange={(value) => void toggle(item, value)}
/>
</div>
)}
</div>
</DialogSection>
</DialogForm>
}
</Dialog>
);
}
function AccessConsequence({ mode }: { mode: FormStatusAccessMode }) {
if (mode === "authenticated") {
return <DismissibleAlert tone="info">Only the bound applicant account can view status. Assisted intake needs an explicit <code>account:</code> party reference for this profile.</DismissibleAlert>;
}
if (mode === "email_link") {
return <DismissibleAlert tone="warning">A matching identifier and email request creates a new expiring secret and revokes the previous one. Notifications and Mail must be configured for delivery.</DismissibleAlert>;
}
return <DismissibleAlert tone="warning">The link does not expire or require sign-in. Anyone holding it can see the bounded status timeline until the policy is suspended.</DismissibleAlert>;
}
function referenceKey(value?: FormDefinition | FormStatusAccessPolicy): string {
if (!value) return "";
const reference = "definition_ref" in value ? value.definition_ref : value.reference;
return `${reference.object_id}:${reference.version ?? ""}`;
}
function policyTitle(policy: FormStatusAccessPolicy): string {
const title = policy.metadata.definition_title;
return typeof title === "string" && title.trim()
? title
: policy.definition_ref.label ?? policy.definition_ref.object_id;
}
function modeLabel(mode: FormStatusAccessMode): string {
if (mode === "authenticated") return "Authenticated applicant";
if (mode === "email_link") return "Short-lived email link";
return "Permanent public link";
}
+73
View File
@@ -278,6 +278,79 @@
font-size: 0.78rem;
}
.form-status-policy-save {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.form-status-access-receipt {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, auto) auto;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.form-status-access-receipt > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.form-status-access-receipt span {
color: var(--text-soft);
}
.form-status-access-receipt code {
overflow-wrap: anywhere;
}
.form-status-policy-list {
display: grid;
gap: 8px;
}
.form-status-policy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(150px, auto);
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.form-status-policy-row > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
}
.form-status-policy-row small {
color: var(--text-soft);
}
@media (max-width: 760px) {
.form-status-access-receipt {
grid-template-columns: minmax(0, 1fr);
}
.form-status-policy-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.form-status-policy-row .toggle-switch {
grid-column: 1 / -1;
}
}
.form-intake-create,
.form-intake-profiles {
display: flex;