Skip to content
File

Blob: src/client/pages/sign-in.tsx

typescript300 lines
1import { useQuery } from "@tanstack/react-query";
2import { Github } from "lucide-react";
3import { useEffect, useState } from "react";
4import { useNavigate, useSearchParams } from "react-router";
5 
6import { TurnstileWidget } from "@/client/components/turnstile-widget";
7import { Button } from "@/client/components/ui/button";
8import { GoogleIcon } from "@/client/components/ui/google-icon";
9import { Input } from "@/client/components/ui/input";
10import { PageError } from "@/client/components/ui/page-error";
11import { useSession } from "@/client/lib/auth-client";
12import { type SocialProvider, useClientConfig } from "@/client/lib/config";
13import { apiJsonRequest } from "@/client/lib/http";
14import { normalizeLocalPath } from "@/client/lib/navigation";
15import { queryKeys } from "@/client/lib/query";
16import {
17 shouldRedirectSignedInFromSignIn,
18 signedOAuthQueryFromParams,
19 socialErrorCallbackURL,
20} from "@/client/lib/sign-in-oauth";
21import { buildSocialSignInRequest } from "@/client/lib/social";
22 
23interface OAuthClientPublicInfo {
24 client_id: string;
25 client_name?: string | null;
26}
27 
28// Errors set on the URL by Better Auth's social-callback `errorCallbackURL`
29// or by tessera's own redirects. Unrecognized codes fall through to the
30// generic "social_unavailable" copy rather than throwing the user back to
31// /auth-error mid-flow.
32const SIGN_IN_ERROR_MESSAGES: Record<string, string> = {
33 social_unavailable: "Could not sign in with that provider. Please try again or use email and password.",
34 signup_disabled: "tessera is invite-only. Ask an admin for an invite.",
35};
36 
37const TURNSTILE_UNAVAILABLE_MESSAGE =
38 "Human verification is unavailable. Sign-in is disabled until this is fixed; contact an administrator.";
39 
40export const SignInPage = () => {
41 const navigate = useNavigate();
42 const [searchParams] = useSearchParams();
43 const redirectTo = normalizeLocalPath(searchParams.get("redirect"));
44 const signedOAuthQuery = signedOAuthQueryFromParams(searchParams);
45 const oauthClientId = signedOAuthQuery ? (searchParams.get("client_id") ?? "") : "";
46 const session = useSession();
47 
48 const initialErrorCode = searchParams.get("error");
49 
50 const [email, setEmail] = 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 // Lazy initializer so the lookup runs once at mount instead of on every
56 // render. The query string does not change between renders during a
57 // single page session.
58 const [error, setError] = useState<string | null>(() =>
59 initialErrorCode ? (SIGN_IN_ERROR_MESSAGES[initialErrorCode] ?? SIGN_IN_ERROR_MESSAGES.social_unavailable) : null,
60 );
61 const [socialPending, setSocialPending] = useState<SocialProvider | null>(null);
62 const config = useClientConfig();
63 // `unavailable` covers two distinct failure modes:
64 // - /api/config reports the worker is missing TURNSTILE_SITE_KEY
65 // - the embedded Turnstile widget itself fails to load (network /
66 // CSP / runtime), reported via TurnstileWidget.onUnavailable
67 // Either disables the form; the widget signal is local because
68 // /api/config can succeed but the script can still fail to load.
69 const [widgetUnavailable, setWidgetUnavailable] = useState(false);
70 const turnstileUnavailable = config?.status === "unavailable" || widgetUnavailable;
71 const siteKey = config?.status === "ok" ? config.turnstileSiteKey : null;
72 const socialProviders: SocialProvider[] = config?.status === "ok" ? config.socialProviders : [];
73 const signedInUserId = session.data?.user.id ?? null;
74 
75 useEffect(() => {
76 if (
77 shouldRedirectSignedInFromSignIn({
78 sessionPending: session.isPending,
79 signedInUserId,
80 signedOAuthQuery,
81 })
82 ) {
83 navigate(redirectTo, { replace: true });
84 }
85 }, [session.isPending, signedInUserId, signedOAuthQuery, navigate, redirectTo]);
86 
87 const { data: oauthClientInfo = null } = useQuery({
88 queryKey: queryKeys.preloginClient(oauthClientId || null, signedOAuthQuery),
89 queryFn: async () => {
90 if (!signedOAuthQuery) return null;
91 const res = await apiJsonRequest(
92 "/api/auth/oauth2/public-client-prelogin",
93 { client_id: oauthClientId, oauth_query: signedOAuthQuery },
94 { method: "POST" },
95 );
96 return res.ok ? ((await res.json()) as OAuthClientPublicInfo) : null;
97 },
98 enabled: Boolean(oauthClientId && signedOAuthQuery),
99 });
100 
101 const handleSubmit = async (e: React.FormEvent) => {
102 e.preventDefault();
103 setError(null);
104 if (!turnstileToken) {
105 setError("Please complete the verification challenge.");
106 return;
107 }
108 setSubmitting(true);
109 try {
110 const signInBody: { email: string; oauth_query?: string; password: string; turnstileToken: string } = {
111 email,
112 password,
113 turnstileToken,
114 };
115 if (signedOAuthQuery) signInBody.oauth_query = signedOAuthQuery;
116 const res = await apiJsonRequest("/api/sign-in", signInBody, { method: "POST" });
117 const body = (await res.json().catch(() => null)) as {
118 redirect?: boolean;
119 url?: string;
120 message?: unknown;
121 } | null;
122 if (!res.ok) {
123 setError(typeof body?.message === "string" && body.message.length > 0 ? body.message : "Sign-in failed.");
124 setResetKey((n) => n + 1);
125 return;
126 }
127 if (body?.redirect && body.url) {
128 window.location.assign(body.url);
129 return;
130 }
131 await session.refetch();
132 navigate(redirectTo, { replace: true });
133 } catch {
134 setError("Network error. Please try again.");
135 setResetKey((n) => n + 1);
136 } finally {
137 setSubmitting(false);
138 }
139 };
140 
141 const handleSocial = async (provider: SocialProvider) => {
142 setError(null);
143 if (!turnstileToken) {
144 setError("Please complete the verification challenge.");
145 return;
146 }
147 setSocialPending(provider);
148 try {
149 // errorCallbackURL keeps the signed `oauth_query` so an IdP failure
150 // during an RP-initiated flow lands the user back on /sign-in still
151 // attached to the same /authorize, instead of stranding them outside
152 // the RP completion path. Signed params come first so the extractor's
153 // stop-at-sig invariant survives the local `error` append.
154 const errorCallbackURL = socialErrorCallbackURL(signedOAuthQuery);
155 const body = buildSocialSignInRequest({
156 provider,
157 callbackURL: redirectTo,
158 errorCallbackURL,
159 turnstileToken,
160 oauthQuery: signedOAuthQuery,
161 });
162 const res = await apiJsonRequest("/api/sign-in/social", body, { method: "POST" });
163 const payload = (await res.json().catch(() => null)) as {
164 url?: string;
165 message?: unknown;
166 } | null;
167 if (!res.ok) {
168 setError(
169 typeof payload?.message === "string" && payload.message.length > 0
170 ? payload.message
171 : `Could not sign in with ${provider}.`,
172 );
173 setResetKey((n) => n + 1);
174 setSocialPending(null);
175 return;
176 }
177 if (payload?.url) {
178 window.location.assign(payload.url);
179 return;
180 }
181 setError(`Could not sign in with ${provider}: no redirect URL returned.`);
182 setSocialPending(null);
183 } catch (e) {
184 setError(e instanceof Error ? e.message : `Failed to sign in with ${provider}.`);
185 setSocialPending(null);
186 }
187 };
188 
189 if (session.isPending) return null;
190 // A signed-in user landing on /sign-in normally redirects via the
191 // effect above. When a signed RP-initiated /authorize query is present
192 // (e.g. `prompt=login`), keep the form rendered so the user can
193 // re-authenticate; otherwise the RP's authorization request is dropped.
194 if (signedInUserId && !signedOAuthQuery) return null;
195 
196 const oauthClientName = oauthClientInfo?.client_name ?? oauthClientInfo?.client_id ?? null;
197 // Reserve a slot for the "Continue to <RP>" subtitle so the layout
198 // does not bump down when oauthClientInfo resolves after the initial
199 // render. Empty when the user did not arrive from /authorize, sized
200 // to match the resolved-state height when they did.
201 const reserveSubtitleSpace = Boolean(oauthClientId);
202 // Surface the turnstile-unavailable banner without round-tripping
203 // through useState — the form is already disabled in that branch and
204 // dismiss should be a no-op while the failure persists.
205 const displayError = error ?? (turnstileUnavailable ? TURNSTILE_UNAVAILABLE_MESSAGE : null);
206 
207 return (
208 <div className="mx-auto max-w-md py-12">
209 <h1 className="mb-2 font-display text-3xl font-semibold tracking-tight text-zinc-100">Sign in</h1>
210 <div className={reserveSubtitleSpace ? "mb-6 min-h-[2.5rem]" : "mb-6"}>
211 {oauthClientName ? (
212 <>
213 <p className="text-sm text-zinc-400">
214 Continue to <strong className="font-medium text-zinc-200">{oauthClientName}</strong>.
215 </p>
216 {oauthClientInfo?.client_name ? (
217 <p className="mt-1 truncate font-mono text-xs tracking-wide text-zinc-500">{oauthClientInfo.client_id}</p>
218 ) : null}
219 </>
220 ) : null}
221 </div>
222 
223 <PageError message={displayError} onDismiss={() => setError(null)} className="mb-5" />
224 
225 <form onSubmit={handleSubmit} className="flex flex-col gap-4">
226 <Input
227 label="Email"
228 type="email"
229 autoComplete="email"
230 required
231 value={email}
232 onChange={(e) => setEmail(e.target.value)}
233 />
234 <Input
235 label="Password"
236 type="password"
237 autoComplete="current-password"
238 required
239 value={password}
240 onChange={(e) => setPassword(e.target.value)}
241 />
242 <div className="flex flex-col gap-1.5" aria-live="polite">
243 <div className="grid min-h-[65px]">
244 {turnstileUnavailable ? (
245 <p className="place-self-center text-center text-xs text-zinc-500">
246 Verification is unavailable. Sign-in is disabled.
247 </p>
248 ) : siteKey ? (
249 <TurnstileWidget
250 siteKey={siteKey}
251 action="sign-in"
252 onTokenChange={setTurnstileToken}
253 onUnavailable={() => setWidgetUnavailable(true)}
254 resetKey={resetKey}
255 />
256 ) : (
257 <p className="place-self-center text-center text-xs text-zinc-500">Loading verification…</p>
258 )}
259 </div>
260 </div>
261 <Button type="submit" loading={submitting} disabled={!turnstileToken || turnstileUnavailable}>
262 Sign in
263 </Button>
264 </form>
265 
266 {socialProviders.length > 0 ? (
267 <div className="mt-6 grid grid-cols-2 gap-2">
268 {socialProviders.includes("github") ? (
269 <Button
270 variant="secondary"
271 loading={socialPending === "github"}
272 disabled={socialPending !== null || turnstileUnavailable || !turnstileToken}
273 onClick={() => handleSocial("github")}
274 >
275 <Github className="h-4 w-4" aria-hidden="true" />
276 GitHub
277 </Button>
278 ) : null}
279 {socialProviders.includes("google") ? (
280 <Button
281 variant="secondary"
282 loading={socialPending === "google"}
283 disabled={socialPending !== null || turnstileUnavailable || !turnstileToken}
284 onClick={() => handleSocial("google")}
285 >
286 <GoogleIcon className="h-4 w-4" />
287 Google
288 </Button>
289 ) : null}
290 </div>
291 ) : null}
292 
293 <p className="mt-16 max-w-[58ch] font-display text-sm italic leading-[1.7] text-zinc-500">
294 A <em className="not-italic font-medium text-zinc-400">tessera</em>, in Roman antiquity, is a small ceramic or
295 bronze tile bearing an identification mark — presented at a checkpoint, recognized, admitted.
296 </p>
297 </div>
298 );
299};