// ============================================================ // AUTH.JS — Login, Register, 2FA, Session Management // ============================================================ // Wait for DOM to be fully ready document.addEventListener('DOMContentLoaded', function() { var authScreen = document.getElementById('auth-screen'); var mainApp = document.getElementById('main-app'); var loginForm = document.getElementById('login-form'); var registerForm = document.getElementById('register-form'); var forgotForm = document.getElementById('forgot-form'); var userName = document.getElementById('user-name'); var TOKEN_KEY = 'ped_scribe_token'; var USER_KEY = 'ped_scribe_user'; var SESSION_KEY = 'ped_session_id'; // Runtime-split auth model: // - Web browser → httpOnly cookie only (no localStorage token, XSS-safe). // fetch() defaults to credentials:'same-origin' so cookies are sent. // - Capacitor native app → Bearer token in iOS Keychain / Android Keystore // via SecureStorage wrapper. WebView cookies can be evicted; Keychain // survives cold starts reliably. function isNativeApp() { try { return !!(window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform()); } catch(e) { return false; } } window.IS_NATIVE_APP = isNativeApp(); // Hide elements that only make sense for the web audience (e.g. the // "Download Android APK" link on the login page — you're already in // the app). Add more element IDs here as other web-only UI appears. if (isNativeApp()) { ['apk-download-link'].forEach(function(id) { var el = document.getElementById(id); if (el) el.style.display = 'none'; }); } // Auth module initialized // Make sure forms are visible/hidden correctly on load showLoginForm(); // Auth screen is hidden by CSS default — only show it when there is no valid session function showAuthScreen() { if (authScreen) authScreen.style.display = 'flex'; } // ── Check for SSO redirect (token is in httpOnly cookie) ── var urlParams = new URLSearchParams(window.location.search); var ssoOk = urlParams.get('sso'); var ssoError = urlParams.get('error'); if (ssoOk === 'ok') { var ssoSid = urlParams.get('sid'); history.replaceState(null, '', window.location.pathname); if (ssoSid && isNativeApp()) { window.SecureStorage.set(SESSION_KEY, ssoSid); } // Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically) fetch('/api/auth/me', { credentials: 'same-origin' }) .then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); }) .then(function(data) { if (data && data.user) { enterApp(data.user, ''); showToast('Welcome, ' + data.user.name + '!', 'success'); } else { showAuthScreen(); clearSession(); } }) .catch(function() { showAuthScreen(); clearSession(); }); } else if (ssoError) { history.replaceState(null, '', window.location.pathname); var errorMsgs = { invalid_state: 'SSO session expired', expired: 'SSO session expired', no_email: 'Your identity provider did not return an email', disabled: 'Account disabled', sso_failed: 'SSO login failed', email_unverified: 'Your identity provider did not confirm your email is verified. Contact your administrator.', sub_mismatch: 'Your SSO identity does not match the linked account. Contact your administrator.' }; showAuthScreen(); setTimeout(function() { showToast(errorMsgs[ssoError] || 'SSO error', 'error'); }, 300); } // ── Check OIDC status to show/hide SSO button ── fetch('/api/auth/oidc-status') .then(function(r) { return r.json(); }) .then(function(data) { if (data.oidcEnabled) { var ssoBtn = document.getElementById('btn-sso'); var ssoDivider = document.getElementById('sso-divider'); var ssoLabel = document.getElementById('sso-label'); if (ssoBtn) ssoBtn.style.display = 'block'; if (ssoDivider) ssoDivider.style.display = 'block'; if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel; if (data.disableLocalAuth) { // Hide local login form fields, only show SSO var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, #show-register, #show-forgot'); localFields.forEach(function(el) { el.style.display = 'none'; }); if (ssoDivider) ssoDivider.style.display = 'none'; } } }) .catch(function() {}); if (isNativeApp()) { // Native app path: token lives in Keychain/Keystore via SecureStorage window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY]).then(function() { var savedToken = window.SecureStorage.getSync(TOKEN_KEY); if (ssoOk || ssoError) return; // SSO branch handled above if (!savedToken) { showAuthScreen(); return; } fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + savedToken } }) .then(function(r) { if (r.ok) return r.json(); throw new Error('expired'); }) .then(function(data) { if (data && data.user) enterApp(data.user, savedToken); else { showAuthScreen(); clearSession(); } }) .catch(function() { showAuthScreen(); clearSession(); }); }); } else { // Web path: rely on httpOnly cookie. fetch sends same-origin cookies // automatically in modern browsers — no Authorization header needed. if (ssoOk || ssoError) { // SSO branch handled above } else { fetch('/api/auth/me', { credentials: 'same-origin' }) .then(function(r) { if (r.ok) return r.json(); throw new Error('not-logged-in'); }) .then(function(data) { if (data && data.user) enterApp(data.user, ''); else showAuthScreen(); }) .catch(function() { showAuthScreen(); }); } } // ---- HELPER FUNCTIONS ---- function showLoginForm() { if (loginForm) loginForm.style.display = 'block'; if (registerForm) registerForm.style.display = 'none'; if (forgotForm) forgotForm.style.display = 'none'; } function showRegisterForm() { if (loginForm) loginForm.style.display = 'none'; if (registerForm) registerForm.style.display = 'block'; if (forgotForm) forgotForm.style.display = 'none'; } function showForgotForm() { if (loginForm) loginForm.style.display = 'none'; if (registerForm) registerForm.style.display = 'none'; if (forgotForm) forgotForm.style.display = 'block'; } function enterApp(user, token) { window.AUTH_TOKEN = token; window.CURRENT_USER = user; // cached so settings page doesn't re-fetch /me just to read canLocalAuth if (isNativeApp()) { // Mobile: persist token to Keychain/Keystore window.SecureStorage.set(TOKEN_KEY, token); window.SecureStorage.set(USER_KEY, JSON.stringify(user)); } // Web: do not persist the token anywhere — session lives in the // httpOnly cookie already set by the server. User info is // re-fetched via /api/auth/me on each boot. if (authScreen) authScreen.style.display = 'none'; if (mainApp) { mainApp.style.display = 'block'; mainApp.className = mainApp.className.replace('hidden', '').trim(); } if (userName) userName.textContent = user.name || user.email; // Show admin tab for admins only var adminTabBtn = document.getElementById('admin-tab-btn'); if (adminTabBtn) { if (user.role === 'admin') adminTabBtn.classList.remove('hidden'); else adminTabBtn.classList.add('hidden'); } // Show Content Manager tab for admin + moderator var cmsTabBtn = document.getElementById('cms-tab-btn'); if (cmsTabBtn) { if (user.role === 'admin' || user.role === 'moderator') cmsTabBtn.classList.remove('hidden'); else cmsTabBtn.classList.add('hidden'); } // Store role globally for JS modules to check window._userRole = user.role; console.log('[Auth] ✅ Entered app as:', user.email, 'Role:', user.role); // Restore last visited tab (saved by activateTab) var lastTab = null; try { lastTab = localStorage.getItem('ped_last_tab'); } catch(e) {} if (typeof window.activateTab === 'function') { if (!lastTab || !window.activateTab(lastTab)) { window.activateTab('encounter'); } } // Load announcement banner if function is available if (typeof loadAnnouncement === 'function') loadAnnouncement(); // Check if server-side transcription is configured if (typeof checkTranscribeStatus === 'function') checkTranscribeStatus(); } function exitApp() { // Destroy session server-side before clearing local state fetch('/api/auth/logout', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin' }).catch(function() {}); // Tell sibling tabs to drop their UI too. try { if (window.__authBroadcast) window.__authBroadcast.postMessage({ type: 'logout' }); } catch(e) {} clearSession(); document.documentElement.classList.remove('has-session'); history.replaceState(null, '', window.location.pathname); if (authScreen) authScreen.style.display = 'flex'; if (mainApp) mainApp.style.display = 'none'; var adminTabBtn = document.getElementById('admin-tab-btn'); if (adminTabBtn) adminTabBtn.classList.add('hidden'); showLoginForm(); // Clear fields after browser autofill has had a chance to run setTimeout(function() { ['login-email', 'login-password', 'reg-name', 'reg-email', 'reg-password', 'login-totp'].forEach(function(id) { var el = document.getElementById(id); if (el) el.value = ''; }); }, 100); } function clearSession() { if (isNativeApp()) { window.SecureStorage.remove(TOKEN_KEY); window.SecureStorage.remove(USER_KEY); window.SecureStorage.remove(SESSION_KEY); } else { // Web: cookie cleared by /api/auth/logout. Also wipe any legacy // localStorage entries from the dual-mode era so they don't linger. try { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); localStorage.removeItem(SESSION_KEY); } catch(e) {} } window.AUTH_TOKEN = null; // Clear service worker caches so a logged-out user on a shared device // cannot read cached pages from offline mode. try { if (window.caches && caches.keys) { caches.keys().then(function(names) { names.forEach(function(n) { caches.delete(n); }); }).catch(function(){}); } } catch(e) {} } window.getAuthHeaders = function() { // Web: no Authorization header. The httpOnly cookie is sent automatically // by fetch (default credentials = same-origin). Server middleware falls // back to cookie when Bearer is absent. if (!isNativeApp()) { return { 'Content-Type': 'application/json' }; } // Native: Bearer from Keychain/Keystore via SecureStorage. var token = window.AUTH_TOKEN || window.SecureStorage.getSync(TOKEN_KEY) || ''; return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; }; // ---- FORM TOGGLE LINKS ---- document.addEventListener('click', function(e) { var target = e.target; // Handle link clicks by ID if (target.id === 'show-register' || target.closest('#show-register')) { e.preventDefault(); showRegisterForm(); } if (target.id === 'show-login' || target.closest('#show-login')) { e.preventDefault(); showLoginForm(); } if (target.id === 'show-login-2' || target.closest('#show-login-2')) { e.preventDefault(); showLoginForm(); } if (target.id === 'show-forgot' || target.closest('#show-forgot')) { e.preventDefault(); showForgotForm(); } // Logout button if (target.id === 'btn-logout' || target.closest('#btn-logout')) { e.preventDefault(); exitApp(); showToast('Logged out', 'info'); } // About modal if (target.id === 'btn-show-about' || target.closest('#btn-show-about')) { e.preventDefault(); var aboutModal = document.getElementById('about-modal'); if (aboutModal) aboutModal.classList.remove('hidden'); } if (target.id === 'btn-close-about' || target.closest('#btn-close-about')) { e.preventDefault(); var aboutModal = document.getElementById('about-modal'); if (aboutModal) aboutModal.classList.add('hidden'); } // Settings button — navigate to settings tab if (target.id === 'btn-settings' || target.closest('#btn-settings')) { e.preventDefault(); var settingsBtn = document.querySelector('.tab-btn[data-tab="settings"]'); if (settingsBtn) settingsBtn.click(); load2FAStatus(); if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus(); if (typeof loadMemories === 'function') loadMemories(); if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList(); } // Setup 2FA if (target.id === 'btn-setup-2fa' || target.closest('#btn-setup-2fa')) { e.preventDefault(); fetch('/api/auth/setup-2fa', { method: 'POST', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { var qr = document.getElementById('2fa-qr'); var secret = document.getElementById('2fa-secret'); var setup = document.getElementById('2fa-setup'); if (qr) qr.src = data.qrCode; if (secret) secret.textContent = data.secret; if (setup) { setup.style.display = 'block'; setup.className = setup.className.replace('hidden', '').trim(); } } }); } // Verify 2FA if (target.id === 'btn-verify-2fa' || target.closest('#btn-verify-2fa')) { e.preventDefault(); var code = document.getElementById('2fa-verify-code').value; fetch('/api/auth/verify-2fa', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ code: code }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('2FA enabled!', 'success'); var setup = document.getElementById('2fa-setup'); if (setup) setup.style.display = 'none'; if (data.backupCodes && data.backupCodes.length) { showBackupCodesModal(data.backupCodes); } load2FAStatus(); } else { showToast(data.error || 'Invalid code', 'error'); } }); } // Regenerate backup codes if (target.id === 'btn-regen-backup-codes' || target.closest('#btn-regen-backup-codes')) { e.preventDefault(); showConfirm('Regenerate backup codes? This invalidates your existing ones. Enter your current password to confirm.', function(pw) { if (!pw) return; fetch('/api/auth/2fa/backup-codes', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ password: pw }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.codes) showBackupCodesModal(data.codes); else showToast(data.error || 'Failed to regenerate codes', 'error'); }); }, { input: true, inputType: 'password', placeholder: 'Current password', confirmText: 'Regenerate', required: true, requiredMsg: 'Password is required' }); } // Disable 2FA if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) { e.preventDefault(); var confirmBox = document.getElementById('2fa-disable-confirm'); if (confirmBox) { confirmBox.classList.remove('hidden'); } return; } if (target.id === 'btn-disable-2fa-cancel' || target.closest('#btn-disable-2fa-cancel')) { e.preventDefault(); var confirmBox = document.getElementById('2fa-disable-confirm'); if (confirmBox) { confirmBox.classList.add('hidden'); } var pwField = document.getElementById('2fa-disable-password'); if (pwField) pwField.value = ''; return; } if (target.id === 'btn-disable-2fa-confirm' || target.closest('#btn-disable-2fa-confirm')) { e.preventDefault(); var pwField = document.getElementById('2fa-disable-password'); var pw = pwField ? pwField.value : ''; if (!pw) { showToast('Enter your password', 'error'); return; } fetch('/api/auth/disable-2fa', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ password: pw }) }) .then(function(r) { return r.json(); }) .then(function(data) { var confirmBox = document.getElementById('2fa-disable-confirm'); if (confirmBox) confirmBox.classList.add('hidden'); if (pwField) pwField.value = ''; if (data.success) { showToast('2FA disabled', 'info'); load2FAStatus(); } else showToast(data.error || 'Failed', 'error'); }); } }); // ---- LOGIN FORM SUBMIT ---- if (loginForm) { loginForm.addEventListener('submit', function(e) { e.preventDefault(); e.stopPropagation(); var email = document.getElementById('login-email').value.trim(); var password = document.getElementById('login-password').value; var totpEl = document.getElementById('login-totp'); var totpCode = totpEl ? totpEl.value.trim() : ''; if (!email || !password) { showToast('Enter email and password', 'error'); return false; } // Cloudflare Turnstile var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]'); var loginToken = loginTurnstile ? loginTurnstile.value : ''; if (!loginToken) { showToast('Please complete the verification', 'error'); return false; } showLoading('Signing in...'); var body = { email: email, password: password, turnstileToken: loginToken }; if (totpCode) body.totpCode = totpCode; fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) .then(function(data) { hideLoading(); if (data.requires2FA) { var grp = document.getElementById('totp-group'); if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); } showToast('Enter your 2FA code', 'info'); return; } if (data.needsVerification) { var resendBox = document.getElementById('resend-verify-box'); if (resendBox) resendBox.className = resendBox.className.replace('hidden', '').trim(); showToast('Verify your email first. Check inbox.', 'error'); return; } if (data.success && data.token && data.user) { if (data.sessionId && isNativeApp()) { window.SecureStorage.set(SESSION_KEY, data.sessionId); } enterApp(data.user, data.token); showToast('Welcome, ' + data.user.name + '!', 'success'); } else { showToast(data.error || 'Login failed', 'error'); if (window.turnstile) turnstile.reset('#turnstile-login'); } }) .catch(function(err) { hideLoading(); console.error('[Auth] Login error:', err); showToast('Connection error', 'error'); if (window.turnstile) turnstile.reset('#turnstile-login'); }); return false; }); } // ---- RESEND VERIFICATION LINK ---- var resendLink = document.getElementById('resend-verify-link'); if (resendLink) { resendLink.addEventListener('click', function(e) { e.preventDefault(); var email = document.getElementById('login-email').value.trim(); if (!email) { showToast('Enter your email first', 'error'); return; } resendLink.style.pointerEvents = 'none'; resendLink.textContent = 'Sending...'; fetch('/api/auth/resend-verification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email }) }) .then(function(r) { return r.json(); }) .then(function(data) { resendLink.style.pointerEvents = ''; resendLink.textContent = 'Resend verification link'; if (data.error) { showToast(data.error, 'error'); } else { showToast('Verification email sent! Check your inbox.', 'success'); } }) .catch(function() { resendLink.style.pointerEvents = ''; resendLink.textContent = 'Resend verification link'; showToast('Connection error', 'error'); }); }); } // ---- REGISTER FORM SUBMIT ---- if (registerForm) { registerForm.addEventListener('submit', function(e) { e.preventDefault(); e.stopPropagation(); var name = document.getElementById('reg-name').value.trim(); var email = document.getElementById('reg-email').value.trim(); var password = document.getElementById('reg-password').value; if (!name || !email || !password) { showToast('Fill all fields', 'error'); return false; } if (password.length < 8) { showToast('Password must be 8+ characters', 'error'); return false; } // Cloudflare Turnstile verification var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]'); var turnstileToken = turnstileResponse ? turnstileResponse.value : ''; if (!turnstileToken) { showToast('Please complete the verification challenge', 'error'); return false; } showLoading('Creating account...'); fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken }) }) .then(function(r) { return r.json(); }) .then(function(data) { hideLoading(); if (data.success && data.token && data.user) { if (data.sessionId && isNativeApp()) { window.SecureStorage.set(SESSION_KEY, data.sessionId); } enterApp(data.user, data.token); showToast(data.message || 'Account created!', 'success'); } else if (data.success && data.needsVerification) { showToast(data.message || 'Check email to verify', 'success'); showLoginForm(); } else { showToast(data.error || 'Registration failed', 'error'); if (window.turnstile) turnstile.reset(); } }) .catch(function(err) { hideLoading(); console.error('[Auth] Register error:', err); showToast('Connection error', 'error'); if (window.turnstile) turnstile.reset(); }); return false; }); } // ---- FORGOT PASSWORD SUBMIT ---- if (forgotForm) { forgotForm.addEventListener('submit', function(e) { e.preventDefault(); e.stopPropagation(); var email = document.getElementById('forgot-email').value.trim(); if (!email) { showToast('Enter email', 'error'); return false; } // Cloudflare Turnstile var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]'); var forgotToken = forgotTurnstile ? forgotTurnstile.value : ''; if (!forgotToken) { showToast('Please complete the verification', 'error'); return false; } showLoading('Sending...'); fetch('/api/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, turnstileToken: forgotToken }) }) .then(function(r) { return r.json(); }) .then(function(data) { hideLoading(); showToast(data.message || 'Check your email', 'success'); showLoginForm(); }) .catch(function(err) { hideLoading(); showToast('Error', 'error'); if (window.turnstile) turnstile.reset('#turnstile-forgot'); }); return false; }); } // ---- SESSION MANAGEMENT ---- function timeAgo(date) { var s = Math.floor((Date.now() - date.getTime()) / 1000); if (s < 60) return 'just now'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago'; return Math.floor(s / 86400) + 'd ago'; } function escH(str) { var d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; } window.loadSessions = function() { var list = document.getElementById('sessions-list'); if (!list) return; fetch('/api/sessions', { headers: getAuthHeaders(), credentials: 'same-origin' }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success || !data.sessions || data.sessions.length === 0) { list.innerHTML = '
No active sessions found.
'; return; } var currentSid = data.currentSessionId || (window.SecureStorage ? window.SecureStorage.getSync(SESSION_KEY) : localStorage.getItem(SESSION_KEY)); list.innerHTML = data.sessions.map(function(s) { var isCurrent = s.id === currentSid; var created = new Date(s.created_at).toLocaleDateString(); var lastAct = timeAgo(new Date(s.last_activity)); return 'Could not load sessions.
'; }); }; // Revoke all other sessions button document.addEventListener('click', function(e) { if (e.target.id === 'btn-revoke-all-sessions' || e.target.closest('#btn-revoke-all-sessions')) { e.preventDefault(); showConfirm('Revoke all other sessions? All other devices will be logged out.', function() { fetch('/api/sessions', { method: 'DELETE', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(d) { if (d.success) { showToast('All other sessions revoked (' + (d.revoked || 0) + ' removed)', 'info'); loadSessions(); } }); }, { danger: true, confirmText: 'Revoke All' }); } }); // ---- CHANGE PASSWORD ---- document.addEventListener('click', function(e) { if (e.target.id === 'btn-change-password' || e.target.closest('#btn-change-password')) { e.preventDefault(); var current = document.getElementById('pw-current'); var newPw = document.getElementById('pw-new'); var confirmPw = document.getElementById('pw-confirm'); var status = document.getElementById('pw-change-status'); if (!current || !newPw || !confirmPw) return; if (!current.value || !newPw.value) { showToast('Fill in all fields', 'error'); return; } if (newPw.value.length < 8) { showToast('New password must be 8+ characters', 'error'); return; } if (newPw.value !== confirmPw.value) { showToast('Passwords do not match', 'error'); return; } if (status) { status.textContent = 'Changing...'; status.style.color = 'var(--g500)'; } fetch('/api/auth/change-password', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ currentPassword: current.value, newPassword: newPw.value }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast(data.message || 'Password changed', 'success'); if (data.passwordWarning) setTimeout(function() { showToast(data.passwordWarning, 'warning'); }, 1500); current.value = ''; newPw.value = ''; confirmPw.value = ''; if (status) { status.textContent = ''; } if (typeof loadSessions === 'function') loadSessions(); } else { showToast(data.error || 'Failed', 'error'); if (status) { status.textContent = ''; } } }) .catch(function() { showToast('Connection error', 'error'); if (status) status.textContent = ''; }); } }); // ---- 2FA STATUS ---- function load2FAStatus() { fetch('/api/auth/me', { credentials: 'same-origin', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data || !data.user) { // /me failed — if we already have a cached user from login, fall back to that // so reloaded Settings pages still render for local-auth users. if (window.CURRENT_USER && window.CURRENT_USER.canLocalAuth === true) { data = { user: window.CURRENT_USER }; } else { return; } } // Keep cache in sync window.CURRENT_USER = data.user; // Sections are display:none by default. Show them unless the server // explicitly marks this user as SSO-only (canLocalAuth === false). // Missing/undefined flag defaults to showing — safer than hiding a // legit local user's sections due to a transient fetch hiccup. var pwSection = document.getElementById('change-password-section'); var twofaSection = document.getElementById('2fa-section'); var sessionsSection = document.getElementById('sessions-section'); if (data.user.canLocalAuth === false) { return; // SSO-only — keep sections hidden } if (pwSection) pwSection.style.display = ''; if (twofaSection) twofaSection.style.display = ''; if (sessionsSection) sessionsSection.style.display = ''; var status = document.getElementById('2fa-status'); var setupBtn = document.getElementById('btn-setup-2fa'); var disableBtn = document.getElementById('btn-disable-2fa'); if (data.user.totp_enabled) { if (status) { status.textContent = 'Status: ✅ Enabled'; status.style.color = '#10b981'; } if (setupBtn) setupBtn.style.display = 'none'; if (disableBtn) disableBtn.style.display = 'inline-flex'; // Show backup-code row and remaining count fetch('/api/auth/2fa/backup-codes/count', { credentials: 'same-origin', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(bc) { var el = document.getElementById('2fa-backup-info'); if (el && typeof bc.remaining === 'number') { var color = bc.remaining <= 2 ? '#f97316' : 'var(--g500)'; el.innerHTML = '