Add governed public form intake

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent d4bbdd079f
commit 3c292b8c07
25 changed files with 4715 additions and 47 deletions
+179 -20
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
import { Archive, ArrowLeft, BadgeCheck, ExternalLink, RefreshCw, Save, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
@@ -7,6 +7,7 @@ import {
ConfirmDialog,
DocumentationHelpLink,
DismissibleAlert,
FileDropZone,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
@@ -14,6 +15,7 @@ import {
hasScope,
i18nMessage,
useGuardedNavigate,
usePlatformModuleInstalled,
usePlatformLanguage,
useUnsavedDraftGuard,
type PlatformRouteContext
@@ -26,9 +28,13 @@ import {
listFormHandoffs,
startFormHandoff,
actOnFormHandoff,
acknowledgeFormInstance,
compensateFormHandoff,
createFormEvidenceGrant,
saveFormDraft,
submitFormInstance,
uploadFormEvidence,
type EvidenceReference,
type FormDefinition,
type FormFieldDefinition,
type FormInstance,
@@ -47,12 +53,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const { instanceId = "" } = useParams();
const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const recordsAvailable = usePlatformModuleInstalled("records");
const [instance, setInstance] = useState<FormInstance | null>(null);
const [definition, setDefinition] = useState<FormDefinition | null>(null);
const [history, setHistory] = useState<FormInstance[]>([]);
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
const [handoffs, setHandoffs] = useState<FormHandoff[]>([]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
const [signatureRefs, setSignatureRefs] = useState<EvidenceReference[]>([]);
const [changeReason, setChangeReason] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -63,6 +73,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
const [confirmingAcknowledgement, setConfirmingAcknowledgement] = useState(false);
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
@@ -81,6 +92,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
setEvents(nextEvents.events);
setHandoffs(nextHandoffs.handoffs);
setValues(nextInstance.values);
setAttachmentRefs(nextInstance.attachment_refs);
setSignatureRefs(nextInstance.signature_refs);
setChangeReason("");
} finally {
setLoading(false);
@@ -112,8 +125,12 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const editable = Boolean(editableLifecycle && canEditPermission);
const canSave = Boolean(instance?.status === "draft" && definition?.allow_drafts && canEditPermission);
const changed = useMemo(
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
[instance, values]
() => Boolean(instance && (
JSON.stringify(values) !== JSON.stringify(instance.values)
|| JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs)
|| JSON.stringify(signatureRefs) !== JSON.stringify(instance.signature_refs)
)),
[attachmentRefs, instance, signatureRefs, values]
);
const diagnostics = useMemo(() => {
const grouped = new Map<string, ValidationResult[]>();
@@ -133,13 +150,18 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
);
const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref);
const canHandoff = canWrite || canAdmin;
const signatureRequired = definition?.signature_requirement === "required";
const signatureBlocked = Boolean(signatureRequired && signatureRefs.length === 0);
const attachmentLimitReached = Boolean(
definition && attachmentRefs.length >= definition.max_attachments
);
async function save(): Promise<boolean> {
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
setSaving(true);
setError("");
try {
await saveFormDraft(settings, instance, values, changeReason.trim());
await saveFormDraft(settings, instance, values, changeReason.trim(), attachmentRefs, signatureRefs);
await load();
return true;
} catch (reason) {
@@ -155,6 +177,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
onSave: save,
onDiscard: () => {
setValues(instance?.values ?? {});
setAttachmentRefs(instance?.attachment_refs ?? []);
setSignatureRefs(instance?.signature_refs ?? []);
setChangeReason("");
},
title: "i18n:govoplan-forms-runtime.unsaved_title",
@@ -166,7 +190,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
setSaving(true);
setError("");
try {
await submitFormInstance(settings, instance, values);
await submitFormInstance(settings, instance, values, attachmentRefs, signatureRefs);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
@@ -175,6 +199,73 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
}
function changeValue(fieldKey: string, value: unknown) {
setValues((current) => {
const next = { ...current };
if (value === undefined || value === "") delete next[fieldKey];
else next[fieldKey] = value;
return next;
});
setSignatureRefs([]);
}
async function uploadAttachments(files: File[]) {
if (!instance || !definition) return;
const available = Math.max(0, definition.max_attachments - attachmentRefs.length);
const selected = files.slice(0, available);
if (selected.length === 0) return;
setSaving(true);
setError("");
try {
const uploaded: EvidenceReference[] = [];
const names: Record<string, string> = {};
for (const file of selected) {
const grant = await createFormEvidenceGrant(settings, instance, {
idempotencyKey: crypto.randomUUID(),
purpose: "Form attachment",
attachmentRefs: [...attachmentRefs, ...uploaded]
});
const result = await uploadFormEvidence(settings, grant, file);
uploaded.push(result.evidence);
names[result.evidence.evidence_id] = file.name;
}
setAttachmentRefs((current) => [...current, ...uploaded]);
setAttachmentNames((current) => ({ ...current, ...names }));
setSignatureRefs([]);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded.");
} finally {
setSaving(false);
}
}
async function acknowledge() {
if (!instance || !editable) return;
setSaving(true);
setError("");
try {
const result = await acknowledgeFormInstance(
settings,
instance,
values,
attachmentRefs,
{
statementId: "forms_runtime.submission.correct_and_complete",
statementVersion: "1",
idempotencyKey: crypto.randomUUID()
}
);
setSignatureRefs((current) => [
...current.filter((item) => item.owner_module !== "forms_runtime"),
result.evidence
]);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The acknowledgement could not be recorded.");
} finally {
setSaving(false);
}
}
async function startHandoff() {
if (!instance || !mayHandoff) return;
setHandoffBusy(true);
@@ -227,6 +318,12 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
</Button>
{definition && <strong>{localized.title}</strong>}
{instance && <StatusBadge status={editableLifecycle ? "active" : "inactive"} label={stateLabel(instance.status)} />}
{instance && recordsAvailable && !editableLifecycle &&
<Button onClick={() => navigate(recordFilingPath(instance))}>
<Archive size={16} aria-hidden="true" />
File in eAkte
</Button>
}
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
</div>
<PageScrollViewport className="form-instance-viewport">
@@ -277,22 +374,61 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
value={values[field.key]}
disabled={!editable || saving}
diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => {
const next = { ...current };
if (value === undefined || value === "") delete next[field.key];
else next[field.key] = value;
return next;
})}
onChange={(value) => changeValue(field.key, value)}
/>
)}
</div>
</section>
)}
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
<div className="form-evidence-summary">
<span>{instance.attachment_refs.length} attachments</span>
<span>{instance.signature_refs.length} signatures</span>
</div>
{(definition.max_attachments > 0 || definition.signature_requirement !== "none") &&
<section className="form-instance-evidence">
<div className="form-evidence-heading">
<h2>Evidence and acknowledgement</h2>
<span>{attachmentRefs.length} attachments · {signatureRefs.length} acknowledgements</span>
</div>
{editable && definition.max_attachments > 0 &&
<FileDropZone
multiple
busy={saving}
disabled={attachmentLimitReached}
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
onFiles={uploadAttachments}
/>
}
{attachmentRefs.length > 0 &&
<div className="form-public-attachment-list">
{attachmentRefs.map((reference) =>
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
{editable &&
<Button
variant="danger"
aria-label="Remove attachment"
disabled={saving}
onClick={() => {
setAttachmentRefs((current) => current.filter((item) => item !== reference));
setSignatureRefs([]);
}}>
<Trash2 size={15} aria-hidden="true" />
</Button>
}
</div>
)}
</div>
}
{editable && definition.signature_requirement !== "none" &&
<div className="form-acknowledgement-row">
<span>
<strong>Authenticated acknowledgement</strong>
<small>Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.</small>
</span>
<Button disabled={saving} onClick={() => setConfirmingAcknowledgement(true)}>
<BadgeCheck size={16} aria-hidden="true" />
{signatureRefs.some((item) => item.owner_module === "forms_runtime") ? "Renew acknowledgement" : "Acknowledge"}
</Button>
</div>
}
</section>
}
{editable &&
<div className="form-instance-actions">
@@ -316,7 +452,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
Save draft
</Button>
}
<Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : undefined}>
<Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving || signatureBlocked} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : signatureBlocked ? "Record the required acknowledgement before submitting." : undefined}>
<Send size={16} aria-hidden="true" />
Submit
</Button>
@@ -392,6 +528,18 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
</PageScrollViewport>
</div>
<ConfirmDialog
open={confirmingAcknowledgement}
title="Record acknowledgement"
message="Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature."
confirmLabel="Acknowledge"
busy={saving}
onCancel={() => setConfirmingAcknowledgement(false)}
onConfirm={() => {
setConfirmingAcknowledgement(false);
void acknowledge();
}}
/>
<ConfirmDialog
open={confirmingSubmit}
title="i18n:govoplan-forms-runtime.submit_title"
@@ -430,7 +578,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
);
}
function FormField({
export function FormField({
field,
value,
disabled,
@@ -580,7 +728,7 @@ function numericConstraint(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
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);
if (!definition.pages?.length) {
@@ -640,7 +788,7 @@ function comparable(left: unknown, right: unknown, compare: (left: number | stri
return false;
}
function localizeDefinition(definition: FormDefinition | null, language: string) {
export function localizeDefinition(definition: FormDefinition | null, language: string) {
const canonical = {
title: definition?.title ?? "",
description: definition?.description ?? null,
@@ -676,6 +824,17 @@ function stateLabel(value: string): string {
return `i18n:govoplan-forms-runtime.state_${value}`;
}
function recordFilingPath(instance: FormInstance): string {
const query = new URLSearchParams({
sourceModule: "forms_runtime",
resourceType: "form_submission_revision",
resourceId: instance.instance_id,
sourceRevision: String(instance.revision),
sourceLabel: instance.receipt_id ? `Form submission ${instance.receipt_id}` : "Form submission"
});
return `/records?${query.toString()}`;
}
function domainLabel(value: string): string {
return `i18n:govoplan-forms-runtime.domain_${value}`;
}
+13 -2
View File
@@ -1,4 +1,4 @@
import { RefreshCw } from "lucide-react";
import { Link2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
@@ -7,6 +7,7 @@ import {
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
i18nMessage,
useGuardedNavigate,
usePlatformLanguage,
@@ -14,11 +15,12 @@ import {
} from "@govoplan/core-webui";
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
import IntakeProfilesDialog from "./IntakeProfilesDialog";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
export default function FormsRuntimePage({ settings, auth }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const [items, setItems] = useState<FormInstance[]>([]);
@@ -26,6 +28,8 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
const [status, setStatus] = useState("open");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [intakeOpen, setIntakeOpen] = useState(false);
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
@@ -59,6 +63,12 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
{canAdmin &&
<Button onClick={() => setIntakeOpen(true)}>
<Link2 size={16} aria-hidden="true" />
Public intake
</Button>
}
<label>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
@@ -107,6 +117,7 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
}
</PageScrollViewport>
</div>
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
</main>
);
}
@@ -0,0 +1,254 @@
import { Copy, Link, Plus, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
LoadingIndicator,
StatusBadge,
ToggleSwitch,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createFormIntakeProfile,
issueFormIntakeInvitation,
listFormIntakeDefinitions,
listFormIntakeProfiles,
setFormIntakeProfileEnabled,
type FormDefinition,
type FormIntakeProfile
} from "../../api/formsRuntime";
type IntakeProfilesDialogProps = {
open: boolean;
settings: PlatformRouteContext["settings"];
onClose: () => void;
};
export default function IntakeProfilesDialog({ open, settings, onClose }: IntakeProfilesDialogProps) {
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
const [definitionId, setDefinitionId] = useState("");
const [mode, setMode] = useState<"anonymous" | "invitation">("invitation");
const [draftDays, setDraftDays] = useState(30);
const [invitationDays, setInvitationDays] = useState(14);
const [rateLimit, setRateLimit] = useState(60);
const [invitationLinks, setInvitationLinks] = useState<Record<string, string>>({});
const [busyKey, setBusyKey] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const definitionsByKey = useMemo(
() => new Map(definitions.map((item) => [definitionKey(item), item])),
[definitions]
);
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const [profileResult, definitionResult] = await Promise.all([
listFormIntakeProfiles(settings, signal),
listFormIntakeDefinitions(settings, signal)
]);
setProfiles(profileResult.profiles);
setDefinitions(definitionResult.definitions);
setDefinitionId((current) => current || definitionKey(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 : "Public intake profiles could not be loaded.");
}
});
return () => controller.abort();
}, [load, open]);
async function createProfile() {
const definition = definitionsByKey.get(definitionId);
if (!definition) return;
setBusyKey("create");
setError("");
setNotice("");
try {
await createFormIntakeProfile(settings, definition.reference, mode, {
draftTtlSeconds: Math.round(draftDays * 86_400),
invitationTtlSeconds: Math.round(invitationDays * 86_400),
rateLimitPerMinute: rateLimit
});
await load();
setNotice("The public intake profile was created.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The public intake profile could not be created.");
} finally {
setBusyKey("");
}
}
async function setEnabled(profile: FormIntakeProfile, enabled: boolean) {
setBusyKey(`state:${profile.profile_id}`);
setError("");
try {
const updated = await setFormIntakeProfileEnabled(settings, profile, enabled);
setProfiles((current) => current.map((item) => item.profile_id === updated.profile_id ? updated : item));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The public intake profile could not be updated.");
} finally {
setBusyKey("");
}
}
async function issueInvitation(profile: FormIntakeProfile) {
setBusyKey(`invite:${profile.profile_id}`);
setError("");
setNotice("");
try {
const result = await issueFormIntakeInvitation(settings, profile, crypto.randomUUID());
if (!result.token) throw new Error("The invitation was recorded, but its one-time secret is no longer available.");
setInvitationLinks((current) => ({
...current,
[profile.profile_id]: absolutePath(`/forms/intake/${encodeURIComponent(result.token!)}`)
}));
setNotice("The invitation was issued. Copy its link now; the token is not stored in readable form.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The invitation could not be issued.");
} finally {
setBusyKey("");
}
}
async function copy(value: string) {
if (!navigator.clipboard?.writeText) return;
try {
await navigator.clipboard.writeText(value);
setNotice("The intake link was copied.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The intake link could not be copied.");
}
}
return (
<Dialog
open={open}
title="Public Form intake"
className="form-intake-dialog"
closeDisabled={Boolean(busyKey)}
onClose={onClose}
helpContextId="forms_runtime.public-intake"
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 public intake profiles" />}
{!loading &&
<>
<section className="form-intake-create">
<h3>Add intake profile</h3>
<div className="form-intake-create-grid">
<label>
<span>Published Form</span>
<select value={definitionId} onChange={(event) => setDefinitionId(event.target.value)} disabled={Boolean(busyKey)}>
{definitions.map((item) =>
<option key={definitionKey(item)} value={definitionKey(item)}>
{item.title} (revision {item.reference.version})
</option>
)}
</select>
</label>
<label>
<span>Access mode</span>
<select value={mode} onChange={(event) => setMode(event.target.value as "anonymous" | "invitation")} disabled={Boolean(busyKey)}>
<option value="invitation">Invitation link</option>
<option value="anonymous">Open anonymous link</option>
</select>
</label>
<label>
<span>Draft retention (days)</span>
<input type="number" min={1} max={365} value={draftDays} onChange={(event) => setDraftDays(Number(event.target.value))} disabled={Boolean(busyKey)} />
</label>
<label>
<span>Invitation validity (days)</span>
<input type="number" min={1} max={90} value={invitationDays} onChange={(event) => setInvitationDays(Number(event.target.value))} disabled={Boolean(busyKey) || mode === "anonymous"} />
</label>
<label>
<span>Starts per minute</span>
<input type="number" min={1} max={10_000} value={rateLimit} onChange={(event) => setRateLimit(Number(event.target.value))} disabled={Boolean(busyKey)} />
</label>
<Button
variant="primary"
className="form-intake-create-button"
disabled={Boolean(busyKey) || !definitionId || !validSettings(draftDays, invitationDays, rateLimit)}
onClick={() => void createProfile()}>
<Plus size={16} aria-hidden="true" />
Add profile
</Button>
</div>
</section>
<section className="form-intake-profiles">
<h3>Configured profiles</h3>
{profiles.length === 0 && <p className="form-intake-empty">No public intake profile has been configured.</p>}
{profiles.map((profile) => {
const definition = definitionsByKey.get(referenceKey(profile));
const link = profile.mode === "anonymous"
? absolutePath(`/forms/public/${encodeURIComponent(profile.public_id)}`)
: invitationLinks[profile.profile_id];
const busy = busyKey.endsWith(profile.profile_id);
return (
<div className="form-intake-profile-row" key={profile.profile_id}>
<span className="form-intake-profile-main">
<strong>{definition?.title ?? profile.definition_ref.label ?? profile.definition_ref.object_id}</strong>
<small>Revision {profile.definition_ref.version} · {profile.mode === "anonymous" ? "Anonymous link" : "Invitation links"}</small>
</span>
<StatusBadge status={profile.enabled ? "active" : "inactive"} label={profile.enabled ? "Active" : "Inactive"} />
<ToggleSwitch label="Profile active" checked={profile.enabled} disabled={Boolean(busyKey)} onChange={(enabled) => void setEnabled(profile, enabled)} />
<span className="form-intake-profile-actions">
{profile.mode === "invitation" &&
<Button disabled={!profile.enabled || Boolean(busyKey)} onClick={() => void issueInvitation(profile)}>
<Send size={15} aria-hidden="true" />
Issue invitation
</Button>
}
{link &&
<Button disabled={busy || typeof navigator === "undefined" || !navigator.clipboard?.writeText} onClick={() => void copy(link)}>
<Copy size={15} aria-hidden="true" />
Copy link
</Button>
}
{profile.mode === "anonymous" && <Link size={16} aria-label="Reusable public link" />}
</span>
{link && <code className="form-intake-link">{link}</code>}
</div>
);
})}
</section>
</>
}
</Dialog>
);
}
function definitionKey(definition?: FormDefinition): string {
return definition ? `${definition.reference.object_id}:${definition.reference.version ?? ""}` : "";
}
function referenceKey(profile: FormIntakeProfile): string {
return `${profile.definition_ref.object_id}:${profile.definition_ref.version ?? ""}`;
}
function absolutePath(path: string): string {
return typeof window === "undefined" ? path : new URL(path, window.location.origin).toString();
}
function validSettings(draftDays: number, invitationDays: number, rateLimit: number): boolean {
return Number.isFinite(draftDays) && draftDays >= 1 && draftDays <= 365
&& Number.isFinite(invitationDays) && invitationDays >= 1 && invitationDays <= 90
&& Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= 10_000;
}
+391
View File
@@ -0,0 +1,391 @@
import { Save, Send, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
ActionBlockerHint,
Button,
ConfirmDialog,
DismissibleAlert,
FileDropZone,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
usePlatformLanguage,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
createPublicFormEvidenceGrant,
getPublicFormIntake,
savePublicFormDraft,
startAnonymousFormIntake,
startInvitationFormIntake,
submitPublicFormIntake,
uploadFormEvidence,
type EvidenceReference,
type FormDefinition,
type FormInstance,
type ValidationResult
} from "../../api/formsRuntime";
import {
FormField,
localizeDefinition,
visibleGroups
} from "./FormInstancePage";
type AnonymousStartAttempt = {
idempotencyKey: string;
recordedAt: string;
};
export default function PublicFormPage({ settings }: PlatformRouteContext) {
const { publicId = "", token: invitationToken = "" } = useParams();
const { language } = usePlatformLanguage();
const [token, setToken] = useState(invitationToken);
const [instance, setInstance] = useState<FormInstance | null>(null);
const [definition, setDefinition] = useState<FormDefinition | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
useEffect(() => {
const controller = new AbortController();
let active = true;
async function initialize() {
setLoading(true);
setError("");
try {
let activeToken = invitationToken;
if (activeToken) {
await startInvitationFormIntake(settings, activeToken);
} else if (publicId) {
activeToken = readSessionToken(publicId);
if (!activeToken) {
const attempt = anonymousStartAttempt(publicId);
const started = await startAnonymousFormIntake(
settings,
publicId,
attempt.idempotencyKey,
attempt.recordedAt
);
activeToken = started.token ?? "";
if (!activeToken) {
throw new Error("This Form was already opened in another browser session. Use the original session or request a new link.");
}
rememberSessionToken(publicId, activeToken);
clearAnonymousStartAttempt(publicId);
}
}
if (!activeToken) throw new Error("This Form link is incomplete.");
const loaded = await getPublicFormIntake(settings, activeToken, controller.signal);
if (!active) return;
setToken(activeToken);
applyLoaded(loaded.instance, loaded.definition);
} catch (reason) {
if (active && (reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "The public Form could not be loaded.");
}
} finally {
if (active) setLoading(false);
}
}
function applyLoaded(nextInstance: FormInstance, nextDefinition: FormDefinition) {
setInstance(nextInstance);
setDefinition(nextDefinition);
setValues(nextInstance.values);
setAttachmentRefs(nextInstance.attachment_refs);
}
void initialize();
return () => {
active = false;
controller.abort();
};
}, [invitationToken, publicId, settings]);
const editable = Boolean(instance && ["started", "draft"].includes(instance.status));
const changed = Boolean(
instance && (
JSON.stringify(values) !== JSON.stringify(instance.values)
|| JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs)
)
);
const localized = useMemo(
() => localizeDefinition(definition, language),
[definition, language]
);
const groups = useMemo(
() => definition ? visibleGroups(definition, values) : [],
[definition, values]
);
const diagnostics = useMemo(() => {
const grouped = new Map<string, ValidationResult[]>();
for (const item of instance?.validation_results ?? []) {
const key = item.field ?? "";
grouped.set(key, [...(grouped.get(key) ?? []), item]);
}
return grouped;
}, [instance]);
const signatureBlocked = Boolean(
definition?.signature_requirement === "required"
&& (instance?.signature_refs.length ?? 0) === 0
);
const attachmentLimitReached = Boolean(
definition && attachmentRefs.length >= definition.max_attachments
);
async function reload() {
if (!token) return;
const loaded = await getPublicFormIntake(settings, token);
setInstance(loaded.instance);
setDefinition(loaded.definition);
setValues(loaded.instance.values);
setAttachmentRefs(loaded.instance.attachment_refs);
}
async function save() {
if (!instance || !token || !definition?.allow_drafts || !changed) return;
setBusy(true);
setError("");
try {
await savePublicFormDraft(
settings,
token,
instance,
values,
attachmentRefs,
"Public participant saved the draft."
);
await reload();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
} finally {
setBusy(false);
}
}
async function submit() {
if (!instance || !token || !editable || signatureBlocked) return;
setBusy(true);
setError("");
try {
await submitPublicFormIntake(settings, token, instance, values, attachmentRefs);
await reload();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
} finally {
setBusy(false);
}
}
async function upload(files: File[]) {
if (!instance || !token || !definition) return;
const available = Math.max(0, definition.max_attachments - attachmentRefs.length);
const selected = files.slice(0, available);
if (selected.length === 0) return;
setBusy(true);
setError("");
try {
const uploaded: EvidenceReference[] = [];
const names: Record<string, string> = {};
for (const file of selected) {
const grant = await createPublicFormEvidenceGrant(
settings,
token,
instance,
crypto.randomUUID(),
[...attachmentRefs, ...uploaded]
);
const result = await uploadFormEvidence(settings, grant, file);
uploaded.push(result.evidence);
names[result.evidence.evidence_id] = file.name;
}
setAttachmentRefs((current) => [...current, ...uploaded]);
setAttachmentNames((current) => ({ ...current, ...names }));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded.");
} finally {
setBusy(false);
}
}
return (
<main className="forms-runtime-page forms-public-page">
<div className="form-instance-shell">
<div className="form-instance-toolbar">
<strong>{localized.title || "Form"}</strong>
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={stateLabel(instance.status)} />}
</div>
<PageScrollViewport className="form-instance-viewport">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading Form" />}
{!loading && instance && definition &&
<div className="form-public-content">
<header>
<h1>{localized.title}</h1>
{localized.description && <p>{localized.description}</p>}
</header>
{groups.map((group) =>
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
{(groups.length > 1 || definition.pages?.length) &&
<header>
<h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>
{group.description && <p>{group.description}</p>}
</header>
}
<div className="form-fields">
{group.fields.map((field) =>
<FormField
key={field.key}
field={{
...field,
label: localized.fieldLabels[field.key] ?? field.label,
help_text: localized.fieldHelpTexts[field.key] ?? field.help_text
}}
optionLabels={localized.optionLabels[field.key] ?? {}}
value={values[field.key]}
disabled={!editable || busy}
diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => {
const next = { ...current };
if (value === undefined || value === "") delete next[field.key];
else next[field.key] = value;
return next;
})}
/>
)}
</div>
</section>
)}
{definition.max_attachments > 0 && editable &&
<section className="form-public-attachments">
<h2>Attachments</h2>
<FileDropZone
multiple
busy={busy}
disabled={attachmentLimitReached}
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
onFiles={upload}
/>
<div className="form-public-attachment-list">
{attachmentRefs.map((reference) =>
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
<Button
variant="danger"
aria-label="Remove attachment"
disabled={busy}
onClick={() => setAttachmentRefs((current) => current.filter((item) => item !== reference))}>
<Trash2 size={15} aria-hidden="true" />
</Button>
</div>
)}
</div>
</section>
}
{signatureBlocked &&
<ActionBlockerHint
tone="warning"
reason={{
summary: "This Form requires an authenticated or external signature.",
details: "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
requiredAction: "Use an authenticated service entry or a configured signature provider.",
actor: "Service owner",
target: "Form intake and signature policy"
}}
/>
}
{editable &&
<div className="form-instance-actions">
{definition.allow_drafts &&
<Button disabled={busy || !changed} onClick={() => void save()}>
<Save size={16} aria-hidden="true" />
Save draft
</Button>
}
<Button
variant="primary"
disabled={busy || signatureBlocked}
disabledReason={signatureBlocked ? "The required signature profile is unavailable for this public link." : undefined}
onClick={() => setConfirmingSubmit(true)}>
<Send size={16} aria-hidden="true" />
Submit
</Button>
</div>
}
{!editable && instance.receipt_id &&
<div className="form-receipt">
<strong>Submission received</strong>
<span>Receipt</span>
<code>{instance.receipt_id}</code>
</div>
}
</div>
}
</PageScrollViewport>
</div>
<ConfirmDialog
open={confirmingSubmit}
title="Submit Form"
message="Submit this Form? The values and managed attachment references become an immutable submission revision."
confirmLabel="Submit"
busy={busy}
onCancel={() => setConfirmingSubmit(false)}
onConfirm={() => {
setConfirmingSubmit(false);
void submit();
}}
/>
</main>
);
}
function anonymousStartAttempt(publicId: string): AnonymousStartAttempt {
const key = `govoplan.forms-runtime.start.${publicId}`;
try {
const stored = sessionStorage.getItem(key);
if (stored) return JSON.parse(stored) as AnonymousStartAttempt;
const attempt = {
idempotencyKey: crypto.randomUUID(),
recordedAt: new Date().toISOString()
};
sessionStorage.setItem(key, JSON.stringify(attempt));
return attempt;
} catch {
return {
idempotencyKey: crypto.randomUUID(),
recordedAt: new Date().toISOString()
};
}
}
function clearAnonymousStartAttempt(publicId: string) {
try {
sessionStorage.removeItem(`govoplan.forms-runtime.start.${publicId}`);
} catch {
// Private browsing may deny session storage; the active token still works.
}
}
function readSessionToken(publicId: string): string {
try {
return sessionStorage.getItem(`govoplan.forms-runtime.token.${publicId}`) ?? "";
} catch {
return "";
}
}
function rememberSessionToken(publicId: string, token: string) {
try {
sessionStorage.setItem(`govoplan.forms-runtime.token.${publicId}`, token);
} catch {
// Keep the token in component memory when session storage is unavailable.
}
}
function stateLabel(value: string): string {
return `i18n:govoplan-forms-runtime.state_${value}`;
}