Files
Krystie 3dff49cdc5 feat(status): TOTP recovery UI - panel, backup download, always-visible Import
- status/js/totp-recovery.js: NEW. Wires up recovery panel on the TOTP
  gate. Pastes Base32 -> /api/v1/totp/setup -> /verify-setup -> session.
  Exposes window._refreshRecoveryLink() called by totp-auth.js.
- status/js/totp-auth.js: showTotpOverlay() now calls
  _refreshRecoveryLink() so the recovery link hides when TOTP is healthy
  and appears when it's broken.
- status/js/totp-settings.js: removed setupSection.style.display='none'
  so 'Import existing secret' is always visible; added 'Download backup
  file' button after setup that exports the Base32 + recovery
  instructions as JSON.
- status/index.html: added 'Lost access? Recover with saved Base32
  key ->' link to the TOTP overlay plus the recovery panel itself;
  added title tooltip to the auth card reminding users to save the
  Base32 on first setup.
- status/build.js: include JS('totp-recovery.js') in the core bundle
  after totp-auth.js (since recovery registers a hook auth calls).
2026-06-18 19:56:52 -07:00

181 lines
7.2 KiB
JavaScript

// ===== 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;
};
})();