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

63 lines
2.2 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 { 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>
)
}