+ Paste the Base32 secret you saved when you first set up TOTP (e.g. JBSWY3DPEHPK3PXP).
+ If you don't have it, you'll need to SSH into the server to rotate the encryption key.
+
+
+
+
+
+
+
+
+
+
+
+ Secret accepted. Add it to your authenticator app and enter a 6-digit code to confirm.
+
+
+
+
+
+
+
+
+
+
+
@@ -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 @@
Copy this key into your authenticator app:
-
+
+
+
+
+
+ Save a backup file — if you ever lose your authenticator,
+ this is the only way to recover without SSH access to the server.
+
+
+
+
+
Show QR code (for mobile authenticator apps)
@@ -119,7 +133,13 @@
statusBanner.style.background = 'color-mix(in srgb, var(--ok-fg) 8%, transparent)';
statusText.textContent = 'TOTP is active';
statusText.style.color = 'var(--ok-fg, #7ef2ff)';
- setupSection.style.display = 'none';
+ // Keep the setup section visible (collapsed) so the "Import existing
+ // secret" option is always reachable — users may need to re-enroll
+ // their authenticator with the same secret from a backup file.
+ setupSection.style.display = 'block';
+ const setupBtn = document.getElementById('totp-setup-btn');
+ if (setupBtn) setupBtn.textContent = 'Generate New Secret';
+ // Hide the QR section by default in the active state — setupBtn click shows it
qrSection.style.display = 'none';
durationSection.style.display = 'block';
disableSection.style.display = 'block';
@@ -233,6 +253,41 @@
});
});
+ // Download backup file — plain JSON so it round-trips through any password
+ // manager, cloud backup, or printed paper. The secret IS recoverable plaintext
+ // (that's the whole point of the backup), so warn the user and rely on
+ // them to keep it safe.
+ document.getElementById('totp-download-backup')?.addEventListener('click', () => {
+ const secret = document.getElementById('totp-manual-key').textContent.trim();
+ if (!secret) return;
+ const payload = {
+ service: 'DashCaddy',
+ type: 'totp-secret',
+ secret: secret,
+ issuer: 'DashCaddy',
+ algorithm: 'SHA1',
+ digits: 6,
+ period: 30,
+ issued: new Date().toISOString(),
+ // Recovery instructions baked into the file so a year from now the
+ // user (or their future self) knows what this file is and how to use it.
+ recovery_url: `${window.location.origin}/ (login screen → "Lost access?")`,
+ note: 'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'
+ };
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `dashcaddy-totp-backup-${new Date().toISOString().slice(0, 10)}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ const btn = document.getElementById('totp-download-backup');
+ btn.textContent = '✅ Saved';
+ setTimeout(() => { btn.textContent = '⬇ Download'; }, 2000);
+ });
+
// Confirm setup
document.getElementById('totp-confirm-setup')?.addEventListener('click', async () => {
const code = document.getElementById('totp-setup-code').value;