157 lines
6.3 KiB
TypeScript
157 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { PLATFORM_URL } from "@/lib/api";
|
|
|
|
// TermsGate blocks the page it wraps until the user has accepted the
|
|
// current terms version. Fetches /cp/auth/terms-status on mount; if
|
|
// the server says accepted=false it renders a modal over the children
|
|
// instead of hiding them entirely — that way the /orgs list is still
|
|
// visible behind the gate so the user understands what they're
|
|
// agreeing to touch.
|
|
//
|
|
// The server is the source of truth; this component is a UX
|
|
// convenience. Org-mutating endpoints should (and do) also enforce
|
|
// ToS via their own DB check so a power-user calling curl can't
|
|
// bypass the gate.
|
|
export function TermsGate({ children }: { children: React.ReactNode }) {
|
|
const [status, setStatus] = useState<"loading" | "accepted" | "pending" | "error">("loading");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(`${PLATFORM_URL}/cp/auth/terms-status`, {
|
|
credentials: "include",
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (cancelled) return;
|
|
if (res.status === 401) {
|
|
// Not signed in — the page this wraps handles redirect to login.
|
|
// Fall through to "accepted" so we don't double-gate anonymous.
|
|
setStatus("accepted");
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
setStatus("error");
|
|
setError(`terms-status: ${res.status}`);
|
|
return;
|
|
}
|
|
const body = (await res.json()) as { accepted?: boolean };
|
|
setStatus(body.accepted ? "accepted" : "pending");
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setStatus("error");
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const accept = async () => {
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`${PLATFORM_URL}/cp/auth/accept-terms`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`${res.status}: ${text}`);
|
|
}
|
|
setStatus("accepted");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Move focus to the "I agree" button when the modal opens (WCAG 2.4.3).
|
|
// The dialog is a hard gate — no Esc dismiss — so we don't need a focus
|
|
// trap loop, just a one-shot focus move into the dialog.
|
|
const agreeButtonRef = useRef<HTMLButtonElement>(null);
|
|
useEffect(() => {
|
|
if (status !== "pending") return;
|
|
const raf = requestAnimationFrame(() => agreeButtonRef.current?.focus());
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [status]);
|
|
|
|
return (
|
|
<>
|
|
{children}
|
|
{status === "pending" && (
|
|
// Backdrop is decorative — does NOT carry aria-hidden anymore.
|
|
// The earlier version put aria-hidden="true" on this wrapper,
|
|
// which hid the dialog AND its descendants from screen readers,
|
|
// making the entire terms-acceptance flow invisible to AT users.
|
|
// Backdrop click intentionally does nothing — this is a hard
|
|
// gate.
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="terms-dialog-title"
|
|
aria-describedby="terms-dialog-body"
|
|
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
|
|
>
|
|
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms & conditions</h2>
|
|
<div id="terms-dialog-body">
|
|
<p className="mt-3 text-sm text-ink-mid">
|
|
Before you create an organization, please review our{" "}
|
|
<a
|
|
href="/legal/terms"
|
|
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Terms of Service
|
|
</a>{" "}
|
|
and{" "}
|
|
<a
|
|
href="/legal/privacy"
|
|
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Privacy Policy
|
|
</a>
|
|
. Click agree to continue.
|
|
</p>
|
|
<p className="mt-3 text-xs text-ink-mid">
|
|
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
|
</p>
|
|
</div>
|
|
{error && <p role="alert" className="mt-3 text-sm text-bad">{error}</p>}
|
|
<div className="mt-5 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
ref={agreeButtonRef}
|
|
onClick={accept}
|
|
disabled={submitting}
|
|
// Hover goes DARKER, not lighter — emerald-500 on white
|
|
// text drops contrast below AA vs emerald-700. Same trap
|
|
// I fixed in ApprovalBanner + ConfirmDialog.
|
|
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
|
>
|
|
{submitting ? "Saving…" : "I agree"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{status === "error" && (
|
|
<div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
|
|
Couldn't check terms status: {error ?? "unknown error"}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|