From 3dff49cdc56060f4be561e83ff0fa5513e3cc137 Mon Sep 17 00:00:00 2001 From: Krystie Date: Thu, 18 Jun 2026 19:56:52 -0700 Subject: [PATCH] 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). --- status/build.js | 3 + status/index.html | 69 +++++++++++++- status/js/totp-auth.js | 7 ++ status/js/totp-recovery.js | 180 +++++++++++++++++++++++++++++++++++++ status/js/totp-settings.js | 59 +++++++++++- 5 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 status/js/totp-recovery.js diff --git a/status/build.js b/status/build.js index 2f7311f..86abd26 100644 --- a/status/build.js +++ b/status/build.js @@ -19,6 +19,9 @@ const bundles = { JS('skeleton-loader.js'), JS('theme.js'), JS('totp-auth.js'), + // totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js + // calls from showTotpOverlay(). Must come after totp-auth.js. + JS('totp-recovery.js'), JS('service-credentials.js'), JS('totp-settings.js'), JS('core', 'credentials.js'), diff --git a/status/index.html b/status/index.html index c0a6c2c..fb249d7 100644 --- a/status/index.html +++ b/status/index.html @@ -43,6 +43,59 @@
+ + + + + + @@ -199,7 +252,8 @@
-
+
@@ -256,6 +310,9 @@ +
@@ -390,8 +447,14 @@
+
@@ -406,7 +469,7 @@
diff --git a/status/js/totp-auth.js b/status/js/totp-auth.js index d592122..c5a7465 100644 --- a/status/js/totp-auth.js +++ b/status/js/totp-auth.js @@ -21,6 +21,13 @@ const firstInput = overlay.querySelector('.totp-digits input'); if (firstInput) setTimeout(() => firstInput.focus(), 100); } + // Refresh the "Lost access?" recovery link visibility based on server state. + // Hides itself if TOTP is healthy; shows if unreadable/corrupt. The user + // can still click it even when healthy — but the panel will explain there's + // no recovery needed. Cheaper than gating it. + if (typeof window._refreshRecoveryLink === 'function') { + window._refreshRecoveryLink(); + } } function hideTotpOverlay() { diff --git a/status/js/totp-recovery.js b/status/js/totp-recovery.js new file mode 100644 index 0000000..2844871 --- /dev/null +++ b/status/js/totp-recovery.js @@ -0,0 +1,180 @@ +// ===== 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; + }; +})(); diff --git a/status/js/totp-settings.js b/status/js/totp-settings.js index b4e54e5..b8ac0e1 100644 --- a/status/js/totp-settings.js +++ b/status/js/totp-settings.js @@ -38,11 +38,25 @@