Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Tapping SSO on a frozen realm did nothing at all — no navigation, no message. The guard is correct: a page bound to one account must not start a login for another. But silence is indistinguishable from a broken button, and it was reported as "single sign on error" when no SSO request had been made; the server logged no OIDC activity because the click never left the page. The button now says what is happening and names the control that clears it. The guard itself is unchanged — same checks, same order, same outcomes. The recovery screen said "your saved credentials no longer match this session", which describes the mechanism rather than the situation. The ordinary cause is signing in as a different account on a device that already held one. It now says that, and says plainly that nothing was lost and no data was mixed — which is the reassurance the safeguard has earned and was not giving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
1330 lines
58 KiB
JavaScript
1330 lines
58 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 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;
|
|
authScreen.style.display = 'flex';
|
|
}
|
|
|
|
// ── Check for SSO redirect (token is in httpOnly cookie) ──
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var ssoOk = urlParams.get('sso');
|
|
var ssoError = urlParams.get('error');
|
|
var ssoIntent = false;
|
|
try {
|
|
var intentTime = Number(sessionStorage.getItem('ped_sso_intent'));
|
|
ssoIntent = intentTime > 0 && Date.now() - intentTime >= 0 && Date.now() - intentTime < 5 * 60 * 1000;
|
|
sessionStorage.removeItem('ped_sso_intent');
|
|
} catch (e) {}
|
|
var ssoButton = document.getElementById('btn-sso');
|
|
if (ssoButton) ssoButton.addEventListener('click', async function(e) {
|
|
// A frozen realm cannot start a login, and silently doing nothing reads as
|
|
// "SSO is broken" — it is the account boundary holding, not a failure. Say
|
|
// so, and point at the one control that clears it.
|
|
if (boundary.blocked()) {
|
|
e.preventDefault();
|
|
showToast('This page is locked to a different account. Tap "Sign in again" to start a fresh session, then use SSO.', 'error');
|
|
return;
|
|
}
|
|
if (!e.isTrusted || authenticationPending) { e.preventDefault(); return; }
|
|
boundary.capture(); // Detect a replaced sibling session before capturing credentials.
|
|
if (boundary.blocked()) {
|
|
e.preventDefault();
|
|
showToast('This page is locked to a different account. Tap "Sign in again" to start a fresh session, then use SSO.', 'error');
|
|
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, ' +
|
|
'#btn-login-continue, #login-choice, #login-change-email, #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();
|
|
}
|
|
|
|
// 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();
|
|
// Signing out mid-recording used to end it with nothing kept. Say so, and
|
|
// store the audio either way, tagged with the encounter it came from, so
|
|
// it can be transcribed from Settings afterwards.
|
|
var recording = typeof activeRecordingCount === 'function' && activeRecordingCount() > 0;
|
|
if (!recording) { exitApp(); showToast('Logged out', 'info'); return; }
|
|
showConfirm('A recording is still running. Signing out stops it — the audio will be saved for 24 hours so you can transcribe it later. Sign out?', function() {
|
|
showToast('Saving the recording...', 'info');
|
|
rescueActiveRecordings().then(function(saved) {
|
|
var kept = saved.filter(function(entry) { return entry && entry.id; });
|
|
if (kept.length) showToast('Recording saved (' + kept[0].module + '). Find it in Settings → Audio backups.', 'success');
|
|
else showToast('The recording could not be saved', 'error');
|
|
}).finally(function() {
|
|
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...');
|
|
});
|
|
}
|
|
|
|
// ---- EMAIL-FIRST SIGN IN ----
|
|
// Email, then both ways in offered together. A code needs mail to arrive and
|
|
// a password does not, so neither is allowed to be the only route: the
|
|
// password option sits next to the code option rather than behind it.
|
|
function reveal(id, on) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return;
|
|
// classList, not a string replace: hiding twice used to append 'hidden'
|
|
// twice, and a non-global replace then stripped only one of them, so the
|
|
// element stayed hidden forever. Measured on the "use a different email"
|
|
// link, which never reappeared.
|
|
if (on) el.classList.remove('hidden'); else el.classList.add('hidden');
|
|
el.style.display = on ? (id === 'login-choice' ? 'flex' : '') : 'none';
|
|
}
|
|
|
|
function loginStep(step) {
|
|
reveal('btn-login-continue', step === 'email');
|
|
reveal('login-choice', step === 'choice');
|
|
reveal('login-code-group', step === 'code');
|
|
reveal('login-password-group', step === 'password');
|
|
reveal('btn-local-login', step === 'code' || step === 'password');
|
|
reveal('login-change-email', step !== 'email');
|
|
var email = document.getElementById('login-email');
|
|
if (email) email.readOnly = step !== 'email';
|
|
var submit = document.getElementById('btn-local-login');
|
|
if (submit) submit.textContent = step === 'code' ? 'Sign in with code' : 'Sign In';
|
|
loginMode = step;
|
|
}
|
|
var loginMode = 'email';
|
|
|
|
var continueBtn = document.getElementById('btn-login-continue');
|
|
if (continueBtn) continueBtn.addEventListener('click', function () {
|
|
var email = (document.getElementById('login-email') || {}).value || '';
|
|
if (!email.trim() || email.indexOf('@') === -1) { showToast('Enter your email', 'error'); return; }
|
|
loginStep('choice');
|
|
});
|
|
|
|
var changeEmail = document.getElementById('login-change-email');
|
|
if (changeEmail) changeEmail.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
['login-code', 'login-password', 'login-totp'].forEach(function (id) {
|
|
var el = document.getElementById(id); if (el) el.value = '';
|
|
});
|
|
reveal('totp-group', false);
|
|
loginStep('email');
|
|
var email = document.getElementById('login-email');
|
|
if (email) email.focus();
|
|
});
|
|
|
|
var usePassword = document.getElementById('btn-login-use-password');
|
|
if (usePassword) usePassword.addEventListener('click', function () {
|
|
loginStep('password');
|
|
var pw = document.getElementById('login-password');
|
|
if (pw) pw.focus();
|
|
});
|
|
|
|
var sendCode = document.getElementById('btn-login-send-code');
|
|
if (sendCode) sendCode.addEventListener('click', function () {
|
|
var email = ((document.getElementById('login-email') || {}).value || '').trim();
|
|
if (!email) { showToast('Enter your email', 'error'); return; }
|
|
sendCode.disabled = true;
|
|
fetch('/api/auth/login-code/request', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: email })
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
// The server answers the same way whether or not the account exists, so
|
|
// this says what was done, not what was found.
|
|
loginStep('code');
|
|
var hint = document.getElementById('login-code-hint');
|
|
if (hint) {
|
|
hint.textContent = data && data.error
|
|
? data.error
|
|
: 'If ' + email + ' has an account, a code is on its way. It expires in 10 minutes. ' +
|
|
'No code? Use your password instead.';
|
|
}
|
|
var box = document.getElementById('login-code');
|
|
if (box) box.focus();
|
|
})
|
|
.catch(function () { showToast('Connection error', 'error'); })
|
|
.finally(function () { sendCode.disabled = false; });
|
|
});
|
|
|
|
// ---- LOGIN FORM SUBMIT ----
|
|
function submitCode() {
|
|
var email = document.getElementById('login-email').value.trim();
|
|
var code = (document.getElementById('login-code') || {}).value || '';
|
|
var totpEl = document.getElementById('login-totp');
|
|
var totpCode = totpEl ? totpEl.value.trim() : '';
|
|
if (!code.trim()) { showToast('Enter the code from your email', 'error'); return false; }
|
|
|
|
var body = { email: email, code: code };
|
|
if (totpCode) body.totpCode = totpCode;
|
|
var attempt = authState();
|
|
return fetch('/api/auth/login-code/verify', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) { return handleSignIn(data, attempt); })
|
|
.catch(function (err) {
|
|
if (boundary.blocked() || err.name === 'AbortError') return;
|
|
showToast('Connection error', 'error');
|
|
});
|
|
}
|
|
|
|
// Shared by both routes in, so a code sign-in gets the same 2FA prompt, the
|
|
// same verification notice and the same welcome as a password sign-in.
|
|
function handleSignIn(data, attempt) {
|
|
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) showToast('Welcome, ' + data.user.name + '!', 'success');
|
|
});
|
|
}
|
|
showToast(data.error || 'Sign-in failed', 'error');
|
|
}
|
|
|
|
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 your 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(loginMode === 'code' ? submitCode : submitLogin, 'Signing in...');
|
|
});
|
|
loginStep('email');
|
|
|
|
// ---- 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,
|
|
// Sent whether or not the field is showing; the server decides
|
|
// whether it is required.
|
|
inviteCode: (document.getElementById('reg-invite') || {}).value || ''
|
|
})
|
|
});
|
|
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 = '';
|
|
}
|
|
// Only ask for a code when one is actually required, so ordinary
|
|
// registration is not cluttered by a field nobody can fill in.
|
|
var inviteGroup = document.getElementById('reg-invite-group');
|
|
var inviteInput = document.getElementById('reg-invite');
|
|
if (inviteGroup) inviteGroup.classList.toggle('hidden', !data.inviteOnly);
|
|
if (inviteInput) inviteInput.required = !!data.inviteOnly;
|
|
})
|
|
.catch(function() {});
|
|
|
|
console.log('[Auth] ✅ Module ready');
|
|
});
|