feat(auth): trusted devices, recovery codes, and admin MFA management
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

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>
This commit is contained in:
2026-07-21 23:38:48 -05:00
parent 8d5bdc0d6e
commit 60ebacff2c
38 changed files with 3542 additions and 90 deletions

View File

@@ -56,8 +56,12 @@ export const api = {
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
acceptInvite: (token, username, password, extra = {}) =>
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for web login. `extra` carries the optional recoveryCode (an
// alternative to code) and the trustDevice/deviceName opt-in. On success the
// response may include { trustLimitReached, devices } when trust was requested
// but the device cap is reached.
loginTotp: (challenge, code, extra = {}) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
// Self-service password reset (public, token-gated). forgot always resolves the
// same way whether or not the email exists (no enumeration); getPasswordReset
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword
@@ -76,6 +80,20 @@ export const api = {
// List the active ones and revoke a single device by its session id.
mySessions: () => req('/auth/me/sessions'),
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
// mySessions, which are live mobile login sessions).
myTrustedDevices: () => req('/auth/me/trusted-devices'),
trustThisDevice: (deviceName) =>
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
revokeTrustedDevice: (id) =>
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
generateRecoveryCodes: (currentPassword) =>
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
// ----- public -----
publicSettings: () => req('/public/settings'),
@@ -199,6 +217,13 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// A user's trusted devices + MFA reset (admin only).
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
revokeUserTrustedDevice: (id, deviceId) =>
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
revokeAllUserTrustedDevices: (id) =>
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
// Email invites.
listInvites: () => req('/admin/invites'),
createInvite: (email, role, sendEmail = true) =>

View File

@@ -0,0 +1,62 @@
import { useState } from 'react'
// Renders a freshly generated batch of recovery codes ONCE, with copy + download.
// The backend never returns these again, so the copy stresses saving them now.
export default function RecoveryCodesDisplay({ codes, onDone }) {
const [copied, setCopied] = useState(false)
const text = (codes || []).join('\n')
async function copy() {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* clipboard blocked — the codes are visible to copy manually */
}
}
function download() {
const blob = new Blob([`${text}\n`], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'recovery-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 18, marginTop: 8 }}>
<p className="sans" style={{ margin: '0 0 12px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Save these recovery codes somewhere safe. Each can be used <strong>once</strong> to sign in if you
lose your authenticator. <strong>They will not be shown again.</strong>
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 8,
fontFamily: 'monospace',
fontSize: '0.95rem',
marginBottom: 14,
}}
>
{(codes || []).map((c) => (
<div key={c} style={{ padding: '8px 10px', border: '1px solid var(--line-soft)', borderRadius: 6, letterSpacing: '0.06em', textAlign: 'center', color: 'var(--head)' }}>
{c}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={copy} className="pill">{copied ? 'Copied!' : 'Copy'}</button>
<button onClick={download} className="pill">Download</button>
{onDone && (
<button onClick={onDone} className="btn btn-primary btn-sq" style={{ marginLeft: 'auto' }}>
Ive saved them
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import RecoveryCodesDisplay from './RecoveryCodesDisplay.jsx'
// Self-service recovery (backup) codes. Shows how many remain and lets the user
// regenerate a fresh set (password step-up). Shown only when 2FA is enabled.
// `hasPassword` decides whether the current-password field is required — an
// SSO-only account with no password may regenerate while authenticated.
export default function RecoveryCodesPanel({ hasPassword = true }) {
const [remaining, setRemaining] = useState(null)
const [currentPassword, setCurrentPassword] = useState('')
const [codes, setCodes] = useState(null) // freshly generated batch, shown once
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const { remaining: n } = await api.recoveryCodesStatus()
setRemaining(n)
} catch {
/* non-fatal — the panel still offers regeneration */
}
}, [])
useEffect(() => {
load()
}, [load])
async function regenerate() {
setBusy(true)
setError('')
try {
const { recoveryCodes } = await api.generateRecoveryCodes(hasPassword ? currentPassword : undefined)
setCodes(recoveryCodes)
setCurrentPassword('')
await load()
} catch (err) {
setError(err.message || 'Could not generate recovery codes.')
} finally {
setBusy(false)
}
}
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Recovery codes
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
codes you still have.
</p>
{remaining != null && !codes && (
<p className="sans" style={{ color: remaining > 0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
{remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
</p>
)}
{codes ? (
<RecoveryCodesDisplay codes={codes} onDone={() => setCodes(null)} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 }}>
{hasPassword && (
<label style={{ display: 'block', maxWidth: 260 }}>
<span className="field-label">Current password</span>
<input
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="input"
/>
</label>
)}
<div>
<button onClick={regenerate} disabled={busy || (hasPassword && !currentPassword)} className="btn btn-sq">
{busy ? 'Generating…' : 'Generate new codes'}
</button>
</div>
</div>
)}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { api } from '../../api/client.js'
// Shown when a user tries to trust a device but is already at the trusted-device
// cap. Styled like the TOTP entry flow (centered card on a dim overlay). The user
// MUST revoke at least one existing device before they can continue — there is no
// silent pruning — or they can cancel and leave the device untrusted.
//
// Props:
// devices — the existing trusted devices (from the 409 / trustLimitReached payload)
// onTrusted — called after the current device is successfully trusted (post-revoke)
// onCancel — called when the user backs out without trusting this device
export default function TrustLimitModal({ devices: initialDevices, onTrusted, onCancel }) {
const [devices, setDevices] = useState(initialDevices || [])
const [revokedAny, setRevokedAny] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function revoke(id) {
setBusy(true)
setError('')
try {
await api.revokeTrustedDevice(id)
setDevices((list) => list.filter((d) => d.id !== id))
setRevokedAny(true)
} catch {
setError('Could not revoke that device. Please try again.')
} finally {
setBusy(false)
}
}
async function trustNow() {
setBusy(true)
setError('')
try {
await api.trustThisDevice()
onTrusted?.()
} catch (err) {
// Still at the cap somehow (a race) — surface it and let them revoke more.
if (err.status === 409 && err.body?.devices) {
setDevices(err.body.devices)
setError('Still at the limit — revoke another device.')
} else {
setError('Could not trust this device. Please try again.')
}
} finally {
setBusy(false)
}
}
return (
<div style={overlay} role="dialog" aria-modal="true" aria-label="Trusted-device limit reached">
<div style={card}>
<h2 className="display" style={{ margin: '0 0 8px', fontSize: '1.15rem', color: 'var(--head)' }}>
Trusted-device limit reached
</h2>
<p className="sans" style={{ margin: '0 0 16px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
You can trust up to {Math.max(devices.length, 1)} devices. Revoke one below to make room, then
continue or cancel to leave this device untrusted.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16, maxHeight: 240, overflowY: 'auto' }}>
{devices.map((d) => (
<div key={d.id} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>
{d.deviceName || d.platform || 'Device'}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
{devices.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>All devices revoked. You can trust this one now.</p>
)}
</div>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.84rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={trustNow} disabled={busy || !revokedAny} className="btn btn-primary btn-sq">
{busy ? 'Working…' : 'Trust this device'}
</button>
<button onClick={onCancel} disabled={busy} className="pill">
Cancel
</button>
</div>
</div>
</div>
)
}
const overlay = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
zIndex: 1000,
}
const card = {
width: '100%',
maxWidth: 460,
background: 'var(--panel, #1a1a1f)',
border: '1px solid var(--line)',
borderRadius: 12,
padding: 24,
}
const row = {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
border: '1px solid var(--line)',
borderRadius: 8,
}

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import TrustLimitModal from './TrustLimitModal.jsx'
// Self-service list of the devices allowed to skip the TOTP step at login (MFA
// "Trust this device"). Uses the role-agnostic /auth/me/trusted-devices surface, so
// the same panel serves players and staff. Shown only when 2FA is enabled — trust
// is meaningless without a second factor to skip.
function fmtDate(s) {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
export default function TrustedDevicesPanel() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [capModal, setCapModal] = useState(null) // { devices } when the cap is hit
const load = useCallback(async () => {
try {
setDevices(await api.myTrustedDevices())
} catch {
setError('Could not load your trusted devices.')
}
}, [])
useEffect(() => {
load()
}, [load])
async function trustThis() {
setBusy(true)
setMsg('')
setError('')
try {
await api.trustThisDevice()
setMsg('This device is now trusted.')
await load()
} catch (err) {
if (err.status === 409 && err.body?.error === 'trusted_device_limit') {
setCapModal({ devices: err.body.devices || [] })
} else {
setError('Could not trust this device.')
}
} finally {
setBusy(false)
}
}
async function revoke(id) {
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeTrustedDevice(id)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Untrust every device? Each will require the full two-factor step at the next login.')) return
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeAllTrustedDevices()
setMsg('All devices untrusted.')
await load()
} catch {
setError('Could not untrust devices.')
} finally {
setBusy(false)
}
}
if (!devices) return null
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Trusted devices
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Devices youve trusted skip the authenticator step at login (your password is still required).
Revoke any you dont recognize.
</p>
{devices.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{devices.map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'} · last used {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
</div>
) : (
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>No trusted devices yet.</p>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={trustThis} disabled={busy} className="btn btn-sq">Trust this device</button>
{devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Untrust all
</button>
)}
</div>
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
{capModal && (
<TrustLimitModal
devices={capModal.devices}
onTrusted={() => { setCapModal(null); setMsg('This device is now trusted.'); load() }}
onCancel={() => setCapModal(null)}
/>
)}
</div>
)
}

View File

@@ -38,11 +38,14 @@ export function AuthProvider({ children }) {
return data
}, [])
// Step 2 for TOTP users: exchange the challenge + code for a real session.
const loginTotp = useCallback(async (challenge, code) => {
const data = await api.loginTotp(challenge, code)
// Step 2 for TOTP users: exchange the challenge + a second factor (TOTP code or a
// recovery code) for a real session. `extra` carries recoveryCode + the
// trustDevice/deviceName opt-in. Returns the full payload ({ user,
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
const loginTotp = useCallback(async (challenge, code, extra) => {
const data = await api.loginTotp(challenge, code, extra)
setUser(data.user)
return data.user
return data
}, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in

View File

@@ -2,6 +2,7 @@ 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'
@@ -52,6 +53,9 @@ export default function AdminLogin() {
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=...).
@@ -122,16 +126,23 @@ export default function AdminLogin() {
const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/admin', { replace: true })
} else {
const u = await loginTotp(challenge, code)
navigate(destFor(u), { replace: true })
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)
setError(
expired
? 'Your verification session expired. Please sign in again.'
: 'Invalid verification code.',
)
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')
@@ -223,22 +234,40 @@ export default function AdminLogin() {
</div>
</>
) : (
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Authentication code</span>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
placeholder="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' }}>
Enter the code from your authenticator app.
</span>
</label>
<>
<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)) && (
@@ -304,6 +333,14 @@ export default function AdminLogin() {
</Link>
</p>
</div>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</main>
)
}

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import ProviderIcon from '../../../components/ProviderIcon.jsx'
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through
@@ -127,6 +130,7 @@ export default function AccountAdmin() {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
async function load() {
try {
@@ -164,9 +168,10 @@ export default function AccountAdmin() {
setMsg('')
setError('')
try {
await api.admin.totpEnable(code.trim())
const res = await api.admin.totpEnable(code.trim())
setSetup(null)
setCode('')
setNewCodes(res?.recoveryCodes || null)
setMsg('Two-factor authentication is now enabled.')
await load()
} catch (err) {
@@ -302,6 +307,21 @@ export default function AccountAdmin() {
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
{/* One-time recovery codes shown right after enabling 2FA. */}
{newCodes && (
<div style={{ marginTop: 20 }}>
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
</div>
)}
{/* Trusted devices + recovery-code management, only relevant with 2FA on. */}
{enabled && (
<>
<TrustedDevicesPanel />
<RecoveryCodesPanel hasPassword={account?.has_password !== false} />
</>
)}
<LinkedAccounts />
</section>
)

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
@@ -136,6 +136,114 @@ function Houses({ scope }) {
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
try {
setDevices(await api.admin.userTrustedDevices(userId))
} catch {
setError('Could not load trusted devices.')
}
}, [userId])
useEffect(() => {
load()
}, [load])
async function revoke(deviceId) {
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeUserTrustedDevice(userId, deviceId)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Revoke ALL of this users trusted devices?')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeAllUserTrustedDevices(userId)
setMsg('All trusted devices revoked.')
await load()
} catch {
setError('Could not revoke devices.')
} finally {
setBusy(false)
}
}
async function resetMfa() {
if (!window.confirm('Reset this users two-factor? This turns TOTP off, revokes their trusted devices, and clears their recovery codes so they can sign in with their password.')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.resetUserMfa(userId)
setMsg('Two-factor has been reset for this user.')
await load()
} catch {
setError('Could not reset two-factor.')
} finally {
setBusy(false)
}
}
const fmt = (d) => {
const t = d ? new Date(d) : null
return t && !Number.isNaN(t.getTime()) ? t.toLocaleDateString() : '—'
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Security &amp; two-factor</SectionTitle>
{devices == null ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : devices.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No trusted devices.</p>
) : (
<ul style={{ listStyle: 'none', margin: '0 0 14px', padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{devices.map((d) => (
<li key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{devices && devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke all trusted devices
</button>
)}
<button onClick={resetMfa} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
Reset two-factor
</button>
</div>
{msg && <p className="sans" style={{ marginTop: 12, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 12, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</section>
)
}
function ShardSections({ scope }) {
return (
<>
@@ -187,6 +295,7 @@ export default function UserDetail() {
</div>
</div>
<SecurityAdmin userId={id} />
<ShardSections scope={scope} />
</section>
)

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -116,6 +119,7 @@ function TwoFactor({ account, reload }) {
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
async function begin() {
setBusy(true); setMsg(''); setError('')
@@ -131,8 +135,8 @@ function TwoFactor({ account, reload }) {
async function confirm() {
setBusy(true); setMsg(''); setError('')
try {
await api.player.totpEnable(code.trim())
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.')
const res = await api.player.totpEnable(code.trim())
setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
await reload()
} catch (err) {
setError(err.message || 'Could not enable two-factor.')
@@ -204,6 +208,11 @@ function TwoFactor({ account, reload }) {
</div>
)}
<Note msg={msg} error={error} />
{newCodes && (
<div style={{ marginTop: 16 }}>
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
</div>
)}
</Section>
)
}
@@ -416,6 +425,12 @@ export default function PlayerAccount() {
<ChangeUsername account={account} onChanged={onUsernameChanged} />
<ChangePassword account={account} />
<TwoFactor account={account} reload={load} />
{account.totp_enabled && (
<>
<TrustedDevicesPanel />
<RecoveryCodesPanel hasPassword={account.has_password !== false} />
</>
)}
<LinkedAccounts />
<ActiveDevices />
</>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
@@ -34,6 +35,12 @@ export default function PlayerLogin() {
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
const [trustDevice, setTrustDevice] = useState(false)
const [useRecovery, setUseRecovery] = useState(false)
// When trust was requested at login but the device cap is reached: show the
// revoke-to-continue modal, then navigate on resolve. `pendingDest` holds where
// to go once the prompt is dealt with.
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest }
const [providers, setProviders] = useState([])
const [canRegister, setCanRegister] = useState(false)
@@ -111,12 +118,25 @@ export default function PlayerLogin() {
}
navigate(returnTo || '/account', { replace: true })
} else {
const u = await loginTotp(challenge, code)
navigate(destFor(u), { replace: true })
const entered = code.trim()
const data = await loginTotp(challenge, useRecovery ? '' : entered, {
recoveryCode: useRecovery ? entered : undefined,
trustDevice,
})
const to = destFor(data.user)
// Trust was requested but the device cap is reached: the session is already
// issued, so prompt to revoke one before trusting, then navigate.
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)
setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.')
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')
@@ -169,13 +189,42 @@ export default function PlayerLogin() {
</div>
</>
) : (
<label style={{ display: 'block', marginBottom: 22 }}>
<span className="field-label">Authentication code</span>
<input type="text" inputMode="numeric" autoComplete="one-time-code" autoFocus placeholder="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' }}>
Enter the code from your authenticator app.
</span>
</label>
<>
<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>
{/* Trust-this-device only applies to real authenticator/recovery login,
not the SSO 2FA bounce (which has no trust cookie flow here). */}
{!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)) && (
@@ -208,6 +257,14 @@ export default function PlayerLogin() {
</div>
)}
</form>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</PlayerShell>
)
}