Sources (correcting what I built earlier) The previous toggle branched the SYSTEM PROMPT, so the same question could get a different answer depending on a display setting — the bias this was meant to avoid. The prompt is now unconditional: buildSystemPrompt takes no display argument and is byte-identical either way. Hiding sources happens on the way out — the server omits them and strips the now-orphaned [n] markers from the copy it sends. The answer is generated, stored and exported with citations intact, so turning the setting back on restores them without re-asking anything. Renamed to clinical_assistant.show_sources; the old key is still honoured. Signed-out preview (admin opt-in, default off) A visitor may try the assistant; reaching for the workspace asks them to sign in. Deliberately narrow: - Reachable paths are an exact allow-list, not a pattern, so a new endpoint is private unless someone adds it on purpose. - A preview visitor gets no identity at all (id: null), so nothing can be owned, saved, billed or addressed to them. - The image tool is withheld rather than left to fail on a null owner, and no audit rows are written. - A caller presenting a token is authenticated normally, so preview can never downgrade a real session; if the setting cannot be read, authentication is required. - Actions needing an account are hidden rather than offered and refused. Composer The bar above the transcript is gone. Patient take home, Export PDF, Download transcript and Attach images moved into a + menu in the composer, and the model selector moved beside send — shown only when there is more than one model, as before. Both views now start at the same top edge, so switching modes cannot nudge the page up or down. On an empty transcript the tiled ground runs behind and below the composer, which floats on it above centre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
1185 lines
52 KiB
JavaScript
1185 lines
52 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) return;
|
|
// Admin-enabled preview: a signed-out visitor at /assistant may try the
|
|
// assistant instead of meeting a wall. Everything else still needs an
|
|
// account, and the server enforces that independently — this only decides
|
|
// whether to show the login screen.
|
|
if (window.location && window.location.pathname === '/assistant') {
|
|
fetch('/api/clinical-assistant/status', { credentials: 'same-origin' })
|
|
.then(function(r) { return r.ok ? r.json() : null; })
|
|
.then(function(data) {
|
|
if (data && data.success && data.preview) return enterPreview();
|
|
authScreen.style.display = 'flex';
|
|
})
|
|
.catch(function() { authScreen.style.display = 'flex'; });
|
|
return;
|
|
}
|
|
authScreen.style.display = 'flex';
|
|
}
|
|
|
|
// The auth screen is an overlay over an already-rendered app, so entering
|
|
// preview is simply declining to raise it.
|
|
function enterPreview() {
|
|
document.body.classList.add('assistant-preview');
|
|
if (authScreen) authScreen.style.display = 'none';
|
|
if (typeof window.activateTab === 'function') window.activateTab('assistant');
|
|
}
|
|
|
|
// ── 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 || '') + ' · Created ' + created + ' · Active ' + lastAct + '</div>'
|
|
+ '</div>'
|
|
+ (isCurrent ? '' : '<button class="btn-sm btn-ghost session-revoke-btn" data-sid="' + s.id + '" style="color:var(--red);font-size:12px;"><i class="fas fa-xmark"></i> Revoke</button>')
|
|
+ '</div>';
|
|
}).join('');
|
|
|
|
// Wire revoke buttons
|
|
list.querySelectorAll('.session-revoke-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
showConfirm('Revoke this session? That device will be logged out.', function() {
|
|
fetch('/api/sessions/' + btn.dataset.sid, { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.success) { showToast('Session revoked', 'info'); loadSessions(); }
|
|
else showToast(d.error || 'Failed', 'error');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
})
|
|
.catch(function() {
|
|
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">Could not load sessions.</p>';
|
|
});
|
|
};
|
|
|
|
// Revoke all other sessions button
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.id === 'btn-revoke-all-sessions' || e.target.closest('#btn-revoke-all-sessions')) {
|
|
e.preventDefault();
|
|
showConfirm('Revoke all other sessions? All other devices will be logged out.', function() {
|
|
fetch('/api/sessions', { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.success) { showToast('All other sessions revoked (' + (d.revoked || 0) + ' removed)', 'info'); loadSessions(); }
|
|
});
|
|
}, { danger: true, confirmText: 'Revoke All' });
|
|
}
|
|
});
|
|
|
|
// ---- CHANGE PASSWORD ----
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.id === 'btn-change-password' || e.target.closest('#btn-change-password')) {
|
|
e.preventDefault();
|
|
var current = document.getElementById('pw-current');
|
|
var newPw = document.getElementById('pw-new');
|
|
var confirmPw = document.getElementById('pw-confirm');
|
|
var status = document.getElementById('pw-change-status');
|
|
if (!current || !newPw || !confirmPw) return;
|
|
|
|
if (!current.value || !newPw.value) { showToast('Fill in all fields', 'error'); return; }
|
|
if (newPw.value.length < 8) { showToast('New password must be 8+ characters', 'error'); return; }
|
|
if (newPw.value !== confirmPw.value) { showToast('Passwords do not match', 'error'); return; }
|
|
|
|
if (status) { status.textContent = 'Changing...'; status.style.color = 'var(--g500)'; }
|
|
|
|
fetch('/api/auth/change-password', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ currentPassword: current.value, newPassword: newPw.value })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) {
|
|
showToast(data.message || 'Password changed', 'success');
|
|
if (data.passwordWarning) setTimeout(function() { showToast(data.passwordWarning, 'warning'); }, 1500);
|
|
current.value = ''; newPw.value = ''; confirmPw.value = '';
|
|
if (status) { status.textContent = ''; }
|
|
if (typeof loadSessions === 'function') loadSessions();
|
|
} else {
|
|
showToast(data.error || 'Failed', 'error');
|
|
if (status) { status.textContent = ''; }
|
|
}
|
|
})
|
|
.catch(function() { showToast('Connection error', 'error'); if (status) status.textContent = ''; });
|
|
}
|
|
});
|
|
|
|
// ---- 2FA STATUS ----
|
|
function load2FAStatus() {
|
|
fetch('/api/auth/me', { credentials: 'same-origin', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data || !data.user) {
|
|
// /me failed — if we already have a cached user from login, fall back to that
|
|
// so reloaded Settings pages still render for local-auth users.
|
|
if (window.CURRENT_USER && window.CURRENT_USER.canLocalAuth === true) {
|
|
data = { user: window.CURRENT_USER };
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
// /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');
|
|
});
|