The Turnstile challenge failed reliably inside the Capacitor WebView, which blocked login and registration from the Android app. Three separate causes: 1. Android WebView blocks third-party cookies by default. Turnstile runs in a cross-origin iframe from challenges.cloudflare.com and needs its own storage, so the widget never emitted a token. MainActivity now calls setAcceptThirdPartyCookies on the app's own WebView. 2. The register handler read the Turnstile response with an unscoped document.querySelector, which matched the *login* widget's input (it comes first in the DOM). Registration therefore submitted the login widget's token — single-use with a 5 minute expiry, so any prior login attempt or slow signup made it fail server-side. 3. The register and forgot-password widgets auto-rendered inside forms that start at display:none, where Turnstile does not reliably complete a challenge, and nothing re-rendered them when the form was shown. Widgets are now rendered explicitly when their form first becomes visible, and tokens are captured from the render callback instead of being read back out of the injected input — which makes the unscoped lookup in (2) structurally impossible. Added error/expired/timeout callbacks so a widget failure surfaces the Cloudflare error code instead of failing silently behind a generic toast. Login is no longer gated at all. It is the path mobile users hit constantly, and it is already covered by a 10-per-15-min per-IP rate limit, a constant-time credential check, and TOTP 2FA. Registration and password reset — the endpoints that actually attract bots — stay gated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1085 lines
46 KiB
JavaScript
1085 lines
46 KiB
JavaScript
// ============================================================
|
|
// 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';
|
|
});
|
|
}
|
|
|
|
// ── Biometric login (Capacitor only) ────────────────────────────────
|
|
// Uses capacitor-native-biometric to gate stored credentials behind
|
|
// Face ID / Touch ID / fingerprint. Server is identity (email);
|
|
// server-side credential is the user's password (not the JWT — JWTs
|
|
// expire and would force a fallback password login to refresh).
|
|
// 2FA still applies on top: biometric replaces the password step but
|
|
// a 2FA-enabled account still gets the TOTP prompt afterwards. That
|
|
// is the intended defense-in-depth.
|
|
var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
|
|
var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe
|
|
function bioPlugin() {
|
|
try {
|
|
if (!isNativeApp()) return null;
|
|
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric;
|
|
return p || null;
|
|
} catch (e) { return null; }
|
|
}
|
|
function bioAvailable() {
|
|
var p = bioPlugin();
|
|
if (!p) return Promise.resolve({ ok: false });
|
|
return p.isAvailable()
|
|
.then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
|
|
.catch(function () { return { ok: false }; });
|
|
}
|
|
function bioStored() {
|
|
// Cheap check first — was biometric ever enrolled? If not, skip the
|
|
// verifyIdentity prompt path entirely so we don't rattle the user.
|
|
try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; }
|
|
}
|
|
function bioEnroll(email, password) {
|
|
var p = bioPlugin();
|
|
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
|
|
return p.setCredentials({ username: email, password: password, server: BIO_SERVER })
|
|
.then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
|
|
}
|
|
function bioRetrieve() {
|
|
var p = bioPlugin();
|
|
if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
|
|
return p.verifyIdentity({
|
|
reason: 'Sign in to PedScribe',
|
|
title: 'PedScribe',
|
|
subtitle: 'Use biometric to sign in',
|
|
description: 'Confirm your identity to continue.'
|
|
}).then(function () {
|
|
return p.getCredentials({ server: BIO_SERVER });
|
|
});
|
|
}
|
|
function bioForget() {
|
|
var p = bioPlugin();
|
|
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
|
|
if (!p) return Promise.resolve();
|
|
return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ });
|
|
}
|
|
// Expose a small surface so settings/logout/etc can call into it.
|
|
window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget };
|
|
|
|
// Auth module initialized
|
|
|
|
// Make sure forms are visible/hidden correctly on load
|
|
showLoginForm();
|
|
|
|
// Biometric login button: reveal on the login form when the device
|
|
// supports it AND the user has previously enrolled. Fire-and-forget;
|
|
// any failure (no plugin, locked out, hardware missing) just leaves
|
|
// the button hidden.
|
|
function maybeRevealBioButton() {
|
|
var btn = document.getElementById('btn-bio-login');
|
|
var div = document.getElementById('bio-divider');
|
|
if (!btn || !isNativeApp()) return;
|
|
if (!bioStored()) return;
|
|
bioAvailable().then(function (s) {
|
|
if (!s.ok) return;
|
|
// Tweak the label to the actual biometry type when known.
|
|
var label = document.getElementById('bio-login-label');
|
|
if (label && s.type) {
|
|
var typeMap = { 'FACE_ID': 'Sign in with Face ID', 'TOUCH_ID': 'Sign in with Touch ID', 'FACE_AUTHENTICATION': 'Sign in with face recognition', 'FINGERPRINT': 'Sign in with fingerprint' };
|
|
label.textContent = typeMap[s.type] || 'Sign in with biometric';
|
|
}
|
|
btn.classList.remove('hidden'); btn.style.display = '';
|
|
if (div) { div.classList.remove('hidden'); div.style.display = ''; }
|
|
});
|
|
}
|
|
maybeRevealBioButton();
|
|
|
|
// 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(); });
|
|
}
|
|
}
|
|
|
|
// ---- CLOUDFLARE TURNSTILE ----
|
|
// Gates registration and password reset. Login is deliberately NOT gated:
|
|
// it is already covered by a 10-per-15-min rate limit and a constant-time
|
|
// credential check, and the widget is unreliable inside the Capacitor
|
|
// WebView — which locked mobile users out of the app entirely.
|
|
//
|
|
// Tokens are captured from the render callback rather than read back out of
|
|
// the injected [name="cf-turnstile-response"] input. That lookup is easy to
|
|
// leave unscoped, which is exactly how the register form ended up
|
|
// submitting the login widget's token (single-use, 5-minute expiry).
|
|
//
|
|
// Rendering is explicit and deferred until the owning form is visible:
|
|
// both widgets live in forms that start at display:none, and Turnstile does
|
|
// not reliably complete a challenge inside a hidden container.
|
|
var turnstileWidgets = {
|
|
register: { el: 'turnstile-register', id: null, token: '', pending: false },
|
|
forgot: { el: 'turnstile-forgot', id: null, token: '', pending: false }
|
|
};
|
|
var turnstileReady = false;
|
|
|
|
// api.js?render=explicit invokes this once the Turnstile API is available.
|
|
window.onloadTurnstileCallback = function() {
|
|
turnstileReady = true;
|
|
Object.keys(turnstileWidgets).forEach(function(name) {
|
|
// Catch up on any form shown before the script finished loading.
|
|
if (turnstileWidgets[name].pending) renderTurnstile(name);
|
|
});
|
|
};
|
|
|
|
function renderTurnstile(name) {
|
|
var w = turnstileWidgets[name];
|
|
if (!w || w.id !== null) return; // already rendered
|
|
var el = document.getElementById(w.el);
|
|
if (!el) return;
|
|
if (!turnstileReady || !window.turnstile) { w.pending = true; return; }
|
|
w.pending = false;
|
|
w.id = window.turnstile.render(el, {
|
|
sitekey: el.getAttribute('data-sitekey'),
|
|
theme: 'light',
|
|
callback: function(token) { w.token = token; },
|
|
'expired-callback': function() { w.token = ''; },
|
|
'timeout-callback': function() { w.token = ''; },
|
|
// Without this a widget failure is silent and the user only ever sees
|
|
// the generic "complete the verification" toast with no way to tell
|
|
// whether the challenge failed, expired, or never loaded at all.
|
|
'error-callback': function(code) {
|
|
w.token = '';
|
|
console.error('[Auth] Turnstile error on ' + name + ' widget:', code);
|
|
showToast('Verification unavailable (' + (code || 'error') + '). Check your connection and try again.', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function turnstileToken(name) {
|
|
var w = turnstileWidgets[name];
|
|
return w ? w.token : '';
|
|
}
|
|
|
|
function resetTurnstile(name) {
|
|
var w = turnstileWidgets[name];
|
|
if (!w) return;
|
|
w.token = '';
|
|
if (w.id !== null && window.turnstile) window.turnstile.reset(w.id);
|
|
}
|
|
|
|
// ---- 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';
|
|
renderTurnstile('register');
|
|
}
|
|
|
|
function showForgotForm() {
|
|
if (loginForm) loginForm.style.display = 'none';
|
|
if (registerForm) registerForm.style.display = 'none';
|
|
if (forgotForm) forgotForm.style.display = 'block';
|
|
renderTurnstile('forgot');
|
|
}
|
|
|
|
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 Docs tab for admins only (same gate as Admin)
|
|
var docsTabBtn = document.getElementById('docs-tab-btn');
|
|
if (docsTabBtn) {
|
|
if (user.role === 'admin') docsTabBtn.classList.remove('hidden');
|
|
else docsTabBtn.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');
|
|
var docsTabBtn = document.getElementById('docs-tab-btn');
|
|
if (docsTabBtn) docsTabBtn.classList.add('hidden');
|
|
// Explicit logout clears biometric — assume the user is leaving the
|
|
// device for someone else. Auto-logout (token expiry, network) does
|
|
// NOT come through this path, so biometric persists across silent
|
|
// session resets.
|
|
if (window.PedBio && typeof window.PedBio.forget === 'function') {
|
|
window.PedBio.forget();
|
|
}
|
|
var bioBtn = document.getElementById('btn-bio-login');
|
|
if (bioBtn) { bioBtn.classList.add('hidden'); bioBtn.style.display = 'none'; }
|
|
var bioDiv = document.getElementById('bio-divider');
|
|
if (bioDiv) { bioDiv.classList.add('hidden'); bioDiv.style.display = 'none'; }
|
|
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');
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---- BIOMETRIC LOGIN BUTTON ----
|
|
// Reads the email + password from the OS-secured keychain (gated behind
|
|
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the
|
|
// form so all the existing flow (2FA prompt, error handling,
|
|
// session storage) runs unchanged. If biometric verification fails, the
|
|
// user just gets a toast and falls through to typing the password.
|
|
var bioBtn = document.getElementById('btn-bio-login');
|
|
if (bioBtn) {
|
|
bioBtn.addEventListener('click', function () {
|
|
bioRetrieve()
|
|
.then(function (creds) {
|
|
if (!creds || !creds.username || !creds.password) {
|
|
showToast('No stored credentials', 'error');
|
|
return;
|
|
}
|
|
var emailEl = document.getElementById('login-email');
|
|
var pwEl = document.getElementById('login-password');
|
|
if (emailEl) emailEl.value = creds.username;
|
|
if (pwEl) pwEl.value = creds.password;
|
|
// Trigger the same submit path as the password form so all the
|
|
// existing handling (2FA, session storage, etc.) runs unchanged.
|
|
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
|
|
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
|
})
|
|
.catch(function (err) {
|
|
// User cancelled or biometric failed (locked out, no enrolled
|
|
// biometric, etc). Stay quiet for cancel; toast for hard errors.
|
|
var msg = (err && (err.message || err.code)) || '';
|
|
if (/cancel/i.test(msg)) return;
|
|
showToast('Biometric sign-in 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;
|
|
}
|
|
|
|
showLoading('Signing in...');
|
|
|
|
var body = { email: email, password: password };
|
|
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');
|
|
// Offer biometric enrollment after the very first successful
|
|
// password login on a Capacitor device. Only ask once per
|
|
// (device, account) — enrollment flips the BIO_ENABLED_KEY flag.
|
|
if (isNativeApp() && !bioStored()) {
|
|
bioAvailable().then(function (s) {
|
|
if (!s.ok) return;
|
|
var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric';
|
|
if (typeof showConfirm === 'function') {
|
|
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
|
|
bioEnroll(email, password)
|
|
.then(function () { showToast(typeName + ' enabled. Use it next time you sign in.', 'success'); })
|
|
.catch(function () { showToast('Could not enable ' + typeName, 'error'); });
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
showToast(data.error || 'Login failed', 'error');
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
hideLoading();
|
|
console.error('[Auth] Login error:', err);
|
|
showToast('Connection error', 'error');
|
|
});
|
|
|
|
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 regToken = turnstileToken('register');
|
|
if (!regToken) {
|
|
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: regToken })
|
|
})
|
|
.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');
|
|
// The token was just consumed server-side — clear it so coming back
|
|
// to this form doesn't resubmit a spent one.
|
|
resetTurnstile('register');
|
|
showLoginForm();
|
|
} else {
|
|
showToast(data.error || 'Registration failed', 'error');
|
|
resetTurnstile('register');
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
hideLoading();
|
|
console.error('[Auth] Register error:', err);
|
|
showToast('Connection error', 'error');
|
|
resetTurnstile('register');
|
|
});
|
|
|
|
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 forgotToken = turnstileToken('forgot');
|
|
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');
|
|
resetTurnstile('forgot');
|
|
showLoginForm();
|
|
})
|
|
.catch(function(err) {
|
|
hideLoading();
|
|
showToast('Error', 'error');
|
|
resetTurnstile('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 = '<p style="color:var(--g400);font-size:13px;">No active sessions found.</p>';
|
|
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 '<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--g50);border-radius:8px;border:1.5px solid ' + (isCurrent ? 'var(--blue)' : 'var(--g200)') + ';">'
|
|
+ '<div>'
|
|
+ '<div style="font-size:13px;font-weight:600;color:var(--g800);">' + escH(s.device_label || 'Unknown device') + (isCurrent ? ' <span style="font-size:11px;color:var(--blue);font-weight:500;">(this device)</span>' : '') + '</div>'
|
|
+ '<div style="font-size:12px;color:var(--g500);margin-top:2px;">' + escH(s.ip_address || '') + ' · Created ' + created + ' · Active ' + lastAct + '</div>'
|
|
+ '</div>'
|
|
+ (isCurrent ? '' : '<button class="btn-sm btn-ghost session-revoke-btn" data-sid="' + s.id + '" style="color:var(--red);font-size:12px;"><i class="fas fa-xmark"></i> Revoke</button>')
|
|
+ '</div>';
|
|
}).join('');
|
|
|
|
// Wire revoke buttons
|
|
list.querySelectorAll('.session-revoke-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
showConfirm('Revoke this session? That device will be logged out.', function() {
|
|
fetch('/api/sessions/' + btn.dataset.sid, { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.success) { showToast('Session revoked', 'info'); loadSessions(); }
|
|
else showToast(d.error || 'Failed', 'error');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
})
|
|
.catch(function() {
|
|
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">Could not load sessions.</p>';
|
|
});
|
|
};
|
|
|
|
// 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 = '<div style="margin-top:12px;font-size:13px;color:' + color + ';">'
|
|
+ '<span>' + bc.remaining + ' backup codes remaining.</span> '
|
|
+ '<button id="btn-regen-backup-codes" class="btn-sm btn-ghost" style="margin-left:6px;">Regenerate</button>'
|
|
+ '</div>';
|
|
}
|
|
}).catch(function(){});
|
|
} else {
|
|
if (status) { status.textContent = 'Status: ❌ Not enabled'; status.style.color = '#ef4444'; }
|
|
if (setupBtn) setupBtn.style.display = 'inline-flex';
|
|
if (disableBtn) disableBtn.style.display = 'none';
|
|
var bi = document.getElementById('2fa-backup-info');
|
|
if (bi) bi.innerHTML = '';
|
|
}
|
|
})
|
|
.catch(function() {
|
|
var status = document.getElementById('2fa-status');
|
|
if (status) status.textContent = 'Status: Unable to load';
|
|
});
|
|
}
|
|
// Expose globally so app.js tabChanged handler can call it
|
|
window.load2FAStatus = load2FAStatus;
|
|
|
|
// Modal that shows backup codes exactly once. Uses textContent and a copy
|
|
// button — codes never land in innerHTML so a stray value can't produce XSS.
|
|
function showBackupCodesModal(codes) {
|
|
var existing = document.getElementById('backup-codes-modal');
|
|
if (existing) existing.remove();
|
|
var modal = document.createElement('div');
|
|
modal.id = 'backup-codes-modal';
|
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px;';
|
|
|
|
var box = document.createElement('div');
|
|
box.style.cssText = 'background:#fff;border-radius:12px;max-width:460px;width:100%;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);';
|
|
|
|
var title = document.createElement('div');
|
|
title.style.cssText = 'font-size:18px;font-weight:700;margin-bottom:4px;';
|
|
title.textContent = 'Save your backup codes';
|
|
var hint = document.createElement('div');
|
|
hint.style.cssText = 'font-size:13px;color:#6b7280;margin-bottom:16px;line-height:1.5;';
|
|
hint.textContent = 'Each code works once. Store them somewhere safe — you will NOT see them again. Use one in place of your 2FA code if you lose your authenticator.';
|
|
|
|
var list = document.createElement('pre');
|
|
list.style.cssText = 'background:#f3f4f6;padding:12px 14px;border-radius:8px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px;line-height:1.8;margin:0;white-space:pre-wrap;user-select:all;';
|
|
list.textContent = codes.join('\n');
|
|
|
|
var btnRow = document.createElement('div');
|
|
btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px;justify-content:flex-end;';
|
|
|
|
var copyBtn = document.createElement('button');
|
|
copyBtn.className = 'btn-sm btn-ghost';
|
|
copyBtn.textContent = 'Copy';
|
|
copyBtn.addEventListener('click', function() {
|
|
navigator.clipboard.writeText(codes.join('\n')).then(function() {
|
|
copyBtn.textContent = 'Copied ✓';
|
|
setTimeout(function() { copyBtn.textContent = 'Copy'; }, 1500);
|
|
}).catch(function() { showToast('Copy failed', 'error'); });
|
|
});
|
|
|
|
var downloadBtn = document.createElement('button');
|
|
downloadBtn.className = 'btn-sm btn-ghost';
|
|
downloadBtn.textContent = 'Download .txt';
|
|
downloadBtn.addEventListener('click', function() {
|
|
var blob = new Blob(['PedScribe 2FA backup codes\nGenerated: ' + new Date().toISOString() + '\n\n' + codes.join('\n') + '\n'], { type: 'text/plain' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a'); a.href = url; a.download = 'pedscribe-backup-codes.txt'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
var doneBtn = document.createElement('button');
|
|
doneBtn.className = 'btn-sm btn-primary';
|
|
doneBtn.textContent = "I've saved them";
|
|
doneBtn.addEventListener('click', function() { modal.remove(); });
|
|
|
|
btnRow.appendChild(copyBtn); btnRow.appendChild(downloadBtn); btnRow.appendChild(doneBtn);
|
|
box.appendChild(title); box.appendChild(hint); box.appendChild(list); box.appendChild(btnRow);
|
|
modal.appendChild(box);
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
// Check registration status — link is hidden by default, shown only when enabled
|
|
fetch('/api/auth/registration-status')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.registrationEnabled) {
|
|
var link = document.getElementById('show-register');
|
|
if (link) link.style.display = '';
|
|
}
|
|
})
|
|
.catch(function() {});
|
|
|
|
console.log('[Auth] ✅ Module ready');
|
|
});
|