feat: add configurable application status access
This commit is contained in:
@@ -49,6 +49,27 @@ export type FormIntakeProfile = {
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FormStatusAccessMode = "authenticated" | "email_link" | "permanent_link";
|
||||
|
||||
export type FormStatusAccessPolicy = {
|
||||
policy_id: string;
|
||||
definition_ref: InstitutionalReference;
|
||||
mode: FormStatusAccessMode;
|
||||
enabled: boolean;
|
||||
revision: number;
|
||||
email_field_key?: string | null;
|
||||
token_ttl_seconds: number;
|
||||
request_limit_per_hour: number;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FormStatusAccessSummary = {
|
||||
tracking_id: string;
|
||||
mode: FormStatusAccessMode;
|
||||
href: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PublicIntakeResult = {
|
||||
session_id: string;
|
||||
mode: "anonymous" | "invitation" | "assisted";
|
||||
@@ -177,6 +198,7 @@ export type FormInstance = {
|
||||
created_by: string;
|
||||
changed_by: string;
|
||||
metadata: Record<string, unknown>;
|
||||
status_access?: FormStatusAccessSummary | null;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
@@ -407,6 +429,41 @@ export function createFormIntakeProfile(
|
||||
});
|
||||
}
|
||||
|
||||
export function listFormStatusAccessPolicies(
|
||||
settings: ApiSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ policies: FormStatusAccessPolicy[] }> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", { signal });
|
||||
}
|
||||
|
||||
export function saveFormStatusAccessPolicy(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
definitionRef: InstitutionalReference;
|
||||
mode: FormStatusAccessMode;
|
||||
enabled: boolean;
|
||||
emailFieldKey?: string;
|
||||
tokenTtlSeconds: number;
|
||||
requestLimitPerHour: number;
|
||||
expectedRevision?: number;
|
||||
}
|
||||
): Promise<FormStatusAccessPolicy> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/status-access/policies", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
definition_ref: options.definitionRef,
|
||||
mode: options.mode,
|
||||
enabled: options.enabled,
|
||||
email_field_key: options.mode === "email_link" ? options.emailFieldKey?.trim() || null : null,
|
||||
token_ttl_seconds: options.tokenTtlSeconds,
|
||||
request_limit_per_hour: options.requestLimitPerHour,
|
||||
expected_revision: options.expectedRevision ?? null,
|
||||
recorded_at: new Date().toISOString(),
|
||||
metadata: {}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function listAssistedIntakeProfiles(
|
||||
settings: ApiSettings,
|
||||
signal?: AbortSignal
|
||||
|
||||
@@ -569,6 +569,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
<code>{instance.receipt_id}</code>
|
||||
</div>
|
||||
}
|
||||
{!editable && instance.status_access &&
|
||||
<section className="form-status-access-receipt">
|
||||
<div>
|
||||
<strong>Applicant status access</strong>
|
||||
<span>{statusAccessMessage(instance.status_access.mode)}</span>
|
||||
</div>
|
||||
<code>{instance.status_access.tracking_id}</code>
|
||||
<a className="btn" href={instance.status_access.href}>Open status page</a>
|
||||
</section>
|
||||
}
|
||||
{!editable && definition.handoff_kinds.length > 0 &&
|
||||
<section className="form-handoffs">
|
||||
<div className="form-handoff-heading">
|
||||
@@ -938,6 +948,12 @@ function intakeContext(instance: FormInstance | null): AssistedIntakeContext | n
|
||||
};
|
||||
}
|
||||
|
||||
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
|
||||
if (mode === "authenticated") return "Only the bound applicant account can open this status page.";
|
||||
if (mode === "email_link") return "The applicant uses this tracking ID and linked email address to request an expiring link.";
|
||||
return "This non-expiring bearer link can be opened by anyone who holds it.";
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link2, RefreshCw, UserRoundPlus } from "lucide-react";
|
||||
import { Link2, RefreshCw, ShieldCheck, UserRoundPlus } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActionToolbar,
|
||||
Button,
|
||||
@@ -22,6 +22,7 @@ import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
|
||||
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
|
||||
import IntakeProfilesDialog from "./IntakeProfilesDialog";
|
||||
import AssistedIntakeDialog from "./AssistedIntakeDialog";
|
||||
import StatusAccessPoliciesDialog from "./StatusAccessPoliciesDialog";
|
||||
|
||||
|
||||
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
|
||||
@@ -36,6 +37,7 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
|
||||
const [error, setError] = useState("");
|
||||
const [intakeOpen, setIntakeOpen] = useState(false);
|
||||
const [assistedOpen, setAssistedOpen] = useState(false);
|
||||
const [statusAccessOpen, setStatusAccessOpen] = useState(false);
|
||||
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
|
||||
const canAssist = hasScope(auth, "forms_runtime:submission:assist")
|
||||
|| hasScope(auth, "forms_runtime:workspace:write");
|
||||
@@ -78,6 +80,12 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
|
||||
Public intake
|
||||
</Button>
|
||||
}
|
||||
{canAdmin &&
|
||||
<Button onClick={() => setStatusAccessOpen(true)}>
|
||||
<ShieldCheck size={16} aria-hidden="true" />
|
||||
Status access
|
||||
</Button>
|
||||
}
|
||||
{canAssist &&
|
||||
<Button onClick={() => setAssistedOpen(true)}>
|
||||
<UserRoundPlus size={16} aria-hidden="true" />
|
||||
@@ -127,6 +135,7 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
|
||||
</PageScrollViewport>
|
||||
</WorkspaceFrame>
|
||||
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
|
||||
<StatusAccessPoliciesDialog open={statusAccessOpen} settings={settings} onClose={() => setStatusAccessOpen(false)} />
|
||||
<AssistedIntakeDialog
|
||||
open={assistedOpen}
|
||||
settings={settings}
|
||||
|
||||
@@ -323,6 +323,16 @@ export default function PublicFormPage({ settings }: PlatformRouteContext) {
|
||||
<code>{instance.receipt_id}</code>
|
||||
</div>
|
||||
}
|
||||
{!editable && instance.status_access &&
|
||||
<section className="form-status-access-receipt">
|
||||
<div>
|
||||
<strong>Track this application</strong>
|
||||
<span>{statusAccessMessage(instance.status_access.mode)}</span>
|
||||
</div>
|
||||
<code>{instance.status_access.tracking_id}</code>
|
||||
<a className="btn" href={instance.status_access.href}>Open status page</a>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
@@ -389,3 +399,9 @@ function rememberSessionToken(publicId: string, token: string) {
|
||||
function stateLabel(value: string): string {
|
||||
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||
}
|
||||
|
||||
function statusAccessMessage(mode: "authenticated" | "email_link" | "permanent_link"): string {
|
||||
if (mode === "authenticated") return "Sign in with the linked applicant account to view status.";
|
||||
if (mode === "email_link") return "Use this tracking ID and the linked email address to request a short-lived status link.";
|
||||
return "This permanent bearer link does not require sign-in. Store and share it carefully.";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
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";
|
||||
}
|
||||
@@ -278,6 +278,79 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.form-status-policy-save {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.form-status-access-receipt {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, auto) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.form-status-access-receipt > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.form-status-access-receipt span {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.form-status-access-receipt code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.form-status-policy-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-status-policy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(150px, auto);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.form-status-policy-row > span:first-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-status-policy-row small {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.form-status-access-receipt {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.form-status-policy-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.form-status-policy-row .toggle-switch {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.form-intake-create,
|
||||
.form-intake-profiles {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user