Add governed public form intake
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user