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
+297 -5
View File
@@ -17,6 +17,46 @@ export type EvidenceReference = {
evidence_id: string;
tenant_id: string;
version?: string | null;
checksum?: string | null;
source_ref?: string | null;
derived_from?: string[];
responsible_actor_ref?: string | null;
captured_at?: string | null;
inspection_url?: string | null;
};
export type FormEvidenceGrant = {
provider_id: string;
grant_id: string;
upload_token?: string | null;
upload_url: string;
expires_at: string;
max_size_bytes: number;
allowed_content_types: string[];
replayed: boolean;
};
export type FormIntakeProfile = {
profile_id: string;
public_id: string;
definition_ref: InstitutionalReference;
mode: "anonymous" | "invitation";
enabled: boolean;
revision: number;
draft_ttl_seconds: number;
invitation_ttl_seconds: number;
rate_limit_per_minute: number;
metadata: Record<string, unknown>;
};
export type PublicIntakeResult = {
session_id: string;
mode: "anonymous" | "invitation";
status: string;
expires_at: string;
instance?: FormInstance | null;
token?: string | null;
replayed: boolean;
};
export type FormCondition =
@@ -183,15 +223,17 @@ export function saveFormDraft(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>,
changeReason: string
changeReason: string,
attachmentRefs: EvidenceReference[] = instance.attachment_refs,
signatureRefs: EvidenceReference[] = instance.signature_refs
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
method: "PATCH",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: instance.attachment_refs,
signature_refs: instance.signature_refs,
attachment_refs: attachmentRefs,
signature_refs: signatureRefs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString(),
change_reason: changeReason
@@ -202,14 +244,243 @@ export function saveFormDraft(
export function submitFormInstance(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>
values: Record<string, unknown>,
attachmentRefs: EvidenceReference[] = instance.attachment_refs,
signatureRefs: EvidenceReference[] = instance.signature_refs
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: instance.attachment_refs,
attachment_refs: attachmentRefs,
signature_refs: signatureRefs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString()
})
});
}
export function createFormEvidenceGrant(
settings: ApiSettings,
instance: FormInstance,
options: {
idempotencyKey: string;
providerId?: string;
purpose?: string;
allowedContentTypes?: string[];
attachmentRefs?: EvidenceReference[];
}
): Promise<FormEvidenceGrant> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/evidence-grants`,
{
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
provider_id: options.providerId ?? "files",
purpose: options.purpose ?? "Form attachment",
idempotency_key: options.idempotencyKey,
expires_at: new Date(Date.now() + 10 * 60_000).toISOString(),
allowed_content_types: options.allowedContentTypes ?? [],
attachment_refs: options.attachmentRefs ?? instance.attachment_refs
})
}
);
}
export function uploadFormEvidence(
settings: ApiSettings,
grant: FormEvidenceGrant,
file: File
): Promise<{ grant_id: string; evidence: EvidenceReference }> {
if (!grant.upload_token) {
return Promise.reject(new Error("The upload grant secret is no longer available. Request a new grant."));
}
const body = new FormData();
body.append("file", file);
return apiFetch(settings, grant.upload_url, {
method: "POST",
headers: { "X-Form-Evidence-Token": grant.upload_token },
body
});
}
export function acknowledgeFormInstance(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>,
attachmentRefs: EvidenceReference[],
options: { statementId: string; statementVersion: string; idempotencyKey: string }
): Promise<{ evidence: EvidenceReference }> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/acknowledgements`,
{
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
statement_id: options.statementId,
statement_version: options.statementVersion,
values,
attachment_refs: attachmentRefs,
accepted_at: new Date().toISOString(),
idempotency_key: options.idempotencyKey
})
}
);
}
export function listFormIntakeProfiles(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ profiles: FormIntakeProfile[] }> {
return apiFetch(settings, "/api/v1/forms-runtime/intake-profiles", { signal });
}
export function listFormIntakeDefinitions(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ definitions: FormDefinition[] }> {
return apiFetch(settings, "/api/v1/forms-runtime/intake-profile-definitions?limit=200", { signal });
}
export function createFormIntakeProfile(
settings: ApiSettings,
definitionRef: InstitutionalReference,
mode: "anonymous" | "invitation",
options: {
draftTtlSeconds?: number;
invitationTtlSeconds?: number;
rateLimitPerMinute?: number;
} = {}
): Promise<FormIntakeProfile> {
return apiFetch(settings, "/api/v1/forms-runtime/intake-profiles", {
method: "POST",
body: JSON.stringify({
definition_ref: definitionRef,
mode,
draft_ttl_seconds: options.draftTtlSeconds ?? 2_592_000,
invitation_ttl_seconds: options.invitationTtlSeconds ?? 1_209_600,
rate_limit_per_minute: options.rateLimitPerMinute ?? 60,
recorded_at: new Date().toISOString()
})
});
}
export function setFormIntakeProfileEnabled(
settings: ApiSettings,
profile: FormIntakeProfile,
enabled: boolean
): Promise<FormIntakeProfile> {
return apiFetch(
settings,
`/api/v1/forms-runtime/intake-profiles/${encodeURIComponent(profile.profile_id)}`,
{
method: "PATCH",
body: JSON.stringify({ expected_revision: profile.revision, enabled })
}
);
}
export function issueFormIntakeInvitation(
settings: ApiSettings,
profile: FormIntakeProfile,
idempotencyKey: string
): Promise<PublicIntakeResult> {
return apiFetch(
settings,
`/api/v1/forms-runtime/intake-profiles/${encodeURIComponent(profile.profile_id)}/invitations`,
{
method: "POST",
body: JSON.stringify({
idempotency_key: idempotencyKey,
recorded_at: new Date().toISOString()
})
}
);
}
export function startAnonymousFormIntake(
settings: ApiSettings,
publicId: string,
idempotencyKey: string,
recordedAt: string
): Promise<PublicIntakeResult> {
return apiFetch(
settings,
`/api/v1/forms-runtime/public/profiles/${encodeURIComponent(publicId)}/start`,
{
method: "POST",
body: JSON.stringify({
values: {},
idempotency_key: idempotencyKey,
recorded_at: recordedAt
})
}
);
}
export function startInvitationFormIntake(
settings: ApiSettings,
token: string
): Promise<PublicIntakeResult> {
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/start", {
method: "POST",
headers: { "X-Form-Intake-Token": token },
body: JSON.stringify({ values: {}, recorded_at: new Date().toISOString() })
});
}
export function getPublicFormIntake(
settings: ApiSettings,
token: string,
signal?: AbortSignal
): Promise<{ instance: FormInstance; definition: FormDefinition }> {
return apiFetch(settings, "/api/v1/forms-runtime/public/intake", {
headers: { "X-Form-Intake-Token": token },
signal
});
}
export function savePublicFormDraft(
settings: ApiSettings,
token: string,
instance: FormInstance,
values: Record<string, unknown>,
attachmentRefs: EvidenceReference[],
changeReason: string
): Promise<FormInstance> {
return apiFetch(settings, "/api/v1/forms-runtime/public/intake", {
method: "PATCH",
headers: { "X-Form-Intake-Token": token },
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: attachmentRefs,
signature_refs: instance.signature_refs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString(),
change_reason: changeReason
})
});
}
export function submitPublicFormIntake(
settings: ApiSettings,
token: string,
instance: FormInstance,
values: Record<string, unknown>,
attachmentRefs: EvidenceReference[]
): Promise<FormInstance> {
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/submit", {
method: "POST",
headers: { "X-Form-Intake-Token": token },
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: attachmentRefs,
signature_refs: instance.signature_refs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString()
@@ -217,6 +488,27 @@ export function submitFormInstance(
});
}
export function createPublicFormEvidenceGrant(
settings: ApiSettings,
token: string,
instance: FormInstance,
idempotencyKey: string,
attachmentRefs: EvidenceReference[] = instance.attachment_refs
): Promise<FormEvidenceGrant> {
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/evidence-grants", {
method: "POST",
headers: { "X-Form-Intake-Token": token },
body: JSON.stringify({
expected_revision: instance.revision,
provider_id: "files",
purpose: "Public Form attachment",
idempotency_key: idempotencyKey,
expires_at: new Date(Date.now() + 10 * 60_000).toISOString(),
attachment_refs: attachmentRefs
})
});
}
export function listFormHandoffs(
settings: ApiSettings,
instanceId: string,
+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}`;
}
+96 -2
View File
@@ -73,7 +73,54 @@ const en = {
"Select": "Select",
"Compensate handoff": "Compensate handoff",
"Confirm absent and compensate": "Confirm absent and compensate",
"Read-only Form": "Read-only Form"
"Read-only Form": "Read-only Form",
"Public intake": "Public intake",
"Public Form intake": "Public Form intake",
"Loading public intake profiles": "Loading public intake profiles",
"Add intake profile": "Add intake profile",
"Published Form": "Published Form",
"Access mode": "Access mode",
"Invitation link": "Invitation link",
"Open anonymous link": "Open anonymous link",
"Draft retention (days)": "Draft retention (days)",
"Invitation validity (days)": "Invitation validity (days)",
"Starts per minute": "Starts per minute",
"Add profile": "Add profile",
"Configured profiles": "Configured profiles",
"No public intake profile has been configured.": "No public intake profile has been configured.",
"Anonymous link": "Anonymous link",
"Invitation links": "Invitation links",
"Active": "Active",
"Inactive": "Inactive",
"Profile active": "Profile active",
"Issue invitation": "Issue invitation",
"Copy link": "Copy link",
"Reusable public link": "Reusable public link",
"The public intake profile was created.": "The public intake profile was created.",
"The invitation was issued. Copy its link now; the token is not stored in readable form.": "The invitation was issued. Copy its link now; the token is not stored in readable form.",
"The intake link was copied.": "The intake link was copied.",
"Evidence and acknowledgement": "Evidence and acknowledgement",
"Attachments": "Attachments",
"Remove attachment": "Remove attachment",
"Authenticated acknowledgement": "Authenticated acknowledgement",
"Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.": "Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.",
"Renew acknowledgement": "Renew acknowledgement",
"Acknowledge": "Acknowledge",
"Record acknowledgement": "Record acknowledgement",
"File in eAkte": "File in eAkte",
"Record the required acknowledgement before submitting.": "Record the required acknowledgement before submitting.",
"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.": "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.",
"Form": "Form",
"This Form requires an authenticated or external signature.": "This Form requires an authenticated or external signature.",
"The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.": "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
"Use an authenticated service entry or a configured signature provider.": "Use an authenticated service entry or a configured signature provider.",
"Service owner": "Service owner",
"Form intake and signature policy": "Form intake and signature policy",
"The required signature profile is unavailable for this public link.": "The required signature profile is unavailable for this public link.",
"Submission received": "Submission received",
"Receipt": "Receipt",
"Submit Form": "Submit Form",
"Submit this Form? The values and managed attachment references become an immutable submission revision.": "Submit this Form? The values and managed attachment references become an immutable submission revision."
} as const;
const de: Record<keyof typeof en, string> = {
@@ -149,7 +196,54 @@ const de: Record<keyof typeof en, string> = {
"Select": "Auswählen",
"Compensate handoff": "Übergabe kompensieren",
"Confirm absent and compensate": "Fehlen bestätigen und kompensieren",
"Read-only Form": "Schreibgeschütztes Formular"
"Read-only Form": "Schreibgeschütztes Formular",
"Public intake": "Öffentlicher Formulareingang",
"Public Form intake": "Öffentlicher Formulareingang",
"Loading public intake profiles": "Profile für den öffentlichen Formulareingang werden geladen",
"Add intake profile": "Eingangsprofil hinzufügen",
"Published Form": "Veröffentlichtes Formular",
"Access mode": "Zugangsart",
"Invitation link": "Einladungslink",
"Open anonymous link": "Offener anonymer Link",
"Draft retention (days)": "Entwurfsaufbewahrung (Tage)",
"Invitation validity (days)": "Gültigkeit der Einladung (Tage)",
"Starts per minute": "Starts pro Minute",
"Add profile": "Profil hinzufügen",
"Configured profiles": "Konfigurierte Profile",
"No public intake profile has been configured.": "Es ist kein Profil für den öffentlichen Formulareingang konfiguriert.",
"Anonymous link": "Anonymer Link",
"Invitation links": "Einladungslinks",
"Active": "Aktiv",
"Inactive": "Inaktiv",
"Profile active": "Profil aktiv",
"Issue invitation": "Einladung ausstellen",
"Copy link": "Link kopieren",
"Reusable public link": "Wiederverwendbarer öffentlicher Link",
"The public intake profile was created.": "Das Profil für den öffentlichen Formulareingang wurde erstellt.",
"The invitation was issued. Copy its link now; the token is not stored in readable form.": "Die Einladung wurde ausgestellt. Kopieren Sie den Link jetzt; das Token wird nicht lesbar gespeichert.",
"The intake link was copied.": "Der Eingangslink wurde kopiert.",
"Evidence and acknowledgement": "Nachweise und Bestätigung",
"Attachments": "Anhänge",
"Remove attachment": "Anhang entfernen",
"Authenticated acknowledgement": "Authentifizierte Bestätigung",
"Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.": "Erfasst Ihr Konto, die exakten Werte und die exakten verwalteten Anhänge. Dies ist keine qualifizierte elektronische Signatur.",
"Renew acknowledgement": "Bestätigung erneuern",
"Acknowledge": "Bestätigen",
"Record acknowledgement": "Bestätigung erfassen",
"File in eAkte": "In eAkte verakten",
"Record the required acknowledgement before submitting.": "Erfassen Sie vor dem Absenden die erforderliche Bestätigung.",
"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.": "Bestätigen Sie, dass die angezeigten Werte und verwalteten Anhänge richtig und vollständig sind. Dies erfasst eine an die Nutzdaten gebundene authentifizierte Bestätigung; es ist keine qualifizierte elektronische Signatur.",
"Form": "Formular",
"This Form requires an authenticated or external signature.": "Dieses Formular erfordert eine authentifizierte oder externe Signatur.",
"The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.": "Das anonyme beziehungsweise Einladungsprofil darf nicht stillschweigend eine Bestätigung mit geringerem Vertrauensniveau einsetzen.",
"Use an authenticated service entry or a configured signature provider.": "Verwenden Sie einen authentifizierten Diensteinstieg oder einen konfigurierten Signaturanbieter.",
"Service owner": "Dienstverantwortliche Stelle",
"Form intake and signature policy": "Richtlinie für Formulareingang und Signaturen",
"The required signature profile is unavailable for this public link.": "Das erforderliche Signaturprofil ist für diesen öffentlichen Link nicht verfügbar.",
"Submission received": "Übermittlung eingegangen",
"Receipt": "Beleg",
"Submit Form": "Formular absenden",
"Submit this Form? The values and managed attachment references become an immutable submission revision.": "Dieses Formular absenden? Die Werte und Referenzen auf verwaltete Anhänge werden zu einer unveränderlichen Übermittlungsrevision."
};
export const generatedTranslations: PlatformTranslations = { en, de };
+15 -2
View File
@@ -6,6 +6,7 @@ import "./styles/forms-runtime.css";
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
const PublicFormPage = lazy(() => import("./features/forms/PublicFormPage"));
const routeScopes = [
"forms_runtime:submission:participate",
"forms_runtime:workspace:read"
@@ -14,9 +15,9 @@ const routeScopes = [
export const formsRuntimeModule: PlatformWebModule = {
id: "forms_runtime",
label: "i18n:govoplan-forms-runtime.forms",
version: "0.1.14",
version: "0.1.18",
dependencies: ["access", "forms"],
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "records", "policy", "audit"],
translations: generatedTranslations,
routes: [
{
@@ -34,6 +35,18 @@ export const formsRuntimeModule: PlatformWebModule = {
render: (context) => createElement(FormInstancePage, context)
}
],
publicRoutes: [
{
path: "/forms/public/:publicId",
order: 10,
render: (context) => createElement(PublicFormPage, context)
},
{
path: "/forms/intake/:token",
order: 11,
render: (context) => createElement(PublicFormPage, context)
}
],
navItems: [
{
to: "/forms-runtime",
+208
View File
@@ -124,6 +124,66 @@
min-width: 0;
}
.form-public-content {
width: min(100%, 900px);
margin: 0 auto;
}
.form-public-content > header {
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.form-public-content > header h1 {
margin: 0;
font-size: 1.45rem;
letter-spacing: 0;
}
.form-public-content > header p {
max-width: 70ch;
margin: 7px 0 0;
color: var(--text-soft);
}
.form-public-attachments {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 0;
border-top: 1px solid var(--border);
}
.form-public-attachments h2 {
margin: 0;
font-size: 0.98rem;
letter-spacing: 0;
}
.form-public-attachments .file-drop-zone {
min-height: 118px;
}
.form-public-attachment-list {
border-top: 1px solid var(--border);
}
.form-public-attachment-list > div {
display: flex;
min-height: 42px;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.form-public-attachment-list > div > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-instance-main > header {
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
@@ -230,6 +290,135 @@
font-size: 0.84rem;
}
.form-instance-evidence {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
border-top: 1px solid var(--border);
}
.form-evidence-heading,
.form-acknowledgement-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.form-evidence-heading h2,
.form-intake-dialog h3 {
margin: 0;
font-size: 0.98rem;
letter-spacing: 0;
}
.form-evidence-heading > span,
.form-acknowledgement-row small {
color: var(--text-soft);
font-size: 0.78rem;
}
.form-instance-evidence .file-drop-zone {
min-height: 104px;
}
.form-acknowledgement-row {
padding: 12px 0;
border-top: 1px solid var(--border);
}
.form-acknowledgement-row > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.form-intake-dialog {
width: min(980px, calc(100vw - 40px));
}
.form-intake-create,
.form-intake-profiles {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-intake-profiles {
margin-top: 24px;
}
.form-intake-create-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
gap: 12px;
}
.form-intake-create-grid label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.form-intake-create-grid label > span {
color: var(--text-soft);
font-size: 0.8rem;
}
.form-intake-create-button {
justify-self: start;
}
.form-intake-profile-row {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto auto;
align-items: center;
gap: 10px 14px;
min-height: 62px;
padding: 10px 0;
border-top: 1px solid var(--border);
}
.form-intake-profile-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.form-intake-profile-main strong,
.form-intake-profile-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-intake-profile-main small,
.form-intake-empty {
color: var(--text-soft);
font-size: 0.78rem;
}
.form-intake-profile-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 7px;
}
.form-intake-link {
grid-column: 1 / -1;
overflow: hidden;
color: var(--text-soft);
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-receipt {
flex-direction: column;
}
@@ -385,6 +574,25 @@
flex-direction: column;
}
.form-evidence-heading,
.form-acknowledgement-row {
align-items: stretch;
flex-direction: column;
}
.form-intake-create-grid,
.form-intake-profile-row {
grid-template-columns: 1fr;
}
.form-intake-profile-actions {
justify-content: flex-start;
}
.form-intake-link {
grid-column: auto;
}
.form-handoff-create,
.form-handoff-row {
align-items: stretch;