pediatric-ai-scribe-v3/public/js/auth.js
Daniel f31afcdbf4 Fix biometric login, and keep recording alive across screen lock
Biometric sign-in has never worked. auth.js drove
window.Capacitor.Plugins.NativeBiometric — the API of
capacitor-native-biometric, which is not a dependency of this project. The
installed plugin is @aparajita/capacitor-biometric-auth, registered as
BiometricAuthNative with an entirely different API and no credential
storage at all. bioPlugin() therefore always returned null, bioAvailable()
always resolved {ok:false}, and the button was never revealed.

Rewritten against what is actually installed, with no new dependency:
BiometricAuthNative (checkBiometry/authenticate) presents the prompt, and
the already-working SecureStoragePlugin — via the window.SecureStorage
wrapper — holds the credentials. Credentials are only read after
authenticate() resolves, so the OS still gates access. biometryType is a
numeric enum in this plugin, so the old FACE_ID/TOUCH_ID string maps are
replaced with a single lookup exposed as typeName.

Secure storage itself was fine and is unchanged: SecureStoragePlugin
matches the name the wrapper looks up and is registered in
capacitor.settings.gradle.

Recording across screen lock: window.nativeKeepAwake() called Capacitor's
KeepAwake plugin, which is also not installed here, so it silently did
nothing and the device slept mid-encounter — taking the WebView's
MediaRecorder with it. keepAwake() is now a method on the existing
NativeRecording JavascriptInterface, which sets FLAG_KEEP_SCREEN_ON, and
is bound to the WebView so it survives the launcher's navigation to the
remote origin. The Capacitor plugin remains a fallback.

If the screen is locked anyway (power button, incoming call), the activity
pauses and Chromium throttles timers for hidden WebViews, starving
MediaRecorder's chunk delivery. MainActivity now calls resumeTimers() on
pause while recording. The foreground service was already correct — it
holds a partial wake lock and declares FOREGROUND_SERVICE_TYPE_MICROPHONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:12:47 +02:00

1120 lines
47 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.
// 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 (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 = s.typeName || '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 || '') + ' &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');
});