File
Blob: src/client/pages/invite/accept.tsx
| 1 | import { useQuery } from "@tanstack/react-query"; |
| 2 | import { useState } from "react"; |
| 3 | import { Navigate, useNavigate, useParams } from "react-router"; |
| 4 | |
| 5 | import { Button } from "@/client/components/ui/button"; |
| 6 | import { Card } from "@/client/components/ui/card"; |
| 7 | import { Input } from "@/client/components/ui/input"; |
| 8 | import { PageError } from "@/client/components/ui/page-error"; |
| 9 | import { TurnstileWidget } from "@/client/components/turnstile-widget"; |
| 10 | import { useSession } from "@/client/lib/auth-client"; |
| 11 | import { useClientConfig } from "@/client/lib/config"; |
| 12 | import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; |
| 13 | import { queryKeys } from "@/client/lib/query"; |
| 14 | |
| 15 | type LookupState = |
| 16 | | { status: "loading" } |
| 17 | | { status: "valid"; email: string; expiresAt: string } |
| 18 | | { status: "not_found" } |
| 19 | | { status: "expired" } |
| 20 | | { status: "consumed" }; |
| 21 | |
| 22 | const TURNSTILE_UNAVAILABLE_MESSAGE = |
| 23 | "Human verification is unavailable. Invite acceptance is disabled until this is fixed; contact an administrator."; |
| 24 | |
| 25 | export const InviteAcceptPage = () => { |
| 26 | const navigate = useNavigate(); |
| 27 | const { token } = useParams(); |
| 28 | const session = useSession(); |
| 29 | const { data: lookup = { status: "loading" } } = useQuery({ |
| 30 | queryKey: queryKeys.inviteLookup(token ?? null), |
| 31 | queryFn: async (): Promise<LookupState> => { |
| 32 | if (!token) return { status: "not_found" }; |
| 33 | try { |
| 34 | const res = await apiRequest(`/api/invite/${encodeURIComponent(token)}`); |
| 35 | if (res.ok) { |
| 36 | const body = (await res.json()) as { email: string; expiresAt: string }; |
| 37 | return { status: "valid", email: body.email, expiresAt: body.expiresAt }; |
| 38 | } |
| 39 | if (res.status === 410) { |
| 40 | const body = (await res.json().catch(() => null)) as { error?: string } | null; |
| 41 | return body?.error === "invite_expired" ? { status: "expired" } : { status: "consumed" }; |
| 42 | } |
| 43 | return { status: "not_found" }; |
| 44 | } catch { |
| 45 | return { status: "not_found" }; |
| 46 | } |
| 47 | }, |
| 48 | }); |
| 49 | |
| 50 | const [name, setName] = useState(""); |
| 51 | const [password, setPassword] = useState(""); |
| 52 | const [turnstileToken, setTurnstileToken] = useState<string | null>(null); |
| 53 | const [resetKey, setResetKey] = useState(0); |
| 54 | const [submitting, setSubmitting] = useState(false); |
| 55 | const [error, setError] = useState<string | null>(null); |
| 56 | const config = useClientConfig(); |
| 57 | // Local widget-side failure (script load, runtime error) is tracked |
| 58 | // separately from the /api/config signal: /api/config can succeed but |
| 59 | // the Turnstile script can still fail to mount. |
| 60 | const [widgetUnavailable, setWidgetUnavailable] = useState(false); |
| 61 | const siteKey = config?.status === "ok" ? config.turnstileSiteKey : null; |
| 62 | const turnstileUnavailable = config?.status === "unavailable" || widgetUnavailable; |
| 63 | |
| 64 | // Surface the turnstile-unavailable banner without round-tripping |
| 65 | // through useState — the form is already disabled in that branch. |
| 66 | const displayError = error ?? (turnstileUnavailable ? TURNSTILE_UNAVAILABLE_MESSAGE : null); |
| 67 | |
| 68 | const handleSubmit = async (e: React.FormEvent) => { |
| 69 | e.preventDefault(); |
| 70 | setError(null); |
| 71 | if (!turnstileToken) { |
| 72 | setError("Please complete the verification challenge."); |
| 73 | return; |
| 74 | } |
| 75 | setSubmitting(true); |
| 76 | try { |
| 77 | const res = await apiJsonRequest( |
| 78 | `/api/invite/${encodeURIComponent(token!)}`, |
| 79 | { name, password, turnstileToken }, |
| 80 | { method: "POST" }, |
| 81 | ); |
| 82 | if (res.status === 410) { |
| 83 | // Mirrors the lookup path: an invite consumed/expired between |
| 84 | // GET (form render) and POST (submit) routes the user to the |
| 85 | // dedicated terminal page so the URL bar reflects a durable |
| 86 | // shareable state instead of stranding them on a dead form. |
| 87 | const body = (await res.json().catch(() => null)) as { error?: string } | null; |
| 88 | const target = body?.error === "invite_expired" ? "/invite/expired" : "/invite/consumed"; |
| 89 | navigate(target, { replace: true }); |
| 90 | return; |
| 91 | } |
| 92 | if (!res.ok) { |
| 93 | setError(await responseMessage(res, "Could not accept invite.")); |
| 94 | setResetKey((n) => n + 1); |
| 95 | return; |
| 96 | } |
| 97 | // Better Auth's autoSignIn sets the cookie on this response, but the |
| 98 | // nanostore behind useSession doesn't observe cookie changes — refetch |
| 99 | // before navigating so /account doesn't bounce to /sign-in. Same |
| 100 | // pattern as sign-in.tsx after a successful credential sign-in. |
| 101 | // `replace: true` so Back doesn't return to /invite/<consumed-token>. |
| 102 | await session.refetch(); |
| 103 | navigate("/account", { replace: true }); |
| 104 | } catch { |
| 105 | setError("Network error. Please try again."); |
| 106 | setResetKey((n) => n + 1); |
| 107 | } finally { |
| 108 | setSubmitting(false); |
| 109 | } |
| 110 | }; |
| 111 | |
| 112 | if (lookup.status === "loading") { |
| 113 | return ( |
| 114 | <div className="mx-auto max-w-md py-12"> |
| 115 | <p className="text-sm text-zinc-400">Validating invite…</p> |
| 116 | </div> |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | if (lookup.status === "expired" || lookup.status === "consumed") { |
| 121 | // Dedicated editorial pages per the design doc — replace the URL so |
| 122 | // the page-and-token combination is shareable in its terminal state. |
| 123 | return <Navigate to={lookup.status === "expired" ? "/invite/expired" : "/invite/consumed"} replace />; |
| 124 | } |
| 125 | |
| 126 | if (lookup.status === "not_found") { |
| 127 | return ( |
| 128 | <div className="mx-auto max-w-md py-12"> |
| 129 | <h1 className="mb-2 font-display text-2xl font-bold tracking-tight text-zinc-100">Invite not found</h1> |
| 130 | <p className="mb-6 text-sm text-zinc-400"> |
| 131 | We couldn’t find that invite. The link may be malformed or revoked. Check it with the person who sent |
| 132 | it. |
| 133 | </p> |
| 134 | <a className="text-sm text-accent-400 underline underline-offset-2" href="/sign-in"> |
| 135 | Back to sign in |
| 136 | </a> |
| 137 | </div> |
| 138 | ); |
| 139 | } |
| 140 | |
| 141 | return ( |
| 142 | <div className="mx-auto max-w-md py-12"> |
| 143 | <h1 className="mb-2 font-display text-3xl font-bold tracking-tight text-zinc-100">Accept your invite</h1> |
| 144 | <p className="mb-2 text-sm text-zinc-400"> |
| 145 | tessera is the single sign-on for the limic suite — anvil, bland, flamemail, and friends. Set up your |
| 146 | credentials and you’ll use them everywhere. |
| 147 | </p> |
| 148 | <p className="mb-6 text-sm text-zinc-500">You can link GitHub or Google after signing in.</p> |
| 149 | |
| 150 | <Card> |
| 151 | <PageError message={displayError} onDismiss={() => setError(null)} /> |
| 152 | |
| 153 | <form onSubmit={handleSubmit} className="flex flex-col gap-4"> |
| 154 | <Input label="Email" type="email" autoComplete="email" value={lookup.email} disabled /> |
| 155 | <Input |
| 156 | label="Name" |
| 157 | type="text" |
| 158 | autoComplete="name" |
| 159 | required |
| 160 | value={name} |
| 161 | onChange={(e) => setName(e.target.value)} |
| 162 | /> |
| 163 | <Input |
| 164 | label="Password" |
| 165 | type="password" |
| 166 | autoComplete="new-password" |
| 167 | required |
| 168 | minLength={12} |
| 169 | helper="At least 12 characters." |
| 170 | value={password} |
| 171 | onChange={(e) => setPassword(e.target.value)} |
| 172 | /> |
| 173 | <div className="flex flex-col gap-1.5" aria-live="polite"> |
| 174 | <div className="grid min-h-[65px]"> |
| 175 | {turnstileUnavailable ? ( |
| 176 | <p className="place-self-center text-center text-xs text-zinc-500"> |
| 177 | Verification is unavailable. Acceptance is disabled. |
| 178 | </p> |
| 179 | ) : siteKey ? ( |
| 180 | <TurnstileWidget |
| 181 | siteKey={siteKey} |
| 182 | action="accept_invite" |
| 183 | onTokenChange={setTurnstileToken} |
| 184 | onUnavailable={() => setWidgetUnavailable(true)} |
| 185 | resetKey={resetKey} |
| 186 | /> |
| 187 | ) : ( |
| 188 | <p className="place-self-center text-center text-xs text-zinc-500">Loading verification…</p> |
| 189 | )} |
| 190 | </div> |
| 191 | </div> |
| 192 | <Button type="submit" loading={submitting} disabled={!turnstileToken || turnstileUnavailable}> |
| 193 | Accept invite |
| 194 | </Button> |
| 195 | </form> |
| 196 | </Card> |
| 197 | </div> |
| 198 | ); |
| 199 | }; |