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:
@@ -19,6 +19,9 @@ const bundles = {
|
|||||||
JS('skeleton-loader.js'),
|
JS('skeleton-loader.js'),
|
||||||
JS('theme.js'),
|
JS('theme.js'),
|
||||||
JS('totp-auth.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('service-credentials.js'),
|
||||||
JS('totp-settings.js'),
|
JS('totp-settings.js'),
|
||||||
JS('core', 'credentials.js'),
|
JS('core', 'credentials.js'),
|
||||||
|
|||||||
+66
-3
@@ -43,6 +43,59 @@
|
|||||||
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]">
|
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]">
|
||||||
</div>
|
</div>
|
||||||
<div class="totp-error" id="totp-error"></div>
|
<div class="totp-error" id="totp-error"></div>
|
||||||
|
<div class="totp-recovery-link" id="totp-recovery-link" style="display: none;">
|
||||||
|
<a href="#" id="totp-show-recovery">Lost access? Recover with saved Base32 key →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOTP Recovery Panel (hidden by default, shown via "Lost access?" link on overlay) -->
|
||||||
|
<div id="totp-recovery-panel" class="weather-modal" style="display: none;">
|
||||||
|
<div class="weather-modal-content" style="min-width: 420px; max-width: 540px;">
|
||||||
|
<h3 style="margin: 0 0 12px; font-size: 1.1rem;">Recover TOTP Access</h3>
|
||||||
|
<div id="totp-recovery-status" style="margin-bottom: 12px; padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem; line-height: 1.4;"></div>
|
||||||
|
|
||||||
|
<!-- Path A: Paste saved Base32 secret -->
|
||||||
|
<div id="totp-recovery-import">
|
||||||
|
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||||
|
Paste the Base32 secret you saved when you first set up TOTP (e.g. <code>JBSWY3DPEHPK3PXP</code>).
|
||||||
|
If you don't have it, you'll need to SSH into the server to rotate the encryption key.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||||
|
<input type="text" id="totp-recovery-secret" placeholder="Paste your Base32 key"
|
||||||
|
autocomplete="off" spellcheck="false"
|
||||||
|
style="flex: 1; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; font-family: monospace; letter-spacing: 1px; text-transform: uppercase;" />
|
||||||
|
<button id="totp-recovery-submit"
|
||||||
|
style="padding: 10px 16px; 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;">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="totp-recovery-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Path B: After successful import, ask user to verify with code -->
|
||||||
|
<div id="totp-recovery-verify" style="display: none;">
|
||||||
|
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">
|
||||||
|
Secret accepted. Add it to your authenticator app and enter a 6-digit code to confirm.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||||
|
<input type="text" id="totp-recovery-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
placeholder="000000" autocomplete="one-time-code"
|
||||||
|
style="flex: 1; padding: 10px; text-align: center; font-size: 1.2rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||||||
|
<button id="totp-recovery-confirm"
|
||||||
|
style="padding: 10px 16px; 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;">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="totp-recovery-confirm-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 14px; text-align: right;">
|
||||||
|
<button id="totp-recovery-close"
|
||||||
|
style="padding: 8px 18px; background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.85rem;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,7 +252,8 @@
|
|||||||
<div class="btn-row"><!-- No button for Internet --></div>
|
<div class="btn-row"><!-- No button for Internet --></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" data-app="auth" data-status="off" id="auth-card">
|
<div class="card" data-app="auth" data-status="off" id="auth-card"
|
||||||
|
title="Two-factor authentication (TOTP). On first setup, save the Base32 secret — it's the only way to recover if you ever lose your authenticator.">
|
||||||
<span id="auth-dot" class="dot bad at-bl"></span>
|
<span id="auth-dot" class="dot bad at-bl"></span>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="logo-wrap">
|
<div class="logo-wrap">
|
||||||
@@ -256,6 +310,9 @@
|
|||||||
<option value="on">🟢 Online</option>
|
<option value="on">🟢 Online</option>
|
||||||
<option value="off">🔴 Offline</option>
|
<option value="off">🔴 Offline</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
</select>
|
||||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,8 +447,14 @@
|
|||||||
<!-- DNS Server Configuration -->
|
<!-- DNS Server Configuration -->
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label-accent">
|
<label class="form-label-accent">
|
||||||
🗂️ DNS Server (Technitium)
|
🗂️ DNS Provider
|
||||||
</label>
|
</label>
|
||||||
|
<select id="setup-dns-provider" class="form-input-lg" style="margin-bottom: 12px;">
|
||||||
|
<option value="technitium">Technitium DNS (recommended)</option>
|
||||||
|
<option value="cloudflare">Cloudflare DNS</option>
|
||||||
|
<option value="rfc2136">RFC 2136 (BIND / PowerDNS / other)</option>
|
||||||
|
<option value="manual">Manual / External DNS</option>
|
||||||
|
</select>
|
||||||
<div style="display: grid; grid-template-columns: 1fr auto; gap: 8px;">
|
<div style="display: grid; grid-template-columns: 1fr auto; gap: 8px;">
|
||||||
<input type="text" id="setup-dns-ip" value="" placeholder="DNS server IP"
|
<input type="text" id="setup-dns-ip" value="" placeholder="DNS server IP"
|
||||||
style="padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
style="padding: 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem;" />
|
||||||
@@ -406,7 +469,7 @@
|
|||||||
<!-- DNS Admin Token -->
|
<!-- DNS Admin Token -->
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label-accent">
|
<label class="form-label-accent">
|
||||||
🔑 Technitium Admin Token
|
🔑 DNS Admin Token / API Key
|
||||||
</label>
|
</label>
|
||||||
<input type="password" id="setup-dns-token" placeholder="Paste your admin token here"
|
<input type="password" id="setup-dns-token" placeholder="Paste your admin token here"
|
||||||
class="form-input-lg" />
|
class="form-input-lg" />
|
||||||
|
|||||||
@@ -21,6 +21,13 @@
|
|||||||
const firstInput = overlay.querySelector('.totp-digits input');
|
const firstInput = overlay.querySelector('.totp-digits input');
|
||||||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
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() {
|
function hideTotpOverlay() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -38,11 +38,25 @@
|
|||||||
<div id="totp-qr-section" style="display: none;">
|
<div id="totp-qr-section" style="display: none;">
|
||||||
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
|
<!-- 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>
|
<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>
|
<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>
|
<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>
|
</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) -->
|
<!-- QR Code (secondary - for mobile apps) -->
|
||||||
<details class="mb-16">
|
<details class="mb-16">
|
||||||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
|
<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)';
|
statusBanner.style.background = 'color-mix(in srgb, var(--ok-fg) 8%, transparent)';
|
||||||
statusText.textContent = 'TOTP is active';
|
statusText.textContent = 'TOTP is active';
|
||||||
statusText.style.color = 'var(--ok-fg, #7ef2ff)';
|
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';
|
qrSection.style.display = 'none';
|
||||||
durationSection.style.display = 'block';
|
durationSection.style.display = 'block';
|
||||||
disableSection.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
|
// Confirm setup
|
||||||
document.getElementById('totp-confirm-setup')?.addEventListener('click', async () => {
|
document.getElementById('totp-confirm-setup')?.addEventListener('click', async () => {
|
||||||
const code = document.getElementById('totp-setup-code').value;
|
const code = document.getElementById('totp-setup-code').value;
|
||||||
|
|||||||
Reference in New Issue
Block a user