pediatric-ai-scribe-v3/public/js/auth.js
Daniel 8f49c4dcb9 feat(mobile): biometric sign-in (Face ID / Touch ID / fingerprint)
Adds opt-in biometric login to the Capacitor app. Replaces the password
step on subsequent sign-ins; the 2FA step (if any) still applies — by
design, defense in depth.

How it works:
- After a successful password sign-in on a Capacitor build, prompt the
  user to enroll. If they accept, capacitor-native-biometric.setCredentials
  stores the (email, password) pair in the iOS Keychain / Android Keystore
  with biometric-protected access. The local flag ped_bio_enabled=1 is
  set so the next launch knows to probe.
- On the login form, if isNativeApp() + bioStored() + bioAvailable.ok,
  reveal the "Sign in with Face ID / Touch ID / fingerprint" button at
  the top. Label is set from the actual biometryType returned by the
  plugin so users see what their device supports.
- Tap → verifyIdentity (OS prompt) → getCredentials → fill the email +
  password fields → fire the existing form submit so all the regular
  flow runs (turnstile, 2FA prompt, error handling, session storage).
- Explicit logout deletes credentials AND clears the local flag,
  hiding the button on the next visit. Auto-logout (token expiry,
  network) does NOT come through that path, so biometric persists
  across silent session resets.

Storage choice — password not JWT:
- JWTs expire and the storage would constantly need refresh.
- Storing the password lets the standard /api/auth/login flow run,
  which already handles password-rotation (a stale stored password
  just fails 401 → user falls back to typing the new one → re-enrolls).
- The password sits in OS-level secure storage, accessible only after
  successful biometric verification — same security posture as a
  password manager autofill.

Files:
- mobile/package.json: add capacitor-native-biometric@^5.0.0 (Capacitor 6
  compat)
- mobile/android/app/src/main/AndroidManifest.xml: add USE_BIOMETRIC
  uses-permission
- mobile/ios/App/App/Info.plist: add NSFaceIDUsageDescription string
- public/js/auth.js: bioPlugin/bioAvailable/bioStored/bioEnroll/
  bioRetrieve/bioForget helpers; window.PedBio surface; reveal-on-load;
  click handler; post-login enrollment prompt; logout cleanup
- public/index.html: hidden #btn-bio-login + #bio-divider above the
  email field on the login form
- public/css/styles.css: themed gradient button + hover lift
- mobile/README.md: feature list updated

Build steps for Daniel:
  cd mobile && npm install        # picks up capacitor-native-biometric
  npx cap sync                    # ports the plugin into android/ + ios/
  # then build APK / IPA as usual
2026-04-28 03:09:38 +02:00

1029 lines
44 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(); });
}
}
// ---- HELPER FUNCTIONS ----
function showLoginForm() {
if (loginForm) loginForm.style.display = 'block';
if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'none';
}
function showRegisterForm() {
if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'block';
if (forgotForm) forgotForm.style.display = 'none';
}
function showForgotForm() {
if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'block';
}
function enterApp(user, token) {
window.AUTH_TOKEN = token;
window.CURRENT_USER = user; // cached so settings page doesn't re-fetch /me just to read canLocalAuth
if (isNativeApp()) {
// Mobile: persist token to Keychain/Keystore
window.SecureStorage.set(TOKEN_KEY, token);
window.SecureStorage.set(USER_KEY, JSON.stringify(user));
}
// Web: do not persist the token anywhere — session lives in the
// httpOnly cookie already set by the server. User info is
// re-fetched via /api/auth/me on each boot.
if (authScreen) authScreen.style.display = 'none';
if (mainApp) {
mainApp.style.display = 'block';
mainApp.className = mainApp.className.replace('hidden', '').trim();
}
if (userName) userName.textContent = user.name || user.email;
// Show admin tab for admins only
var adminTabBtn = document.getElementById('admin-tab-btn');
if (adminTabBtn) {
if (user.role === 'admin') adminTabBtn.classList.remove('hidden');
else adminTabBtn.classList.add('hidden');
}
// Show 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 (turnstile, 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 (turnstile token, 2FA, session storage, etc.)
// runs unchanged. If turnstile hasn't auto-solved yet the form
// will toast "Please complete the verification" — same as a
// manual login attempt before turnstile resolves.
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;
}
// Cloudflare Turnstile
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
var loginToken = loginTurnstile ? loginTurnstile.value : '';
if (!loginToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Signing in...');
var body = { email: email, password: password, turnstileToken: loginToken };
if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.requires2FA) {
var grp = document.getElementById('totp-group');
if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); }
showToast('Enter your 2FA code', 'info');
return;
}
if (data.needsVerification) {
var resendBox = document.getElementById('resend-verify-box');
if (resendBox) resendBox.className = resendBox.className.replace('hidden', '').trim();
showToast('Verify your email first. Check inbox.', 'error');
return;
}
if (data.success && data.token && data.user) {
if (data.sessionId && isNativeApp()) {
window.SecureStorage.set(SESSION_KEY, data.sessionId);
}
enterApp(data.user, data.token);
showToast('Welcome, ' + data.user.name + '!', 'success');
// 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');
if (window.turnstile) turnstile.reset('#turnstile-login');
}
})
.catch(function(err) {
hideLoading();
console.error('[Auth] Login error:', err);
showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
});
return false;
});
}
// ---- RESEND VERIFICATION LINK ----
var resendLink = document.getElementById('resend-verify-link');
if (resendLink) {
resendLink.addEventListener('click', function(e) {
e.preventDefault();
var email = document.getElementById('login-email').value.trim();
if (!email) { showToast('Enter your email first', 'error'); return; }
resendLink.style.pointerEvents = 'none';
resendLink.textContent = 'Sending...';
fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function(r) { return r.json(); })
.then(function(data) {
resendLink.style.pointerEvents = '';
resendLink.textContent = 'Resend verification link';
if (data.error) {
showToast(data.error, 'error');
} else {
showToast('Verification email sent! Check your inbox.', 'success');
}
})
.catch(function() {
resendLink.style.pointerEvents = '';
resendLink.textContent = 'Resend verification link';
showToast('Connection error', 'error');
});
});
}
// ---- REGISTER FORM SUBMIT ----
if (registerForm) {
registerForm.addEventListener('submit', function(e) {
e.preventDefault();
e.stopPropagation();
var name = document.getElementById('reg-name').value.trim();
var email = document.getElementById('reg-email').value.trim();
var password = document.getElementById('reg-password').value;
if (!name || !email || !password) {
showToast('Fill all fields', 'error');
return false;
}
if (password.length < 8) {
showToast('Password must be 8+ characters', 'error');
return false;
}
// Cloudflare Turnstile verification
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
if (!turnstileToken) {
showToast('Please complete the verification challenge', 'error');
return false;
}
showLoading('Creating account...');
fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success && data.token && data.user) {
if (data.sessionId && isNativeApp()) {
window.SecureStorage.set(SESSION_KEY, data.sessionId);
}
enterApp(data.user, data.token);
showToast(data.message || 'Account created!', 'success');
} else if (data.success && data.needsVerification) {
showToast(data.message || 'Check email to verify', 'success');
showLoginForm();
} else {
showToast(data.error || 'Registration failed', 'error');
if (window.turnstile) turnstile.reset();
}
})
.catch(function(err) {
hideLoading();
console.error('[Auth] Register error:', err);
showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset();
});
return false;
});
}
// ---- FORGOT PASSWORD SUBMIT ----
if (forgotForm) {
forgotForm.addEventListener('submit', function(e) {
e.preventDefault();
e.stopPropagation();
var email = document.getElementById('forgot-email').value.trim();
if (!email) { showToast('Enter email', 'error'); return false; }
// Cloudflare Turnstile
var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
if (!forgotToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Sending...');
fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, turnstileToken: forgotToken })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
showToast(data.message || 'Check your email', 'success');
showLoginForm();
})
.catch(function(err) {
hideLoading();
showToast('Error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-forgot');
});
return false;
});
}
// ---- SESSION MANAGEMENT ----
function timeAgo(date) {
var s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function escH(str) { var d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; }
window.loadSessions = function() {
var list = document.getElementById('sessions-list');
if (!list) return;
fetch('/api/sessions', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || !data.sessions || data.sessions.length === 0) {
list.innerHTML = '<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;
}
}
// 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');
});