Files
govoplan-forms-runtime/webui/src/features/forms/StatusAccessPoliciesDialog.tsx
T

281 lines
12 KiB
TypeScript

import { Save } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DialogForm,
DialogSection,
DismissibleAlert,
FormField,
FormGrid,
LoadingIndicator,
StatusBadge,
ToggleSwitch,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listFormIntakeDefinitions,
listFormStatusAccessPolicies,
saveFormStatusAccessPolicy,
type FormDefinition,
type FormStatusAccessMode,
type FormStatusAccessPolicy
} from "../../api/formsRuntime";
type StatusAccessPoliciesDialogProps = {
open: boolean;
settings: PlatformRouteContext["settings"];
onClose: () => void;
};
export default function StatusAccessPoliciesDialog({
open,
settings,
onClose
}: StatusAccessPoliciesDialogProps) {
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
const [policies, setPolicies] = useState<FormStatusAccessPolicy[]>([]);
const [definitionKey, setDefinitionKey] = useState("");
const [mode, setMode] = useState<FormStatusAccessMode>("authenticated");
const [emailFieldKey, setEmailFieldKey] = useState("");
const [tokenMinutes, setTokenMinutes] = useState(60);
const [requestLimit, setRequestLimit] = useState(5);
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const [busyKey, setBusyKey] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const [definitionResult, policyResult] = await Promise.all([
listFormIntakeDefinitions(settings, signal),
listFormStatusAccessPolicies(settings, signal)
]);
setDefinitions(definitionResult.definitions);
setPolicies(policyResult.policies);
setDefinitionKey((current) => current || referenceKey(definitionResult.definitions[0]));
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
if (!open) return undefined;
const controller = new AbortController();
void load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Status access policies could not be loaded.");
}
});
return () => controller.abort();
}, [load, open]);
const definition = useMemo(
() => definitions.find((item) => referenceKey(item) === definitionKey),
[definitionKey, definitions]
);
const policy = useMemo(
() => policies.find((item) => referenceKey(item) === definitionKey),
[definitionKey, policies]
);
useEffect(() => {
if (!definitionKey) return;
if (policy) {
setMode(policy.mode);
setEmailFieldKey(policy.email_field_key ?? "");
setTokenMinutes(Math.max(5, Math.round(policy.token_ttl_seconds / 60)));
setRequestLimit(policy.request_limit_per_hour);
setEnabled(policy.enabled);
return;
}
setMode("authenticated");
setEmailFieldKey("");
setTokenMinutes(60);
setRequestLimit(5);
setEnabled(true);
}, [definitionKey, policy]);
async function save() {
if (!definition || (mode === "email_link" && !emailFieldKey)) return;
setBusyKey(definitionKey);
setError("");
setNotice("");
try {
await saveFormStatusAccessPolicy(settings, {
definitionRef: definition.reference,
mode,
enabled,
emailFieldKey,
tokenTtlSeconds: tokenMinutes * 60,
requestLimitPerHour: requestLimit,
expectedRevision: policy?.revision
});
await load();
setNotice("The applicant status access policy was saved.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The status access policy could not be saved.");
} finally {
setBusyKey("");
}
}
async function toggle(item: FormStatusAccessPolicy, nextEnabled: boolean) {
const exactDefinition = definitions.find((candidate) => referenceKey(candidate) === referenceKey(item));
if (!exactDefinition) return;
setBusyKey(item.policy_id);
setError("");
setNotice("");
try {
await saveFormStatusAccessPolicy(settings, {
definitionRef: exactDefinition.reference,
mode: item.mode,
enabled: nextEnabled,
emailFieldKey: item.email_field_key ?? undefined,
tokenTtlSeconds: item.token_ttl_seconds,
requestLimitPerHour: item.request_limit_per_hour,
expectedRevision: item.revision
});
await load();
setNotice(nextEnabled
? "Applicant status access was enabled for future submissions and existing grants."
: "Applicant status access and all existing grants for this exact Form revision were suspended."
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The status access policy could not be changed.");
} finally {
setBusyKey("");
}
}
return (
<Dialog
open={open}
title="Applicant status access"
description="Choose the access and disclosure profile independently for each exact published Form revision."
size="large"
closeDisabled={Boolean(busyKey)}
onClose={onClose}
helpContextId="forms_runtime.status-policy"
helpTopicId="forms_runtime.submissions"
footer={<Button onClick={onClose} disabled={Boolean(busyKey)}>Close</Button>}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading applicant status policies" />}
{!loading && definitions.length === 0 &&
<DismissibleAlert tone="info">Publish a Form revision before configuring applicant status.</DismissibleAlert>
}
{!loading && definitions.length > 0 &&
<DialogForm onSubmit={(event) => { event.preventDefault(); void save(); }}>
<DialogSection title={policy ? "Edit policy" : "Add policy"}>
<FormGrid columns={2} gap="small" collapseAt="workspace">
<FormField label="Published Form">
<select value={definitionKey} onChange={(event) => setDefinitionKey(event.target.value)} disabled={Boolean(busyKey)}>
{definitions.map((item) =>
<option key={referenceKey(item)} value={referenceKey(item)}>
{item.title} · revision {item.reference.version}
</option>
)}
</select>
</FormField>
<FormField label="Access profile">
<select value={mode} onChange={(event) => setMode(event.target.value as FormStatusAccessMode)} disabled={Boolean(busyKey)}>
<option value="authenticated">Authenticated applicant only</option>
<option value="email_link">Short-lived link to linked email</option>
<option value="permanent_link">Permanent public bearer link</option>
</select>
</FormField>
{mode === "email_link" &&
<FormField label="Linked email field" help="The final submitted value is compared without disclosing whether a request matched.">
<select value={emailFieldKey} onChange={(event) => setEmailFieldKey(event.target.value)} disabled={Boolean(busyKey)} required>
<option value="">Select a field</option>
{definition?.fields.filter((field) => field.value_type === "email").map((field) =>
<option key={field.key} value={field.key}>{field.label} ({field.key})</option>
)}
</select>
</FormField>
}
{mode === "email_link" &&
<FormField label="Link validity (minutes)">
<input type="number" min={5} max={10_080} value={tokenMinutes} onChange={(event) => setTokenMinutes(Number(event.target.value))} disabled={Boolean(busyKey)} />
</FormField>
}
{mode === "email_link" &&
<FormField label="Requests per hour">
<input type="number" min={1} max={60} value={requestLimit} onChange={(event) => setRequestLimit(Number(event.target.value))} disabled={Boolean(busyKey)} />
</FormField>
}
<FormField label="Policy state">
<ToggleSwitch label="Applicant status enabled" checked={enabled} onChange={setEnabled} disabled={Boolean(busyKey)} />
</FormField>
</FormGrid>
<AccessConsequence mode={mode} />
<div className="form-status-policy-save">
<Button
variant="primary"
type="submit"
disabled={Boolean(busyKey) || !definition || (mode === "email_link" && !emailFieldKey)}>
<Save size={16} aria-hidden="true" />
Save policy
</Button>
</div>
</DialogSection>
<DialogSection title="Configured policies" variant="inset">
{policies.length === 0 && <p className="form-intake-empty">No applicant status policy has been configured.</p>}
<div className="form-status-policy-list">
{policies.map((item) =>
<div className="form-status-policy-row" key={item.policy_id}>
<span>
<strong>{policyTitle(item)}</strong>
<small>Revision {item.definition_ref.version} · {modeLabel(item.mode)}</small>
</span>
<StatusBadge status={item.enabled ? "active" : "inactive"} label={item.enabled ? "Enabled" : "Suspended"} />
<ToggleSwitch
label="Policy enabled"
checked={item.enabled}
disabled={Boolean(busyKey)}
onChange={(value) => void toggle(item, value)}
/>
</div>
)}
</div>
</DialogSection>
</DialogForm>
}
</Dialog>
);
}
function AccessConsequence({ mode }: { mode: FormStatusAccessMode }) {
if (mode === "authenticated") {
return <DismissibleAlert tone="info">Only the bound applicant account can view status. Assisted intake needs an explicit <code>account:</code> party reference for this profile.</DismissibleAlert>;
}
if (mode === "email_link") {
return <DismissibleAlert tone="warning">A matching identifier and email request creates a new expiring secret and revokes the previous one. Notifications and Mail must be configured for delivery.</DismissibleAlert>;
}
return <DismissibleAlert tone="warning">The link does not expire or require sign-in. Anyone holding it can see the bounded status timeline until the policy is suspended.</DismissibleAlert>;
}
function referenceKey(value?: FormDefinition | FormStatusAccessPolicy): string {
if (!value) return "";
const reference = "definition_ref" in value ? value.definition_ref : value.reference;
return `${reference.object_id}:${reference.version ?? ""}`;
}
function policyTitle(policy: FormStatusAccessPolicy): string {
const title = policy.metadata.definition_title;
return typeof title === "string" && title.trim()
? title
: policy.definition_ref.label ?? policy.definition_ref.object_id;
}
function modeLabel(mode: FormStatusAccessMode): string {
if (mode === "authenticated") return "Authenticated applicant";
if (mode === "email_link") return "Short-lived email link";
return "Permanent public link";
}