281 lines
12 KiB
TypeScript
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";
|
|
}
|