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>
138 lines
4.9 KiB
JavaScript
138 lines
4.9 KiB
JavaScript
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 you’ve trusted skip the authenticator step at login (your password is still required).
|
||
Revoke any you don’t 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>
|
||
)
|
||
}
|