pediatric-ai-scribe-v3/public/js/auth.js
Daniel 4d92488f0c fix: translated answers keep their source chips; the patient take home can be translated
Translation
- Stop scrubbing markdown before sending it to LibreTranslate. The scrub
  deleted ordered-list numbering ("1. Give amoxicillin" -> "Give amoxicillin"),
  flattened tables into ambiguous whitespace and ate underscores inside
  identifiers. Raw markdown now goes to the translator unchanged.
- Render the translation through the same markdown pipeline as the original
  bubble, with the message's own sources, so [n] markers come back as the usual
  clickable .assistant-cite chips instead of escaped literal text. Headings,
  lists and tables survive with them.
- When the translator drops citation markers, surface the affected sources in a
  recovery block rather than letting the evidence disappear.
- Image cards are live nodes: they are now re-attached on every path out of a
  translation (success, failure and Show original), so a failed translation no
  longer silently removes a generating image from the message.

Patient take home
- Add a language selector to the take-home modal, reusing the existing
  /translate endpoint and offering only what the local LibreTranslate reports.
- Copy, Export and Email carry what the caregiver is actually reading; the
  original stays canonical behind "Original".

Conversation budget
- The admin field no longer prefills with the environment value, which turned
  the next Save into an accidental override and made the documented "leave
  empty to use the environment" path unreachable. The effective limit is shown
  as a placeholder instead.
- Report source 'default' honestly instead of naming an unset env var.
- The load-failure notice now lands on the <p> instead of an <input>'s
  textContent, where it rendered nothing.
- One validator for the budget everywhere: conversationLimit() replaces a
  parseInt that accepted "120000abc".

Other
- /assistant is addressed by its URL, not by ped_last_tab, so "/" no longer
  reopens the assistant; the URL follows tab changes and Back leaves it.
- Remove the dead DeepL path (it referenced an undefined DEEPL_BASES) and stop
  offering admins a provider the server silently ignores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 18:40:23 +02:00

1162 lines
50 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';
var boundary = window.AccountBoundary;
var authenticationPending = false;
function authState() {
var state = boundary.read();
return { revision: boundary.revision(), generation: state && state.generation };
}
function authCurrent(state) {
var now = authState();
return state.revision === now.revision && state.generation === now.generation;
}
async function authenticate(work, message) {
// Admission covers biometric retrieval, response parsing AND native persistence.
// Disabled buttons alone cannot fence programmatic submits or sibling forms.
if (authenticationPending || boundary.blocked()) return Promise.resolve(false);
authenticationPending = true;
if (authScreen) authScreen.setAttribute('aria-busy', 'true');
showLoading(message);
try { return await work(); }
finally {
authenticationPending = false;
if (authScreen) authScreen.removeAttribute('aria-busy');
hideLoading();
}
}
var bootstrapState = authState();
// 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();
// ── 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.
// Two plugins cooperate here, because the one that does the biometric
// prompt does not store anything:
// - BiometricAuthNative (@aparajita/capacitor-biometric-auth) — presents
// the Face ID / fingerprint prompt. checkBiometry() + authenticate().
// - SecureStoragePlugin (capacitor-secure-storage-plugin), via the
// window.SecureStorage wrapper — holds the credentials in the iOS
// Keychain / Android EncryptedSharedPreferences.
//
// This previously called window.Capacitor.Plugins.NativeBiometric, the API
// of capacitor-native-biometric — a package that is not a dependency of this
// project. The plugin object was always undefined, so bioAvailable() always
// resolved {ok:false} and the biometric button was never revealed. The
// feature has been dead since it was written.
var BIO_CREDS_KEY = 'ped_bio_creds'; // SecureStorage key holding {username,password}
var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe
// BiometryType enum from the plugin (numeric) → human label.
var BIO_TYPE_NAMES = {
1: 'Touch ID',
2: 'Face ID',
3: 'fingerprint',
4: 'face recognition',
5: 'iris recognition'
};
function bioPlugin() {
try {
if (!isNativeApp()) return null;
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.BiometricAuthNative;
return p || null;
} catch (e) { return null; }
}
function bioAvailable() {
var p = bioPlugin();
if (!p) return Promise.resolve({ ok: false });
return p.checkBiometry()
.then(function (r) {
return {
ok: !!(r && r.isAvailable),
type: r && r.biometryType,
typeName: (r && BIO_TYPE_NAMES[r.biometryType]) || 'biometric'
};
})
.catch(function () { return { ok: false }; });
}
function bioStored() {
// Cheap check first — was biometric ever enrolled? If not, skip the
// 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) {
if (!bioPlugin()) return Promise.reject(new Error('Biometric plugin unavailable'));
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable'));
return Promise.resolve(
window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password }))
).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'));
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable'));
// authenticate() resolves on success and rejects on cancel/failure, so the
// credentials are only read after the OS has verified the user.
return p.authenticate({
reason: 'Sign in to PedScribe',
androidTitle: 'PedScribe',
androidSubtitle: 'Use biometric to sign in',
cancelTitle: 'Use password',
allowDeviceCredential: false
}).then(function () {
return window.SecureStorage.get(BIO_CREDS_KEY);
}).then(function (raw) {
if (!raw) throw new Error('No stored credentials');
return JSON.parse(raw);
});
}
function bioForget() {
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
if (!window.SecureStorage) return Promise.resolve();
return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).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.typeName) {
label.textContent = 'Sign in with ' + s.typeName;
}
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 (!boundary.blocked() && !window.CURRENT_USER && 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');
var ssoIntent = false;
try {
var intentTime = Number(sessionStorage.getItem('ped_sso_intent'));
ssoIntent = intentTime > 0 && Date.now() - intentTime >= 0 && Date.now() - intentTime < 5 * 60 * 1000;
sessionStorage.removeItem('ped_sso_intent');
} catch (e) {}
var ssoButton = document.getElementById('btn-sso');
if (ssoButton) ssoButton.addEventListener('click', async function(e) {
if (!e.isTrusted || authenticationPending || boundary.blocked()) { e.preventDefault(); return; }
boundary.capture(); // Detect a replaced sibling session before capturing credentials.
if (boundary.blocked()) { e.preventDefault(); return; }
// No owner marker can also mean bootstrap failed with an old cookie still
// valid. Every SSO start must prove cookie absence before issuing intent.
e.preventDefault();
var headers = getAuthHeaders();
var href = ssoButton.href;
try {
boundary.startLogin(); // Publish before logout can change the shared cookie.
boundary.freeze();
var generation = boundary.read().generation;
var logout = await boundary.logoutRequest(headers);
if (!logout.ok || boundary.read().generation !== generation) throw boundary.error();
// Logout swallows DB deletion errors: 2xx is not proof the cookie is gone.
var probe = await boundary.cookieSessionRequest();
if (probe.status !== 401 || boundary.read().generation !== generation) throw boundary.error();
boundary.completeSignIn();
sessionStorage.setItem('ped_sso_intent', String(Date.now()));
window.location.href = href;
} catch (err) {
boundary.freeze(); // Failure/canceled navigation cannot unlock retained clinical state.
try { sessionStorage.removeItem('ped_sso_intent'); } catch (e) {}
}
});
if (ssoOk === 'ok' && !boundary.needsSignIn() && (!boundary.signedOut() || ssoIntent)) {
var ssoSid = urlParams.get('sid');
history.replaceState(null, '', window.location.pathname);
// 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) {
return enterApp(data.user, '', ssoIntent, ssoSid, bootstrapState);
} else { showAuthScreen(); }
})
.catch(function() { showAuthScreen(); });
} 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',
sso_disabled: 'SSO is no longer enabled. Use local sign-in.',
account_link_required: 'This local account is unverified. Account recovery and administrator-assisted linking are required before SSO sign-in.',
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 (boundary.needsSignIn() || (boundary.signedOut() && !(ssoOk === 'ok' && ssoIntent))) {
showAuthScreen();
} else if (isNativeApp()) {
// Native app path: token lives in Keychain/Keystore via SecureStorage
window.SecureStorage.hydrate([TOKEN_KEY, USER_KEY, SESSION_KEY]).then(function() {
if (!authCurrent(bootstrapState)) return;
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) return enterApp(data.user, savedToken, false, null, bootstrapState);
else { showAuthScreen(); }
})
.catch(function() { showAuthScreen(); });
});
} 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) return enterApp(data.user, '', false, null, bootstrapState);
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');
}
async function enterApp(user, token, explicit, sessionId, attempt) {
if (!user || user.id == null) return false;
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
if (!explicit && (boundary.blocked() || boundary.signedOut() || boundary.needsSignIn())) return false;
var shared = boundary.read();
// Verification of A is not authorization to replace a published B. Passive
// bootstrap never writes credentials (native bridge writes cannot be canceled).
if (!explicit && shared && shared.owner !== String(user.id)) {
boundary.recoverSignIn(); return false;
}
var oldOwner = boundary.capture();
if (oldOwner && oldOwner !== String(user.id)) boundary.freeze();
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
if (isNativeApp() && explicit) {
var writes = await Promise.allSettled([
window.SecureStorage.set(TOKEN_KEY, token),
window.SecureStorage.set(USER_KEY, JSON.stringify(user)),
window.SecureStorage.set(SESSION_KEY, sessionId || '')
]);
// Wait for ALL bridge calls, including after one fails. Late successful
// writes must neither trigger an automatic reload nor clean up newer B.
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
if (writes.some(function(result) { return result.status === 'rejected'; })) {
boundary.end();
return false;
}
}
if (!authCurrent(attempt)) { boundary.recoverSignIn(); return false; }
if (explicit) boundary.completeSignIn();
if (boundary.blocked()) {
if (explicit) boundary.publishLogin(user);
boundary.reload();
return false;
}
if (!boundary.enter(user, explicit)) return false;
window.AUTH_TOKEN = token;
window.CURRENT_USER = 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) {}
// The URL is authoritative for the assistant. A stored 'assistant' is a
// legacy value from before the split and must not hijack "/".
if (lastTab === 'assistant') lastTab = null;
if (typeof window !== 'undefined' && window.location && window.location.pathname === '/assistant') lastTab = 'assistant';
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();
return true;
}
function exitApp() {
if (boundary.blocked()) return;
var headers = getAuthHeaders();
boundary.end(); // Synchronously hide and stop activity before changing credentials/cookies.
var logout = boundary.logoutRequest(headers).catch(function() {});
var clearing = clearSession(true);
var forgetting = window.PedBio ? window.PedBio.forget() : Promise.resolve();
// The signed-out latch survives both failed logout and a canceled reload.
Promise.all([logout, clearing, forgetting]).finally(function() { boundary.reload(); });
// A hung network/native bridge must not leave the old UI usable either.
setTimeout(function() { boundary.reload(); }, 3000);
}
function clearSession(explicit) {
if (!explicit && (boundary.blocked() || window.CURRENT_USER)) return Promise.resolve();
if (boundary.active()) { boundary.end(); boundary.reload(); }
window.AUTH_TOKEN = null;
window.CURRENT_USER = null;
return Promise.all([TOKEN_KEY, USER_KEY, SESSION_KEY].map(function(key) {
try { localStorage.removeItem(key); } catch (e) {}
return isNativeApp() ? window.SecureStorage.remove(key) : Promise.resolve();
}));
}
window.getAuthHeaders = function() {
var headers = { 'Content-Type': 'application/json' };
// Only the verified runtime token may authenticate native requests. Late
// storage hydration can still contain A while the verified cookie owns B.
// Explicit native /me bootstrap verifies its stored token separately.
if (isNativeApp() && window.AUTH_TOKEN) headers.Authorization = 'Bearer ' + window.AUTH_TOKEN;
return headers;
};
// ---- 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. Reuses its
// promise-returning handler 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 () {
authenticate(function() {
var retrievalState = authState();
return bioRetrieve()
.then(function (creds) {
if (!authCurrent(retrievalState)) { boundary.recoverSignIn(); return; }
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) return submitLogin();
})
.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');
}); }, 'Signing in...');
});
}
// ---- LOGIN FORM SUBMIT ----
function submitLogin() {
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;
}
var body = { email: email, password: password };
if (totpCode) body.totpCode = totpCode;
var request = fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
var attempt = authState();
return request
.then(function(r) { return r.json(); })
.then(function(data) {
if (boundary.blocked() && !(data.success && data.token && data.user)) return;
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) {
return enterApp(data.user, data.token, true, data.sessionId, attempt).then(function(entered) {
if (!entered) return;
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 || !boundary.active()) return;
var typeName = s.typeName || 'biometric';
if (typeof showConfirm === 'function') {
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
if (!boundary.active()) return;
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) {
if (boundary.blocked() || err.name === 'AbortError') return;
console.error('[Auth] Login error:', err);
showToast('Connection error', 'error');
});
}
if (loginForm) loginForm.addEventListener('submit', function(e) {
e.preventDefault();
e.stopPropagation();
authenticate(submitLogin, 'Signing in...');
});
// ---- 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;
}
authenticate(function() {
var request = fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
});
var attempt = authState();
return request
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.token && data.user) {
return enterApp(data.user, data.token, true, data.sessionId, attempt);
} else if (boundary.blocked()) {
return;
} 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) {
if (boundary.blocked() || err.name === 'AbortError') return;
console.error('[Auth] Register error:', err);
showToast('Connection error', 'error');
resetTurnstile('register');
});
}, 'Creating account...');
return false;
});
}
// ---- FORGOT PASSWORD SUBMIT ----
if (forgotForm) {
forgotForm.addEventListener('submit', function(e) {
e.preventDefault();
e.stopPropagation();
if (authenticationPending || boundary.blocked()) return false;
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 || '') + ' &middot; Created ' + created + ' &middot; 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;
}
}
// /me may reveal a shared cookie replaced before the tab event arrived.
if (boundary.capture() !== String(data.user.id)) { boundary.freeze(); boundary.reload(); return; }
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');
});