// ===== TOTP RECOVERY FLOW ===== // Public, unauthenticated recovery path for users who can't log in. // Designed for the "encryption key rotated and lost my authenticator" case. // The flow is: // // 1. On TOTP overlay show, call /api/v1/totp/recovery-info (public). // If status === 'unreadable', show the "Lost access?" link on the overlay. // 2. User clicks link → opens recovery panel. // 3. User pastes Base32 secret → POST /api/v1/totp/setup with {secret: ...}. // Backend stores as totp.pending_secret (encrypted with current key). // 4. Panel switches to "verify" mode. User enters a code from the // newly-added authenticator entry. POST /api/v1/totp/verify-setup // promotes pending → active and starts a session. // 5. hideTotpOverlay() and initializeDashboard() — same as normal login. // // The recovery flow never requires the user to be logged in. It does require // them to have their Base32 secret saved (e.g. password manager, screenshot, // the "Download backup file" we offer at setup time — see totp-settings.js). (function() { 'use strict'; // ── Helpers ── async function fetchRecoveryInfo() { try { const r = await fetch('/api/v1/totp/recovery-info', { cache: 'no-store' }); return await r.json(); } catch (e) { return { success: false, status: 'unknown', hint: 'Could not contact server' }; } } function showRecoveryLink(show) { const link = document.getElementById('totp-recovery-link'); if (link) link.style.display = show ? '' : 'none'; } function openRecoveryPanel() { const panel = document.getElementById('totp-recovery-panel'); if (panel) panel.style.display = ''; const statusEl = document.getElementById('totp-recovery-status'); const importEl = document.getElementById('totp-recovery-import'); const verifyEl = document.getElementById('totp-recovery-verify'); if (importEl) importEl.style.display = ''; if (verifyEl) verifyEl.style.display = 'none'; // Reset state document.getElementById('totp-recovery-error').textContent = ''; document.getElementById('totp-recovery-confirm-error').textContent = ''; document.getElementById('totp-recovery-secret').value = ''; document.getElementById('totp-recovery-code').value = ''; // Show current status fetchRecoveryInfo().then(info => { statusEl.textContent = info.hint || ''; // Color-code the status banner if (info.status === 'healthy') { statusEl.style.borderColor = 'var(--ok-fg, #7ef2ff)'; } else if (info.status === 'unreadable') { statusEl.style.borderColor = 'var(--bad-fg, #ff9aa3)'; statusEl.style.background = 'color-mix(in srgb, var(--bad-fg) 6%, transparent)'; } else if (info.status === 'not_configured') { statusEl.style.borderColor = 'var(--muted)'; } else { statusEl.style.borderColor = 'var(--border)'; } }); setTimeout(() => { document.getElementById('totp-recovery-secret')?.focus(); }, 100); } function closeRecoveryPanel() { const panel = document.getElementById('totp-recovery-panel'); if (panel) panel.style.display = 'none'; } async function submitRecoverySecret() { const secret = document.getElementById('totp-recovery-secret').value.trim(); const errorEl = document.getElementById('totp-recovery-error'); errorEl.textContent = ''; if (!secret) { errorEl.textContent = 'Paste your Base32 key first'; return; } if (!/^[A-Za-z2-7\s]+=*$/.test(secret)) { errorEl.textContent = 'Invalid Base32 format — should be letters A-Z and digits 2-7 only'; return; } // POST /api/v1/totp/setup with {secret} — backend stores as pending try { const r = await fetch('/api/v1/totp/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secret }) }); const data = await r.json(); if (!r.ok || !data.success) { errorEl.textContent = data.error || data.message || 'Restore failed'; return; } // Switch panel to verify mode document.getElementById('totp-recovery-import').style.display = 'none'; document.getElementById('totp-recovery-verify').style.display = ''; setTimeout(() => document.getElementById('totp-recovery-code')?.focus(), 100); } catch (e) { errorEl.textContent = 'Network error — try again'; } } async function submitRecoveryCode() { const code = document.getElementById('totp-recovery-code').value.trim(); const errorEl = document.getElementById('totp-recovery-confirm-error'); errorEl.textContent = ''; if (!/^\d{6}$/.test(code)) { errorEl.textContent = 'Enter a 6-digit code'; return; } // POST /api/v1/totp/verify-setup — promotes pending → active + starts session try { const r = await fetch('/api/v1/totp/verify-setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) }); const data = await r.json(); if (!r.ok || !data.success) { errorEl.textContent = data.error || data.message || 'Invalid code'; document.getElementById('totp-recovery-code').value = ''; document.getElementById('totp-recovery-code')?.focus(); return; } // Success — hide everything and initialize dashboard closeRecoveryPanel(); const overlay = document.getElementById('totp-overlay'); if (overlay) overlay.classList.remove('show'); if (typeof window.initializeDashboard === 'function') { window.initializeDashboard(); } } catch (e) { errorEl.textContent = 'Network error — try again'; } } // ── Wire up handlers ── document.getElementById('totp-show-recovery')?.addEventListener('click', (e) => { e.preventDefault(); openRecoveryPanel(); }); document.getElementById('totp-recovery-close')?.addEventListener('click', closeRecoveryPanel); document.getElementById('totp-recovery-submit')?.addEventListener('click', submitRecoverySecret); document.getElementById('totp-recovery-confirm')?.addEventListener('click', submitRecoveryCode); // Enter key submits in secret field document.getElementById('totp-recovery-secret')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submitRecoverySecret(); } }); // Enter key submits in code field document.getElementById('totp-recovery-code')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submitRecoveryCode(); } }); // ── Public API ── // Called by totp-auth.js after showing the overlay, so we can decide whether // to show the recovery link. We do this with a public endpoint that doesn't // require auth — perfect for the locked-out state. window._refreshRecoveryLink = async function() { const info = await fetchRecoveryInfo(); // Show the link in any non-healthy state (unreadable / corrupt / unknown). // The hint inside the panel tells the user what the actual issue is. if (info && info.success && info.status && info.status !== 'healthy') { showRecoveryLink(true); } else { showRecoveryLink(false); } return info; }; })();