File
Blob: src/client/pages/sign-in.tsx
| 1 | import { useQuery } from "@tanstack/react-query"; |
| 2 | import { Github } from "lucide-react"; |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { useNavigate, useSearchParams } from "react-router"; |
| 5 | |
| 6 | import { TurnstileWidget } from "@/client/components/turnstile-widget"; |
| 7 | import { Button } from "@/client/components/ui/button"; |
| 8 | import { GoogleIcon } from "@/client/components/ui/google-icon"; |
| 9 | import { Input } from "@/client/components/ui/input"; |
| 10 | import { PageError } from "@/client/components/ui/page-error"; |
| 11 | import { useSession } from "@/client/lib/auth-client"; |
| 12 | import { type SocialProvider, useClientConfig } from "@/client/lib/config"; |
| 13 | import { apiJsonRequest } from "@/client/lib/http"; |
| 14 | import { normalizeLocalPath } from "@/client/lib/navigation"; |
| 15 | import { queryKeys } from "@/client/lib/query"; |
| 16 | import { |
| 17 | shouldRedirectSignedInFromSignIn, |
| 18 | signedOAuthQueryFromParams, |
| 19 | socialErrorCallbackURL, |
| 20 | } from "@/client/lib/sign-in-oauth"; |
| 21 | import { buildSocialSignInRequest } from "@/client/lib/social"; |
| 22 | |
| 23 | interface 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. |
| 32 | const 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 | |
| 37 | const TURNSTILE_UNAVAILABLE_MESSAGE = |
| 38 | "Human verification is unavailable. Sign-in is disabled until this is fixed; contact an administrator."; |
| 39 | |
| 40 | export 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 | |
| 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 | }; |