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
+66 -3
View File
@@ -43,6 +43,59 @@
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]">
</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>
@@ -199,7 +252,8 @@
<div class="btn-row"><!-- No button for Internet --></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>
<div class="row">
<div class="logo-wrap">
@@ -256,6 +310,9 @@
<option value="on">🟢 Online</option>
<option value="off">🔴 Offline</option>
</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>
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
</div>
@@ -390,8 +447,14 @@
<!-- DNS Server Configuration -->
<div>
<label class="form-label-accent">
🗂️ DNS Server (Technitium)
🗂️ DNS Provider
</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;">
<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;" />
@@ -406,7 +469,7 @@
<!-- DNS Admin Token -->
<div>
<label class="form-label-accent">
🔑 Technitium Admin Token
🔑 DNS Admin Token / API Key
</label>
<input type="password" id="setup-dns-token" placeholder="Paste your admin token here"
class="form-input-lg" />