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>
63 lines
2.2 KiB
JavaScript
63 lines
2.2 KiB
JavaScript
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' }}>
|
||
I’ve saved them
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|