Files
website/client/src/routes/admin/AdminLogin.jsx
wtclaude 60ebacff2c
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
feat(auth): trusted devices, recovery codes, and admin MFA management
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.

- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
  recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
  httpOnly cookie (survives logout, revoked on untrust/password change/reset/
  TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
  gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
  over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
  /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
  code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
  revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:38:48 -05:00

347 lines
14 KiB
JavaScript

import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
const SSO_ERRORS = {
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
disabled: 'This account is not active. Contact an administrator.',
denied: 'Sign-in was cancelled.',
unavailable: 'That sign-in method is not available right now.',
bad_state: 'Your sign-in session expired. Please try again.',
error: 'Could not complete sign-in. Please try again.',
}
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
// skip) so real users never fill it but naive scripted bots do. Name must match
// the server's HONEYPOT_FIELD ('company').
const honeypotStyle = {
position: 'absolute',
left: '-9999px',
top: 'auto',
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none',
}
export default function AdminLogin() {
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const { siteTitle, heroImage } = useSite()
const BG = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/admin'
// A player who signs in here belongs in the player portal, not the admin shell
// (the admin API 403s them anyway). Staff go to their intended admin dest.
const destFor = (u) => (u && u.role === 'player' ? '/account' : dest)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [company, setCompany] = useState('') // honeypot — must stay empty
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
const [trustDevice, setTrustDevice] = useState(false)
const [useRecovery, setUseRecovery] = useState(false)
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } when the cap is hit
// SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...).
const [providers, setProviders] = useState([])
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
// Already signed in → go straight to the right home for the role.
useEffect(() => {
if (user) navigate(destFor(user), { replace: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, dest, navigate])
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
// before it will issue a session. Jump straight to the code step.
useEffect(() => {
if (new URLSearchParams(location.search).get('sso_totp')) {
setStage('totp')
setSsoTotp(true)
}
}, [location.search])
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
// still works with password login and simply shows no provider buttons.
useEffect(() => {
let active = true
api
.authProviders()
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
.catch(() => active && setProviders([]))
return () => {
active = false
}
}, [])
// Full-page redirect into the provider's OAuth flow, preserving the intended
// destination so the callback can return the user there.
function startSso(provider) {
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
window.location.assign(provider.loginUrl + q)
}
async function onSubmit(e) {
e.preventDefault()
setError('')
setBusy(true)
try {
const data = await login(username, password, { company })
if (data.totpRequired) {
setChallenge(data.challenge)
setStage('totp')
setBusy(false)
return
}
navigate(destFor(data.user), { replace: true })
} catch (err) {
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
setBusy(false)
}
}
async function onSubmitTotp(e) {
e.preventDefault()
setError('')
setBusy(true)
try {
if (ssoTotp) {
const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/admin', { replace: true })
} else {
const entered = code.trim()
const data = await loginTotp(challenge, useRecovery ? '' : entered, {
recoveryCode: useRecovery ? entered : undefined,
trustDevice,
})
const to = destFor(data.user)
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
setBusy(false)
if (expired) {
setStage('creds')
setSsoTotp(false)
}
}
}
let submitLabel = 'Sign in'
if (busy) submitLabel = 'Signing in…'
else if (stage === 'totp') submitLabel = 'Verify'
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: '40px 18px',
overflow: 'hidden',
backgroundColor: 'var(--bg-deep)',
backgroundImage: BG,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'min(60vh, 520px)',
}}
>
<div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}>
<MoonDot size={15} glow={0.55} />
</div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
{siteTitle}
</h1>
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
Admin Panel
</p>
</div>
<form
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
style={{
border: '1px solid var(--line)',
borderRadius: 12,
padding: 28,
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
backdropFilter: 'blur(6px)',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
}}
>
{stage === 'creds' ? (
<>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Username</span>
<input
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
/>
</label>
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Password</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
/>
</label>
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
<div style={honeypotStyle} aria-hidden="true">
<label>
Company
<input
type="text"
name="company"
tabIndex={-1}
autoComplete="off"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</label>
</div>
</>
) : (
<>
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
<input
type="text"
inputMode={useRecovery ? 'text' : 'numeric'}
autoComplete="one-time-code"
autoFocus
placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</span>
</label>
{!ssoTotp && (
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time)
</label>
)}
{!ssoTotp && (
<button
type="button"
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
className="sans"
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
)}
</>
)}
{(error || (stage === 'creds' && ssoError)) && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
{error || ssoError}
</p>
)}
<button
type="submit"
disabled={busy}
className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
>
{submitLabel}
</button>
{/* SSO providers — only on the credentials step, only if any are enabled. */}
{stage === 'creds' && providers.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => startSso(p)}
className="btn"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
width: '100%',
borderRadius: 8,
padding: 11,
border: '1px solid var(--line)',
background: 'rgba(255,255,255,0.04)',
color: 'var(--ink)',
}}
>
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
<ProviderIcon icon={p.icon} size={18} />
</span>
Continue with {p.name}
</button>
))}
</div>
</div>
)}
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
Protected area not indexed. Sessions expire after 1 day.
</p>
</form>
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
Back to site
</Link>
</p>
</div>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</main>
)
}