import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { Navigate, useNavigate, useParams } from "react-router"; import { Button } from "@/client/components/ui/button"; import { Card } from "@/client/components/ui/card"; import { Input } from "@/client/components/ui/input"; import { PageError } from "@/client/components/ui/page-error"; import { TurnstileWidget } from "@/client/components/turnstile-widget"; import { useSession } from "@/client/lib/auth-client"; import { useClientConfig } from "@/client/lib/config"; import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; import { queryKeys } from "@/client/lib/query"; type LookupState = | { status: "loading" } | { status: "valid"; email: string; expiresAt: string } | { status: "not_found" } | { status: "expired" } | { status: "consumed" }; const TURNSTILE_UNAVAILABLE_MESSAGE = "Human verification is unavailable. Invite acceptance is disabled until this is fixed; contact an administrator."; export const InviteAcceptPage = () => { const navigate = useNavigate(); const { token } = useParams(); const session = useSession(); const { data: lookup = { status: "loading" } } = useQuery({ queryKey: queryKeys.inviteLookup(token ?? null), queryFn: async (): Promise => { if (!token) return { status: "not_found" }; try { const res = await apiRequest(`/api/invite/${encodeURIComponent(token)}`); if (res.ok) { const body = (await res.json()) as { email: string; expiresAt: string }; return { status: "valid", email: body.email, expiresAt: body.expiresAt }; } if (res.status === 410) { const body = (await res.json().catch(() => null)) as { error?: string } | null; return body?.error === "invite_expired" ? { status: "expired" } : { status: "consumed" }; } return { status: "not_found" }; } catch { return { status: "not_found" }; } }, }); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [turnstileToken, setTurnstileToken] = useState(null); const [resetKey, setResetKey] = useState(0); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const config = useClientConfig(); // Local widget-side failure (script load, runtime error) is tracked // separately from the /api/config signal: /api/config can succeed but // the Turnstile script can still fail to mount. const [widgetUnavailable, setWidgetUnavailable] = useState(false); const siteKey = config?.status === "ok" ? config.turnstileSiteKey : null; const turnstileUnavailable = config?.status === "unavailable" || widgetUnavailable; // Surface the turnstile-unavailable banner without round-tripping // through useState — the form is already disabled in that branch. const displayError = error ?? (turnstileUnavailable ? TURNSTILE_UNAVAILABLE_MESSAGE : null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!turnstileToken) { setError("Please complete the verification challenge."); return; } setSubmitting(true); try { const res = await apiJsonRequest( `/api/invite/${encodeURIComponent(token!)}`, { name, password, turnstileToken }, { method: "POST" }, ); if (res.status === 410) { // Mirrors the lookup path: an invite consumed/expired between // GET (form render) and POST (submit) routes the user to the // dedicated terminal page so the URL bar reflects a durable // shareable state instead of stranding them on a dead form. const body = (await res.json().catch(() => null)) as { error?: string } | null; const target = body?.error === "invite_expired" ? "/invite/expired" : "/invite/consumed"; navigate(target, { replace: true }); return; } if (!res.ok) { setError(await responseMessage(res, "Could not accept invite.")); setResetKey((n) => n + 1); return; } // Better Auth's autoSignIn sets the cookie on this response, but the // nanostore behind useSession doesn't observe cookie changes — refetch // before navigating so /account doesn't bounce to /sign-in. Same // pattern as sign-in.tsx after a successful credential sign-in. // `replace: true` so Back doesn't return to /invite/. await session.refetch(); navigate("/account", { replace: true }); } catch { setError("Network error. Please try again."); setResetKey((n) => n + 1); } finally { setSubmitting(false); } }; if (lookup.status === "loading") { return (

Validating invite…

); } if (lookup.status === "expired" || lookup.status === "consumed") { // Dedicated editorial pages per the design doc — replace the URL so // the page-and-token combination is shareable in its terminal state. return ; } if (lookup.status === "not_found") { return (

Invite not found

We couldn’t find that invite. The link may be malformed or revoked. Check it with the person who sent it.

Back to sign in
); } return (

Accept your invite

tessera is the single sign-on for the limic suite — anvil, bland, flamemail, and friends. Set up your credentials and you’ll use them everywhere.

You can link GitHub or Google after signing in.

setError(null)} />
setName(e.target.value)} /> setPassword(e.target.value)} />
{turnstileUnavailable ? (

Verification is unavailable. Acceptance is disabled.

) : siteKey ? ( setWidgetUnavailable(true)} resetKey={resetKey} /> ) : (

Loading verification…

)}
); };