Skip to content
File

Blob: src/client/pages/invite/accept.tsx

typescript200 lines
1import { useQuery } from "@tanstack/react-query";
2import { useState } from "react";
3import { Navigate, useNavigate, useParams } from "react-router";
4 
5import { Button } from "@/client/components/ui/button";
6import { Card } from "@/client/components/ui/card";
7import { Input } from "@/client/components/ui/input";
8import { PageError } from "@/client/components/ui/page-error";
9import { TurnstileWidget } from "@/client/components/turnstile-widget";
10import { useSession } from "@/client/lib/auth-client";
11import { useClientConfig } from "@/client/lib/config";
12import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http";
13import { queryKeys } from "@/client/lib/query";
14 
15type LookupState =
16 | { status: "loading" }
17 | { status: "valid"; email: string; expiresAt: string }
18 | { status: "not_found" }
19 | { status: "expired" }
20 | { status: "consumed" };
21 
22const TURNSTILE_UNAVAILABLE_MESSAGE =
23 "Human verification is unavailable. Invite acceptance is disabled until this is fixed; contact an administrator.";
24 
25export 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&rsquo;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&rsquo;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};