1159 lines
50 KiB
JavaScript
1159 lines
50 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) 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) {
|
|
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) {}
|
|
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');
|
|
});
|