// Login.jsx — the composed sign-in surface (§6.2) after the v0.12.0 // (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) / // v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0 // (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0 // (roadmap item #5) established the email + OTC scaffolding the later // releases extended. // // v0.12.0: the email-entry step renders a Turnstile widget. The token // it produces is sent to `/auth/otc/request` alongside the email. The // passcode step also renders a widget for the "Use a code instead" // fallback dispatch (same backend endpoint, same gate). When the // `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders // nothing — the form still submits and the backend's TURNSTILE_REQUIRED // policy decides admission. // // Four-to-six-step flow (most users see three; the longest path is // pending-user with no passcode, who never sees the passcode steps): // // 1. 'email' Enter email → GET /auth/passcode/check. // * has_passcode=true → step 'passcode'. // * has_passcode=false → POST /auth/otc/request, // step 'code'. // 429 on either dispatch surfaces a "wait a // moment" hint and keeps the user on step 1. // // 2a. 'passcode' Enter passcode → POST /auth/passcode/verify. // * 200 → redirect to "/". // * 423 (lockout, 5 consecutive failures) → // auto-fall back to OTC by requesting a fresh // code and advancing to step 'code'. // * 400 → wrong passcode; user can retry or // click "Use a code instead" to fall back // manually. // // 2b. 'code' Enter the six-digit code → POST /auth/otc/verify. // On 200, fetch /api/auth/me and branch: // * needs_profile === true → 'capture-profile' // * has_passcode === false → 'offer-passcode' // * otherwise → redirect to "/". // needs_profile WINS over has_passcode — a // pending user goes through the §6.1 capture // flow first; setting a passcode while waiting // for admin grant gains them nothing. // Cmd/Ctrl+Enter on the code field is the // keyboard shortcut. // // 3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why // I should be included in the beta" → POST // /api/auth/me/beta-request → redirect to // /beta-pending. The user's row stays // permission_state='pending' until an admin // grants access; they can set a passcode later // from settings, or on a future sign-in once // granted. // // 4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in // next time?" Yes → 'set-passcode'. Skip → "/". // // 4b. 'set-passcode' Pick a passcode (4–20 chars) → POST // /auth/passcode/set → redirect to "/". A // "Skip for now" link also redirects to "/". // // Server-side, /auth/otc/request returns 202 uniformly and // /auth/passcode/check returns has_passcode=false for an unknown // email, so this surface never distinguishes "we couldn't reach you" // from "we don't know you" — an unknown email always lands in the // OTC path with no account-enumeration signal. // // The legacy Gitea OAuth callback remains at /auth/login → // /auth/callback during the v0.7.0 migration; we surface a "Sign in // with Gitea" link as a fallback in the footer so users with active // OAuth sessions or older invite emails still have a path. We hide // the fallback on 'capture-profile' so a half-captured pending user // doesn't bail out into the OAuth path mid-form. import { useEffect, useRef, useState } from 'react' import { useNavigate, Link } from 'react-router-dom' import { requestOtc, verifyOtc, submitBetaRequest, checkPasscode, verifyPasscode, setPasscode as apiSetPasscode, startDeviceTrust, } from '../api' import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget' import { EVENTS, track } from '../lib/analytics' export default function Login() { // Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after // verify) one of: 'capture-profile' (pending user), 'offer-passcode' // (no passcode yet), or straight to "/". 'set-passcode' is reached // from 'offer-passcode'. const [step, setStep] = useState('email') const [email, setEmail] = useState('') const [code, setCode] = useState('') const [passcode, setPasscode] = useState('') const [newPasscode, setNewPasscode] = useState('') // v0.11.0 — "trust this device for 30 days" checkbox, shared by the // OTC and passcode verify steps. The flag rides on the verify POST; // a checked box mints a device-trust row server-side and sets the // long-lived `rfc_device_trust` cookie. Defaults off so the user // makes an explicit choice — auth credentials shouldn't persist by // default. const [trustDevice, setTrustDevice] = useState(false) // v0.8.0 — capture-profile fields. const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') const [reason, setReason] = useState('') const [status, setStatus] = useState('') const [busy, setBusy] = useState(false) // v0.12.0: Turnstile token captured by the widget. `null` means no // challenge solved yet (or the site key is unset, in which case the // widget surfaces null on mount). The token is single-use; we clear // it back to null right after we send it so a second request on the // same form remount re-challenges. `turnstileReady` is true once the // widget has produced a token OR the widget is not configured at // build time (no site key) — the submit button reads from it so the // form locks up when the operator has wired Turnstile but the user // hasn't solved the challenge yet. const [turnstileToken, setTurnstileToken] = useState(null) const turnstileOn = turnstileEnabled() const turnstileReady = !turnstileOn || !!turnstileToken const emailRef = useRef(null) const codeRef = useRef(null) const passcodeRef = useRef(null) const newPasscodeRef = useRef(null) const firstNameRef = useRef(null) const navigate = useNavigate() useEffect(() => { if (step === 'email') emailRef.current?.focus() else if (step === 'code') codeRef.current?.focus() else if (step === 'passcode') passcodeRef.current?.focus() else if (step === 'capture-profile') firstNameRef.current?.focus() else if (step === 'set-passcode') newPasscodeRef.current?.focus() }, [step]) // v0.11.0 — on mount, try the device-trust cookie path. If the // browser still carries a valid `rfc_device_trust` cookie from a // prior "trust this device" gesture, the server re-establishes the // session without any user input and we redirect home. The cookie // is HttpOnly so we can't peek at it; we just call the endpoint and // see whether it returns 200. 401 (no cookie / invalid / revoked) // is the structural-silent case — the user proceeds to the email // step normally. We do not surface any UI about the attempt; a // failure should be invisible. useEffect(() => { let cancelled = false ;(async () => { try { await startDeviceTrust() if (!cancelled) { // v0.15.0 — analytics: device-trust cookie path is one of // three sign-in methods the taxonomy distinguishes. track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' }) window.location.assign('/') } } catch (_) { // No trusted device — fall through to the email step. } })() return () => { cancelled = true } }, []) async function submitEmail(e) { e.preventDefault() if (!email.trim() || !email.includes('@')) { setStatus('Enter a valid email address.') return } setBusy(true) setStatus('') try { const { has_passcode } = await checkPasscode(email.trim()) if (has_passcode) { setStep('passcode') setStatus('') } else { await requestOtc(email.trim(), { turnstileToken }) // v0.12.0: the token is single-use; drop it so a re-request // from the code step (via "Use a different email" → back to // email) starts with a fresh challenge. setTurnstileToken(null) setStep('code') setStatus('Check your inbox — a six-digit code is on the way.') } } catch (err) { // Any failure consumes the token from CloudFlare's side; clear // so the widget re-renders a fresh challenge on retry. setTurnstileToken(null) if (err.status === 429) { setStatus('Slow down — wait a minute before requesting another code.') } else if (err.status === 400) { setStatus("Couldn't verify you're human. Please retry the challenge.") } else { setStatus(err.message || 'Could not start sign-in. Try again.') } } finally { setBusy(false) } } async function submitPasscode(e) { if (e) e.preventDefault() if (!passcode.trim()) { setStatus('Enter your passcode.') return } setBusy(true) setStatus('') try { await verifyPasscode(email.trim(), passcode.trim(), { trustDevice }) // v0.15.0 — analytics: passcode is the second of three // sign-in methods. trust-device gets credited separately when // the cookie-driven path fires above. track(EVENTS.USER_SIGNED_IN, { method: 'passcode' }) // Reload so App.jsx's getMe() picks up the fresh session. A // returning passcode user is by definition already past the // §6.1 capture step (they couldn't have set a passcode while // pending), so we go straight to "/". window.location.assign('/') } catch (err) { if (err.status === 423) { // Lockout — auto-fall back to OTC. The OTC request endpoint // is independent of the passcode lockout, so this lands a // fresh code in the user's inbox immediately. setPasscode('') try { // v0.12.0: pass whatever token the widget on the passcode // step has produced. If the operator has Turnstile required // and the user hasn't solved the passcode-step widget, the // backend refuses and we bounce them back to email-entry // with a clear status (see catch below). await requestOtc(email.trim(), { turnstileToken }) setTurnstileToken(null) setStep('code') setStatus( 'Too many failed attempts. We sent a one-time code to your email — use it to sign in.', ) } catch (e2) { setTurnstileToken(null) if (e2.status === 429) { setStep('code') setStatus( 'Too many failed attempts. Wait a minute, then request a one-time code to sign in.', ) } else if (e2.status === 400) { setStep('email') setStatus( 'Too many failed attempts. Solve the challenge below to receive a one-time code.', ) } else { setStatus( 'Too many failed attempts. Use the "Use a code instead" link to sign in via email.', ) } } } else { setStatus('Wrong passcode. Try again, or use a one-time code instead.') } setBusy(false) } } async function submitCode(e) { if (e) e.preventDefault() if (!code.trim() || code.trim().length !== 6) { setStatus('Enter the six-digit code from your email.') return } setBusy(true) setStatus('') try { await verifyOtc(email.trim(), code.trim(), { trustDevice }) // v0.15.0 — analytics: OTC is the third sign-in method. // We fire it here regardless of whether the user then lands // in capture-profile or offer-passcode — sign-in has happened // server-side either way. track(EVENTS.USER_SIGNED_IN, { method: 'otc' }) // OTC verified — the server has signed in the user. Fetch the // canonical /api/auth/me to decide where to land: // * needs_profile → §6.1 capture (then /beta-pending). // * no passcode → §6.2 offer-passcode (then /). // * otherwise → /. // needs_profile wins over has_passcode: a pending user can't yet // do anything that benefits from faster sign-in, so we don't // distract them with the passcode offer mid-admission. const meResp = await fetch('/api/auth/me', { credentials: 'include' }) let me = null if (meResp.ok) { try { me = await meResp.json() } catch (_) { me = null } } if (me?.needs_profile === true) { setStep('capture-profile') setStatus('') setBusy(false) return } if (me?.has_passcode === false) { setStep('offer-passcode') setStatus('') setBusy(false) return } // Either /me returned the granted-with-passcode shape, or the // call failed but the session cookie is set — fall through to // a hard reload so App.jsx re-fetches and renders accordingly. window.location.assign('/') } catch (err) { setStatus('That code is invalid or expired. Try again, or request a new code.') setBusy(false) } } async function submitProfile(e) { if (e) e.preventDefault() const fn = firstName.trim() const ln = lastName.trim() const why = reason.trim() if (!fn || !ln || !why) { setStatus('All three fields are required.') return } setBusy(true) setStatus('') try { await submitBetaRequest({ first_name: fn, last_name: ln, beta_request_reason: why, }) // v0.15.0 — analytics: a successful capture-profile submit is // the moment a beta-access request lands. No PII in the event // body (no name, no reason text); the count + timestamp is // what the funnel needs. track(EVENTS.BETA_ACCESS_REQUESTED) // Hard-load so App.jsx re-fetches /api/auth/me and picks up // the captured fields. The user stays permission_state='pending' // until an admin grants access — the next thing they should // see is the "your request is in review" page. window.location.assign('/beta-pending') } catch (err) { setStatus(err.message || 'Could not submit your request. Try again.') setBusy(false) } } async function submitNewPasscode(e) { if (e) e.preventDefault() const pc = newPasscode.trim() if (pc.length < 4) { setStatus('Passcode must be at least 4 characters.') return } setBusy(true) setStatus('') try { await apiSetPasscode(pc) window.location.assign('/') } catch (err) { // 422 carries the validation message verbatim (denylist / // length); surface it as-is so the user knows what to change. setStatus(err.message || 'Could not set passcode. Try a different one.') setBusy(false) } } function onCodeKey(e) { // §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field. if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { submitCode(e) } } function onPasscodeKey(e) { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { submitPasscode(e) } } function onReasonKey(e) { // §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from // the reason textarea (the multi-line input that would otherwise // swallow Enter as a newline). if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { submitProfile(e) } } function onNewPasscodeKey(e) { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { submitNewPasscode(e) } } function backToEmail() { setStep('email') setCode('') setPasscode('') setStatus('') } async function fallbackToOtc() { // Manual "Use a code instead" from the passcode step. Same shape // as the email-step OTC dispatch. v0.12.0: pass through whatever // Turnstile token the passcode-step widget has produced (or null // when the widget is disabled at build time). setBusy(true) setStatus('') try { await requestOtc(email.trim(), { turnstileToken }) setTurnstileToken(null) setPasscode('') setStep('code') setStatus('Check your inbox — a six-digit code is on the way.') } catch (err) { setTurnstileToken(null) if (err.status === 429) { setStatus('Slow down — wait a minute before requesting another code.') } else if (err.status === 400) { // v0.12.0: the widget rejected or no token was sent. Bounce // the user back to the email step so they get a fresh // challenge alongside the email input. setStep('email') setStatus("Couldn't verify you're human. Please retry the challenge.") } else { setStatus(err.message || 'Could not request a code. Try again.') } } finally { setBusy(false) } } function skipPasscodeOffer() { window.location.assign('/') } return (
You're signed in. Want to set a passcode for faster sign-in next time? You can always use a one-time code instead — and you can change or remove the passcode from your settings.
{status}
} {step !== 'capture-profile' && (Read the philosophy → · Sign in with Gitea (fallback)
)}