Files
website/client/src/components/security/TrustedDevicesPanel.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

138 lines
4.9 KiB
JavaScript
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.

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>
)
}