Full language display names + RTL set (ar/fa/ur) on /i18n/languages; card.internet/auth/tailscale/dashca, status pills, filter bar and batch-operation strings added to every language dictionary. Frontend: English now loads the server dictionary too (keys are semantic ids, not fallback copy), failed loads keep existing DOM text instead of exposing raw keys, isLoaded() gate for pre-load renders. Rebuilt status/dist. Tests: i18n-cards 9/9, full suite 1837/1837.
390 lines
18 KiB
JavaScript
390 lines
18 KiB
JavaScript
// ===== TOTP SETTINGS =====
|
|
(function() {
|
|
const errorHandler = new ErrorHandler();
|
|
injectModal('totp-settings-modal', `<div id="totp-settings-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
|
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
|
|
|
|
<!-- Status Banner -->
|
|
<div id="totp-status-banner" style="margin-bottom: 16px; padding: 12px 16px; border-radius: 8px; border: 1px solid var(--border); display: flex; align-items: center; gap: 8px;">
|
|
<span id="totp-status-dot" class="status-dot"></span>
|
|
<span id="totp-status-text" style="font-size: 0.9rem;">TOTP is not configured</span>
|
|
</div>
|
|
|
|
<!-- Setup Button (not configured state) -->
|
|
<div id="totp-setup-section">
|
|
<button id="totp-setup-btn" class="btn-accent-solid" style="width: 100%; padding: 12px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.95rem;">
|
|
Generate New Secret
|
|
</button>
|
|
<div style="display: flex; align-items: center; gap: 8px; margin: 10px 0 0;">
|
|
<div class="divider-line"></div>
|
|
<span class="text-tiny-muted">or</span>
|
|
<div class="divider-line"></div>
|
|
</div>
|
|
<div style="margin-top: 10px;">
|
|
<label class="text-hint">Import an existing secret key:</label>
|
|
<div style="display: flex; gap: 8px; margin-top: 6px;">
|
|
<input type="text" id="totp-import-key" 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-import-btn" 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;">
|
|
Import
|
|
</button>
|
|
</div>
|
|
<div id="totp-import-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Secret Display (setup flow) -->
|
|
<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: 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>
|
|
<div style="text-align: center; margin-top: 8px;">
|
|
<img id="totp-qr-image" style="width: 180px; height: 180px; border-radius: 8px;" />
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Verify First Code -->
|
|
<div style="border-top: 1px solid var(--border); padding-top: 16px;">
|
|
<label class="font-bold-sm">Enter code to confirm setup:</label>
|
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
|
<input type="text" id="totp-setup-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000"
|
|
style="flex: 1; text-align: center; font-size: 1.3rem; padding: 10px; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 2px solid var(--border); border-radius: 8px; outline: none;" />
|
|
<button id="totp-confirm-setup" class="btn-accent-solid" style="padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
|
|
Confirm
|
|
</button>
|
|
</div>
|
|
<div id="totp-setup-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Session Duration (active state) -->
|
|
<div id="totp-duration-section" style="display: none; margin-top: 12px;">
|
|
<label class="font-bold-sm">Session Duration:</label>
|
|
<select id="totp-duration-select" style="width: 100%; padding: 10px; margin-top: 6px; background: var(--bg, #0b0f1a); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; font-size: 0.9rem; cursor: pointer;">
|
|
<option value="15m">15 minutes</option>
|
|
<option value="30m">30 minutes</option>
|
|
<option value="1h">1 hour</option>
|
|
<option value="2h">2 hours</option>
|
|
<option value="4h">4 hours</option>
|
|
<option value="8h">8 hours</option>
|
|
<option value="12h">12 hours</option>
|
|
<option value="24h">24 hours</option>
|
|
<option value="never">Never (disable TOTP)</option>
|
|
</select>
|
|
<p style="font-size: 0.75rem; color: var(--muted); margin: 4px 0 0;">How long before you need to re-enter your code</p>
|
|
</div>
|
|
|
|
<!-- Disable Button (active state) -->
|
|
<div id="totp-disable-section" style="display: none; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border);">
|
|
<button id="totp-disable-btn" style="width: 100%; padding: 10px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 8px; cursor: pointer; font-size: 0.9rem;">
|
|
Disable TOTP
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Close -->
|
|
<div style="margin-top: 16px; text-align: right;">
|
|
<button id="totp-modal-close" style="padding: 8px 20px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
|
|
async function loadTotpSettings() {
|
|
try {
|
|
const res = await fetch('/api/v1/totp/config');
|
|
const data = await res.json();
|
|
if (!data.success) return;
|
|
|
|
const { enabled, sessionDuration, isSetUp } = data.config;
|
|
const statusDot = document.getElementById('totp-status-dot');
|
|
const statusText = document.getElementById('totp-status-text');
|
|
const statusBanner = document.getElementById('totp-status-banner');
|
|
const setupSection = document.getElementById('totp-setup-section');
|
|
const qrSection = document.getElementById('totp-qr-section');
|
|
const durationSection = document.getElementById('totp-duration-section');
|
|
const disableSection = document.getElementById('totp-disable-section');
|
|
|
|
if (enabled && isSetUp) {
|
|
statusDot.style.background = 'var(--ok-fg, #7ef2ff)';
|
|
statusBanner.style.borderColor = 'var(--ok-fg, #7ef2ff)';
|
|
statusBanner.style.background = 'color-mix(in srgb, var(--ok-fg) 8%, transparent)';
|
|
statusText.textContent = 'TOTP is active';
|
|
statusText.style.color = 'var(--ok-fg, #7ef2ff)';
|
|
// 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';
|
|
document.getElementById('totp-duration-select').value = sessionDuration;
|
|
} else {
|
|
statusDot.style.background = 'var(--muted)';
|
|
statusBanner.style.borderColor = 'var(--border)';
|
|
statusBanner.style.background = 'transparent';
|
|
statusText.textContent = 'TOTP is not configured';
|
|
statusText.style.color = 'var(--muted)';
|
|
setupSection.style.display = 'block';
|
|
qrSection.style.display = 'none';
|
|
durationSection.style.display = 'none';
|
|
disableSection.style.display = 'none';
|
|
}
|
|
|
|
// Update the Auth card in the top row
|
|
updateAuthCard(enabled && isSetUp, sessionDuration);
|
|
} catch (e) {
|
|
console.warn('Failed to load TOTP settings:', e);
|
|
}
|
|
}
|
|
|
|
// Duration label helper
|
|
const DURATION_LABELS = {
|
|
'15m': '15 min', '30m': '30 min', '1h': '1 hour', '2h': '2 hours',
|
|
'4h': '4 hours', '8h': '8 hours', '12h': '12 hours', '24h': '24 hours', 'never': 'Disabled'
|
|
};
|
|
|
|
function translated(key, fallback) {
|
|
const i18n = window.DCI18n;
|
|
return i18n && i18n.isLoaded() ? i18n.t(key) : fallback;
|
|
}
|
|
|
|
function updateAuthCard(active, duration) {
|
|
const card = document.getElementById('auth-card');
|
|
const pill = document.getElementById('auth-pill');
|
|
const dot = document.getElementById('auth-dot');
|
|
const statusText = document.getElementById('auth-status-text');
|
|
if (!card) return;
|
|
|
|
if (active) {
|
|
card.setAttribute('data-status', 'on');
|
|
pill.className = 'badge on';
|
|
pill.textContent = translated('card.status.yes', 'YES');
|
|
dot.className = 'dot ok at-bl';
|
|
statusText.textContent = 'Session: ' + (DURATION_LABELS[duration] || duration);
|
|
} else {
|
|
card.setAttribute('data-status', 'off');
|
|
pill.className = 'badge off';
|
|
pill.textContent = translated('card.status.no', 'NO');
|
|
dot.className = 'dot bad at-bl';
|
|
statusText.textContent = translated('card.auth.not_configured', 'Not configured');
|
|
}
|
|
}
|
|
|
|
// Setup button (generate new secret)
|
|
document.getElementById('totp-setup-btn')?.addEventListener('click', async () => {
|
|
try {
|
|
const res = await secureFetch('/api/v1/totp/setup', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
document.getElementById('totp-qr-image').src = data.qrCode;
|
|
document.getElementById('totp-manual-key').textContent = data.manualKey;
|
|
document.getElementById('totp-setup-section').style.display = 'none';
|
|
document.getElementById('totp-qr-section').style.display = 'block';
|
|
document.getElementById('totp-setup-code').value = '';
|
|
document.getElementById('totp-setup-error').textContent = '';
|
|
document.getElementById('totp-setup-code').focus();
|
|
}
|
|
} catch (e) {
|
|
errorHandler.logError('[TOTP] Setup Failed', e, { function: 'setupTOTP' });
|
|
}
|
|
});
|
|
|
|
// Import existing secret button
|
|
document.getElementById('totp-import-btn')?.addEventListener('click', async () => {
|
|
const secret = document.getElementById('totp-import-key').value.trim();
|
|
const errorEl = document.getElementById('totp-import-error');
|
|
errorEl.textContent = '';
|
|
if (!secret) {
|
|
errorEl.textContent = 'Paste a Base32 secret key first';
|
|
return;
|
|
}
|
|
try {
|
|
const res = await secureFetch('/api/v1/totp/setup', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ secret })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
errorEl.textContent = '';
|
|
document.getElementById('totp-qr-image').src = data.qrCode;
|
|
document.getElementById('totp-manual-key').textContent = data.manualKey;
|
|
document.getElementById('totp-setup-section').style.display = 'none';
|
|
document.getElementById('totp-qr-section').style.display = 'block';
|
|
document.getElementById('totp-setup-code').value = '';
|
|
document.getElementById('totp-setup-error').textContent = '';
|
|
document.getElementById('totp-setup-code').focus();
|
|
} else {
|
|
errorEl.textContent = data.error || data.message || 'Import failed';
|
|
}
|
|
} catch (e) {
|
|
errorEl.textContent = 'Connection error — try refreshing the page';
|
|
}
|
|
});
|
|
|
|
// Copy key button
|
|
document.getElementById('totp-copy-key')?.addEventListener('click', () => {
|
|
const key = document.getElementById('totp-manual-key').textContent;
|
|
navigator.clipboard.writeText(key).then(() => {
|
|
const btn = document.getElementById('totp-copy-key');
|
|
btn.textContent = '✅';
|
|
setTimeout(() => { btn.textContent = '📋'; }, 2000);
|
|
});
|
|
});
|
|
|
|
// 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;
|
|
const errorEl = document.getElementById('totp-setup-error');
|
|
if (!/^\d{6}$/.test(code)) {
|
|
errorEl.textContent = 'Enter a 6-digit code';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await secureFetch('/api/v1/totp/verify-setup', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
errorEl.textContent = '';
|
|
loadTotpSettings(); // Updates both modal and card
|
|
} else {
|
|
errorEl.textContent = data.error || 'Invalid code';
|
|
}
|
|
} catch (e) {
|
|
errorEl.textContent = 'Connection error';
|
|
}
|
|
});
|
|
|
|
// Allow Enter key on setup code input
|
|
document.getElementById('totp-setup-code')?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') document.getElementById('totp-confirm-setup')?.click();
|
|
});
|
|
|
|
// Duration change
|
|
document.getElementById('totp-duration-select')?.addEventListener('change', async (e) => {
|
|
try {
|
|
await secureFetch('/api/v1/totp/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionDuration: e.target.value })
|
|
});
|
|
loadTotpSettings(); // Refresh modal + card (handles "never" disabling TOTP)
|
|
} catch (err) {
|
|
errorHandler.logError('[TOTP] Update Session Duration', err, { function: 'updateSessionDuration' });
|
|
}
|
|
});
|
|
|
|
// Disable TOTP
|
|
document.getElementById('totp-disable-btn')?.addEventListener('click', async () => {
|
|
if (!confirm('Disable TOTP authentication? All services will be accessible without a code.')) return;
|
|
try {
|
|
const res = await secureFetch('/api/v1/totp/disable', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{}'
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) loadTotpSettings();
|
|
} catch (e) {
|
|
errorHandler.logError('[TOTP] Disable Failed', e, { function: 'disableTOTP' });
|
|
}
|
|
});
|
|
|
|
// Open settings modal from Auth card
|
|
document.getElementById('auth-settings-btn')?.addEventListener('click', () => {
|
|
loadTotpSettings();
|
|
openModal('totp-settings-modal');
|
|
});
|
|
|
|
// Close settings modal
|
|
document.getElementById('totp-modal-close')?.addEventListener('click', () => {
|
|
closeModal('totp-settings-modal');
|
|
});
|
|
|
|
// Backdrop click to close
|
|
document.getElementById('totp-settings-modal')?.addEventListener('click', (e) => {
|
|
if (e.target.id === 'totp-settings-modal') {
|
|
closeModal('totp-settings-modal');
|
|
}
|
|
});
|
|
|
|
// Update auth card on page load
|
|
window._updateAuthCard = updateAuthCard;
|
|
(async () => {
|
|
try {
|
|
const res = await fetch('/api/v1/totp/config');
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
const active = data.config.enabled && data.config.isSetUp;
|
|
updateAuthCard(active, data.config.sessionDuration);
|
|
}
|
|
} catch (e) { errorHandler.logError('[TOTP] AuthCard Update', e, { function: 'authCardUpdate' }); }
|
|
})();
|
|
|
|
})();
|