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).
This commit is contained in:
Krystie
2026-06-18 19:56:52 -07:00
parent d230b39948
commit 3dff49cdc5
5 changed files with 313 additions and 5 deletions
+57 -2
View File
@@ -38,11 +38,25 @@
<div id="totp-qr-section" style="display: none;">
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">Copy this key into your authenticator app:</p>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<code id="totp-manual-key" style="flex: 1; display: block; padding: 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 2px; word-break: break-all; user-select: all; color: var(--fg);"></code>
<button id="totp-copy-key" style="padding: 10px 14px; background: var(--card-base); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 1rem; white-space: nowrap; color: var(--fg);" title="Copy to clipboard">📋</button>
</div>
<!-- Download backup file (recovery aid) -->
<div style="margin-bottom: 16px; padding: 10px 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 0.8rem; color: var(--muted); flex: 1;">
<strong style="color: var(--fg);">Save a backup file</strong> — if you ever lose your authenticator,
this is the only way to recover without SSH access to the server.
</span>
<button id="totp-download-backup" type="button"
style="padding: 8px 14px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
⬇ Download
</button>
</div>
</div>
<!-- QR Code (secondary - for mobile apps) -->
<details class="mb-16">
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
@@ -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;