- OIDC callback now passes only ?sso=ok flag, token stays in httpOnly cookie (prevents token leaking to logs/referrer/history) - Frontend auth.js uses cookie-based auth for SSO flow - Add [PHYSICIAN TEMPLATES] boundary markers around physicianMemories in all 5 generation routes to mitigate prompt injection - Consistent boundary format across wellVisit, sickVisit, hpi, soap, hospitalCourse
517 lines
18 KiB
JavaScript
517 lines
18 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';
|
|
|
|
console.log('[Auth] Initializing...');
|
|
console.log('[Auth] authScreen:', !!authScreen);
|
|
console.log('[Auth] mainApp:', !!mainApp);
|
|
console.log('[Auth] loginForm:', !!loginForm);
|
|
console.log('[Auth] registerForm:', !!registerForm);
|
|
|
|
// Make sure forms are visible/hidden correctly on load
|
|
showLoginForm();
|
|
|
|
// 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') {
|
|
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) {
|
|
// Get token for localStorage from a follow-up — or just use cookie-based auth
|
|
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' };
|
|
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() {});
|
|
|
|
var savedToken = localStorage.getItem(TOKEN_KEY);
|
|
if (ssoOk) {
|
|
// SSO handled above — do nothing here
|
|
} else if (ssoError) {
|
|
// SSO error handled above — auth screen already shown
|
|
} else if (savedToken) {
|
|
// Existing token — verify silently; auth screen stays hidden during check
|
|
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 {
|
|
// No token — show login immediately
|
|
showAuthScreen();
|
|
}
|
|
|
|
// ---- HELPER FUNCTIONS ----
|
|
|
|
function showLoginForm() {
|
|
if (loginForm) loginForm.style.display = 'block';
|
|
if (registerForm) registerForm.style.display = 'none';
|
|
if (forgotForm) forgotForm.style.display = 'none';
|
|
}
|
|
|
|
function showRegisterForm() {
|
|
if (loginForm) loginForm.style.display = 'none';
|
|
if (registerForm) registerForm.style.display = 'block';
|
|
if (forgotForm) forgotForm.style.display = 'none';
|
|
}
|
|
|
|
function showForgotForm() {
|
|
if (loginForm) loginForm.style.display = 'none';
|
|
if (registerForm) registerForm.style.display = 'none';
|
|
if (forgotForm) forgotForm.style.display = 'block';
|
|
}
|
|
|
|
function enterApp(user, token) {
|
|
window.AUTH_TOKEN = token;
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
|
|
|
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 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();
|
|
}
|
|
|
|
function exitApp() {
|
|
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');
|
|
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() {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(USER_KEY);
|
|
window.AUTH_TOKEN = null;
|
|
}
|
|
|
|
window.getAuthHeaders = function() {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem(TOKEN_KEY) || '')
|
|
};
|
|
};
|
|
|
|
// ---- 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');
|
|
}
|
|
|
|
// 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';
|
|
load2FAStatus();
|
|
} else {
|
|
showToast(data.error || 'Invalid code', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Disable 2FA
|
|
if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) {
|
|
e.preventDefault();
|
|
var pw = prompt('Enter password to disable 2FA:');
|
|
if (!pw) return;
|
|
fetch('/api/auth/disable-2fa', {
|
|
method: 'POST', headers: getAuthHeaders(),
|
|
body: JSON.stringify({ password: pw })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) { showToast('2FA disabled', 'info'); load2FAStatus(); }
|
|
else showToast(data.error || '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;
|
|
}
|
|
|
|
console.log('[Auth] Attempting login for:', email);
|
|
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();
|
|
console.log('[Auth] Login response:', JSON.stringify(data).substring(0, 200));
|
|
|
|
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) {
|
|
enterApp(data.user, data.token);
|
|
showToast('Welcome, ' + data.user.name + '!', 'success');
|
|
} 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;
|
|
}
|
|
|
|
console.log('[Auth] Registering:', email);
|
|
showLoading('Creating account...');
|
|
|
|
fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name, email: email, password: password })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideLoading();
|
|
console.log('[Auth] Register response:', JSON.stringify(data).substring(0, 200));
|
|
|
|
if (data.success && data.token && data.user) {
|
|
enterApp(data.user, data.token);
|
|
showToast(data.message || 'Account created!', 'success');
|
|
} else if (data.success && data.needsVerification) {
|
|
showToast(data.message || 'Check email to verify', 'success');
|
|
showLoginForm();
|
|
} else {
|
|
showToast(data.error || 'Registration failed', 'error');
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
hideLoading();
|
|
console.error('[Auth] Register error:', err);
|
|
showToast('Connection error', 'error');
|
|
});
|
|
|
|
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; }
|
|
|
|
showLoading('Sending...');
|
|
|
|
fetch('/api/auth/forgot-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: email })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideLoading();
|
|
showToast(data.message || 'Check your email', 'success');
|
|
showLoginForm();
|
|
})
|
|
.catch(function(err) {
|
|
hideLoading();
|
|
showToast('Error', 'error');
|
|
});
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// ---- 2FA STATUS ----
|
|
function load2FAStatus() {
|
|
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data || !data.user) return;
|
|
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';
|
|
} 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';
|
|
}
|
|
})
|
|
.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;
|
|
|
|
// 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');
|
|
});
|