72f8457933
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+6cfbf69post-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>
715 lines
27 KiB
React
715 lines
27 KiB
React
// 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 (
|
||
<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 (4–20 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>
|
||
)
|
||
}
|