- 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).
181 lines
7.2 KiB
JavaScript
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;
|
|
};
|
|
})();
|