329 lines
15 KiB
JavaScript
329 lines
15 KiB
JavaScript
// ===== TOTP SETTINGS =====
|
|
(function() {
|
|
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: 16px;">
|
|
<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>
|
|
|
|
<!-- 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)';
|
|
setupSection.style.display = 'none';
|
|
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 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 = '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 = 'NO';
|
|
dot.className = 'dot bad at-bl';
|
|
statusText.textContent = '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) {
|
|
console.error('TOTP setup failed:', e);
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// 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) {
|
|
console.error('Failed to update session duration:', err);
|
|
}
|
|
});
|
|
|
|
// 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) {
|
|
console.error('Failed to disable TOTP:', e);
|
|
}
|
|
});
|
|
|
|
// 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) { console.error('[AuthCard] Failed to update:', e); }
|
|
})();
|
|
|
|
})();
|