Files
rfc-app/frontend/src/components/Login.jsx
T
Ben Stull 72f8457933 Release 0.15.0: Amplitude Analytics + Session Replay (with #21 Part C identity lifecycle)
Wave 5 / Track A. Roadmap item #13. Folds in #21 Part C user-identity-
lifecycle work inline rather than as a follow-up release (operator
ask: "implement Amplitude best practices from the very get-go").

Wraps @amplitude/unified with:
  - consent-gated lazy init (v0.13.0 cookie banner; SDK never loads
    without explicit analytics opt-in)
  - amplitude.initAll(KEY, { analytics: { autocapture: true },
    sessionReplay: { sampleRate: 1 } }) — vendor-recommended shape
  - identify({ user_id, properties? }) with set vs setOnce semantics
  - setUserProperties(properties) for mid-session state changes
  - anonymize() clears both user_id binding AND property cache

Wired into App.jsx (identify on me.user with role / permission_state /
passcode_set / device_trusted as set; first_sign_in_at / account_created_at
as setOnce; Page Viewed on route change; Sign-out fires User Signed
Out + anonymize), Login.jsx (User Signed In + Beta Access Requested),
and the per-feature surfaces (ProposeModal, PRModal, RFCView,
RFCDiscussionPanel, PRView, Admin).

Binding: VITE_AMPLITUDE_API_KEY via `flotilla overlay set`
(bundle-embedded public key like VITE_TURNSTILE_SITE_KEY — not
secret). Mid-Session-L correction: original dispatch brief specified
secret-set; vendor guidance + bundle visibility settled it as overlay.

PII discipline: no email, no display_name, no gitea_login, no free-text
fields ever sent. Properties are opaque ids + enums + timestamps +
booleans only.

Subagent ξ shipped the wrapper structure + nine-event taxonomy on
feature/v0.15.0-amplitude (0fd8c52 + 6cfbf69 post-correction).
Driver-side integration extended the wrapper with setUserProperties
+ identify properties for the #21 Part C identity-lifecycle work.
Frontend build verified green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:04:10 -07:00

715 lines
27 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (420 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 (
<div className="otc-login">
<div className="otc-login-inner">
<h1>Sign in</h1>
{step === 'email' && (
<form onSubmit={submitEmail}>
<p className="otc-hint">
Enter your email. If you've set a passcode, you'll enter that
next; otherwise we'll send a one-time code.
</p>
<input
ref={emailRef}
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={busy}
/>
{/*
v0.12.0: CloudFlare Turnstile widget. Renders nothing
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
defaults to true in that case so the submit gate doesn't
lock up). On every challenge the widget calls onToken
with the fresh token; we feed it to /auth/otc/request.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<button
type="submit"
disabled={busy || !email.trim() || !turnstileReady}
>
{busy ? 'Checking…' : 'Continue'}
</button>
</form>
)}
{step === 'passcode' && (
<form onSubmit={submitPasscode}>
<p className="otc-hint">
Enter the passcode for <strong>{email}</strong>.
</p>
<input
ref={passcodeRef}
type="password"
autoComplete="current-password"
value={passcode}
onChange={e => setPasscode(e.target.value)}
onKeyDown={onPasscodeKey}
placeholder="Your passcode"
required
disabled={busy}
/>
{/* v0.11.0 — trust device for 30 days. The checkbox lives
on the verify step so the user makes the trust gesture
in the same breath as signing in. Off by default; the
user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
{/*
v0.12.0: a second Turnstile widget for the
"Use a code instead" fallback dispatch. The passcode
verify path does not consume a Turnstile token (it has
its own 5-attempt lockout shape from v0.10.0), but if
the user falls back to OTC the same /auth/otc/request
endpoint runs and needs a token. We render the widget
on this step too so the fallback works without bouncing
back to email-entry first.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<div className="otc-actions">
<button type="submit" disabled={busy || !passcode.trim()}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={fallbackToOtc}
disabled={busy || !turnstileReady}
>
Use a code instead
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
</form>
)}
{step === 'code' && (
<form onSubmit={submitCode}>
<p className="otc-hint">
Enter the six-digit code we sent to <strong>{email}</strong>.
</p>
<input
ref={codeRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={onCodeKey}
placeholder="123456"
required
disabled={busy}
/>
{/* v0.11.0 — trust device for 30 days. Same shape as the
passcode step; the user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to sign in.
</p>
</form>
)}
{step === 'capture-profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
beta — tell us a bit about yourself and an admin will review
your request.
</p>
<label className="otc-field-label">First name</label>
<input
ref={firstNameRef}
type="text"
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">Last name</label>
<input
type="text"
autoComplete="family-name"
value={lastName}
onChange={e => setLastName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">
Why you'd like to be included in the beta
</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
onKeyDown={onReasonKey}
required
disabled={busy}
rows={5}
maxLength={4000}
placeholder="A sentence or two is plenty."
/>
<div className="otc-actions">
<button
type="submit"
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
>
{busy ? 'Submitting…' : 'Submit request'}
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to submit.
</p>
</form>
)}
{step === 'offer-passcode' && (
<div className="otc-offer-passcode">
<p className="otc-hint">
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.
</p>
<div className="otc-actions">
<button
type="button"
onClick={() => { setStep('set-passcode'); setStatus('') }}
>
Set a passcode
</button>
<button
type="button"
className="btn-link-quiet"
onClick={skipPasscodeOffer}
>
Skip for now
</button>
</div>
</div>
)}
{step === 'set-passcode' && (
<form onSubmit={submitNewPasscode}>
<p className="otc-hint">
Pick a passcode (420 characters). You'll use it with your
email to sign in next time.
</p>
<input
ref={newPasscodeRef}
type="password"
autoComplete="new-password"
value={newPasscode}
onChange={e => setNewPasscode(e.target.value)}
onKeyDown={onNewPasscodeKey}
placeholder="New passcode"
required
disabled={busy}
minLength={4}
maxLength={20}
/>
<div className="otc-actions">
<button type="submit" disabled={busy || newPasscode.trim().length < 4}>
{busy ? 'Saving…' : 'Save passcode'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={skipPasscodeOffer}
disabled={busy}
>
Skip for now
</button>
</div>
</form>
)}
{status && <p className="otc-status">{status}</p>}
{step !== 'capture-profile' && (
<p className="otc-fallback">
<Link to="/philosophy">Read the philosophy </Link>
<span className="otc-fallback-sep">·</span>
<a href="/auth/login">Sign in with Gitea (fallback)</a>
</p>
)}
</div>
</div>
)
}