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>
87 lines
3.3 KiB
JavaScript
87 lines
3.3 KiB
JavaScript
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>
|
|
)
|
|
}
|