import { showToast } from '../components/toast.js'; import { t } from '../i18n.js'; import { esc } from '../utils.js'; /* * A recognisable mark for the providers people expect to see, and an honest generic one for * everything else. Inline SVG rather than a remote image: an to a provider CDN would put a * third-party origin back into the CSP, which is precisely what moving the flow server-side removed. */ const PROVIDER_ICONS = { google: ``, microsoft: ``, }; const GENERIC_ICON = ``; const providerIcon = (slug) => PROVIDER_ICONS[slug] || GENERIC_ICON; let authConfig = null; async function loadAuthConfig() { if (authConfig) return authConfig; const res = await fetch('/api/auth/config'); authConfig = await res.json(); return authConfig; } // #15: resolve instance/default branding for the (pre-login) login page. // Public endpoint: custom-domain match -> platform default -> ScreenTinker. async function loadLoginBranding() { try { const res = await fetch('/api/branding?domain=' + encodeURIComponent(location.hostname)); if (!res.ok) return {}; return await res.json(); } catch { return {}; } } function brandEsc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } // Apply document-level branding (colors, favicon, title, custom CSS) for login. function applyLoginBrandingDoc(b) { const root = document.documentElement; if (b.primary_color) root.style.setProperty('--accent', b.primary_color); if (b.bg_color) root.style.setProperty('--bg-primary', b.bg_color); if (b.brand_name) document.title = b.brand_name; if (b.favicon_url) { document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(l => l.setAttribute('href', b.favicon_url)); } if (b.custom_css) { let style = document.getElementById('wl-custom-css'); if (!style) { style = document.createElement('style'); style.id = 'wl-custom-css'; document.head.appendChild(style); } style.textContent = b.custom_css; } } export async function render(container) { const [config, branding] = await Promise.all([loadAuthConfig(), loadLoginBranding()]); const isSetup = config.needsSetup; // registration_enabled may be absent on older servers — treat as enabled for back-compat const canRegister = config.registration_enabled !== false; applyLoginBrandingDoc(branding); const brandName = branding.brand_name || 'ScreenTinker'; // Branded logo if set, else the default ScreenTinker glyph. const logoHtml = branding.logo_url ? `${brandEsc(brandName)}` : ` `; container.innerHTML = `
${logoHtml}

${brandEsc(brandName)}

${isSetup ? t('auth.subtitle_setup') : t('auth.subtitle_signin')}

${!isSetup && canRegister ? `

${t('auth.trial_notice')}

` : ''}
${isSetup ? `
` : ''} ${!isSetup ? `

${t('auth.forgot_password')}

` : ''} ${!isSetup && canRegister ? ` ` : ''}
${(config.providers || []).length ? `

${t('auth.divider_or')}
` : ''}
${(config.providers || []).map((p) => ` ${providerIcon(p.slug)} ${esc(t('auth.signin_with', { provider: p.name }))} `).join('')}
${t('auth.support_access')}

${t('auth.terms')}  ·  ${t('auth.privacy')}

`; setupHandlers(config, isSetup); } function setupHandlers(config, isSetup) { const showError = (msg) => { const el = document.getElementById('loginError'); el.textContent = msg; el.style.display = 'block'; }; // Outcome of clicking the email-verification link (server GET /verify-email redirects here). const hashQuery = new URLSearchParams((location.hash.split('?')[1]) || ''); if (hashQuery.get('verified') === '1') showToast(t('auth.verify_ok'), 'success'); else if (hashQuery.get('verify_error') === '1') showToast(t('auth.verify_failed'), 'error'); // Support token login document.getElementById('supportLoginBtn')?.addEventListener('click', async () => { const token = document.getElementById('supportToken')?.value.trim(); if (!token) { showError(t('auth.error_paste_support_token')); return; } try { const res = await fetch('/api/auth/support', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }); const data = await res.json(); if (!res.ok) { showError(data.error); return; } onAuthSuccess(data); } catch (err) { showError(t('auth.error_support_failed')); } }); // Local login/register if (isSetup) { document.getElementById('loginBtn')?.addEventListener('click', () => doRegister(true)); } else { document.getElementById('loginBtn')?.addEventListener('click', doLogin); document.getElementById('showRegisterBtn')?.addEventListener('click', () => { document.getElementById('localAuthForm').style.display = 'none'; document.getElementById('registerForm').style.display = 'block'; }); document.getElementById('showLoginBtn')?.addEventListener('click', () => { document.getElementById('localAuthForm').style.display = 'block'; document.getElementById('registerForm').style.display = 'none'; }); document.getElementById('registerBtn')?.addEventListener('click', () => doRegister(false)); } // Enter key on password field document.getElementById('loginPassword')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') isSetup ? doRegister(true) : doLogin(); }); async function doLogin() { const email = document.getElementById('loginEmail').value.trim(); const password = document.getElementById('loginPassword').value; if (!email || !password) { showError(t('auth.error_email_password_required')); return; } try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const data = await res.json(); /* * The organization requires its identity provider, so this is not a credential failure and * must not read like one — "invalid password" sends the user to reset a password that will * never work again. Point them at the control that does work. */ if (!res.ok && data.code === 'sso_required') { showError(t('auth.sso_required')); return; } if (!res.ok) { showError(data.error); return; } // Unverified account (hosted hard-gate): no session — prompt to check email. if (data.verification_required) { showVerifyNotice(data.email || email); return; } // #100: TOTP-enabled accounts get no session yet — a second step verifies a code. if (data.mfa_required) { showMfaChallenge(data.mfa_token); return; } onAuthSuccess(data); } catch (err) { showError(t('auth.error_login_failed')); } } // "Check your email" panel shown when signup/login returns verification_required (hosted). // ---- Self-service password reset ------------------------------------------------- // Two cards swapped into the same login shell. The request step ALWAYS shows the same // confirmation regardless of the server's answer, matching the server's deliberate // refusal to reveal whether an address exists. function showCard(id) { ['localAuthForm', 'registerForm', 'mfaForm', 'ssoBlock', 'forgotForm', 'resetForm'].forEach((x) => { const el = document.getElementById(x); if (el) el.style.display = (x === id ? 'block' : 'none'); }); const errEl = document.getElementById('loginError'); if (errEl) errEl.style.display = 'none'; } const forgotLink = document.getElementById('forgotLink'); if (forgotLink) forgotLink.addEventListener('click', (e) => { e.preventDefault(); showCard('forgotForm'); const src = document.getElementById('loginEmail'); const dst = document.getElementById('forgotEmail'); if (src && dst) dst.value = src.value; // carry over whatever they already typed }); const forgotBackBtn = document.getElementById('forgotBackBtn'); if (forgotBackBtn) forgotBackBtn.addEventListener('click', () => showCard('localAuthForm')); const forgotSendBtn = document.getElementById('forgotSendBtn'); if (forgotSendBtn) forgotSendBtn.addEventListener('click', async () => { const email = (document.getElementById('forgotEmail').value || '').trim(); forgotSendBtn.disabled = true; try { await fetch('/api/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); } catch (e) { /* deliberately ignored — see below */ } // Same confirmation either way. Surfacing a network/server error here would leak // whether the address matched, undoing the server-side enumeration resistance. document.getElementById('forgotNotice').style.display = 'block'; forgotSendBtn.disabled = false; }); // A link from the reset email: #/reset-password?token=... function resetTokenFromHash() { const h = window.location.hash || ''; const q = h.indexOf('?'); if (!h.startsWith('#/reset-password') || q < 0) return null; return new URLSearchParams(h.slice(q + 1)).get('token'); } const pendingResetToken = resetTokenFromHash(); if (pendingResetToken) showCard('resetForm'); const resetBackBtn = document.getElementById('resetBackBtn'); if (resetBackBtn) resetBackBtn.addEventListener('click', () => { window.location.hash = '#/login'; window.location.reload(); }); const resetSubmitBtn = document.getElementById('resetSubmitBtn'); if (resetSubmitBtn) resetSubmitBtn.addEventListener('click', async () => { const password = document.getElementById('resetPassword').value || ''; resetSubmitBtn.disabled = true; try { const res = await fetch('/api/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: pendingResetToken, password }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showError(data.error || t('auth.reset_failed')); resetSubmitBtn.disabled = false; return; } // No session is issued by design, so send them through a normal sign-in — which is // what keeps TOTP in the loop for accounts that have it. showToast(t('auth.reset_done'), 'success'); window.location.hash = '#/login'; window.location.reload(); } catch (e) { showError(t('auth.reset_failed')); resetSubmitBtn.disabled = false; } }); function showVerifyNotice(email) { // The server refused a session — make sure no stale token from a prior login lingers, // else the router would treat this browser as authenticated and bounce it into the app. localStorage.removeItem('token'); localStorage.removeItem('user'); ['localAuthForm', 'registerForm', 'mfaForm', 'ssoBlock', 'supportDetails'].forEach((id) => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); document.getElementById('verifyNotice').style.display = 'block'; document.getElementById('verifyEmail').textContent = email || ''; const errEl = document.getElementById('loginError'); if (errEl) errEl.style.display = 'none'; document.getElementById('verifyBackBtn').addEventListener('click', () => window.location.reload()); document.getElementById('verifyResendBtn').addEventListener('click', async () => { try { await fetch('/api/auth/resend-verification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); showToast(t('auth.verify_resent'), 'success'); // always generic (server never leaks existence) } catch (e) { showToast(t('auth.verify_resend_failed'), 'error'); } }); } // Swap the card to the 6-digit challenge and exchange mfa_token + code for a session. function showMfaChallenge(mfaToken) { ['localAuthForm', 'registerForm', 'ssoBlock', 'supportDetails'].forEach((id) => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); const form = document.getElementById('mfaForm'); form.style.display = 'block'; const errEl = document.getElementById('loginError'); if (errEl) errEl.style.display = 'none'; const codeEl = document.getElementById('mfaCode'); codeEl.value = ''; codeEl.focus(); const verify = async () => { const code = codeEl.value.trim(); if (!code) { showError(t('auth.mfa_code_required')); return; } try { const res = await fetch('/api/auth/totp/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mfa_token: mfaToken, code }) }); const data = await res.json(); if (!res.ok) { showError(data.error || t('auth.mfa_invalid')); codeEl.select(); return; } onAuthSuccess(data); } catch (err) { showError(t('auth.error_login_failed')); } }; document.getElementById('mfaVerifyBtn').addEventListener('click', verify); codeEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') verify(); }); document.getElementById('mfaBackBtn').addEventListener('click', () => { window.location.reload(); }); } async function doRegister(isFirstUser) { const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim(); const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value; const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || ''; if (!email || !password) { showError(t('auth.error_email_password_required')); return; } if (password.length < 6) { showError(t('auth.error_password_min_6')); return; } try { const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, name }) }); const data = await res.json(); if (!res.ok) { showError(data.error); return; } // Hosted signup requires confirming the email before a session is issued. if (data.verification_required) { showVerifyNotice(data.email || email); return; } onAuthSuccess(data); } catch (err) { showError(t('auth.error_registration_failed')); } } /* * SSO is a link, not a script. * * The buttons above are anchors to /api/auth/oidc//start, so there is nothing to bind here * and no SDK to wait for. What DOES need handling is the trip back: the callback redirects to * #/login carrying either a session token or an error code. * * The token rides in the URL FRAGMENT, which browsers never send to servers and proxies never * log — and it is stripped from the address bar before anything else happens, so a shared screen * or a copied URL does not carry a live session. */ /* * Email-first SSO for organizations. * * Instance-wide providers are always on the page. An ORG provider is different — it belongs to * one customer — so it is fetched by domain once the address looks complete, and only then. * * Debounced because this fires while someone types, and the endpoint is rate limited; asking on * every keystroke would spend a user's whole budget before they finished their own address. */ let ssoLookupTimer = null; let lastDomainAsked = ''; const orgSlot = () => document.getElementById('orgSsoSlot'); /* * Show or hide the password half of the sign-in form. * * Presentation only — the server refuses a password for these accounts regardless. Restoring it * on every negative answer matters as much as hiding it: someone who types an SSO-only address, * then corrects it to their own, must get the password box back. */ function setPasswordVisible(visible) { /* * ⚠️ Hide the password FIELD, never its .form-group — the organization SSO slot lives inside * that same group, so hiding the container took the single sign-on button down with it and left * a login page whose only action was "Create Account". Found by looking at a screenshot. */ const show = visible ? '' : 'none'; for (const id of ['loginPassword', 'loginPasswordLabel', 'loginBtn']) { const el = document.getElementById(id); if (el) el.style.display = show; } /* * The instance's own providers go too. They are the operator's, not this organization's, and * they are not domain-confined — so offering "Continue with Google" to someone whose company * requires its own identity provider is offering them the bypass. The server refuses it either * way; this stops the page inviting it. */ const instance = document.getElementById('instanceProviders'); if (instance) instance.style.display = show; /* * "Create Account" goes too. Registration at an SSO-only domain is refused by the server, and * leaving the button was worse than useless: it was the ONLY action left on the card, so the * page invited the one thing that cannot work. */ const reg = document.getElementById('showRegisterBtn'); if (reg) reg.style.display = show; // The OR divider sits outside #instanceProviders, so hiding those alone left a dangling rule // with nothing beneath it. const divider = document.getElementById('ssoDivider'); if (divider) divider.style.display = show; // "Forgot your password?" sits in its own

; hide the wrapper so no empty gap is left. const forgot = document.getElementById('forgotLink'); if (forgot) { const wrap = forgot.parentElement && forgot.parentElement.tagName === 'P' ? forgot.parentElement : forgot; wrap.style.display = show; } } async function lookupOrgSso(email) { const at = String(email || '').lastIndexOf('@'); const domain = at === -1 ? '' : email.slice(at + 1).trim().toLowerCase(); const slot = orgSlot(); if (!slot) return; // Nothing to ask about until there is a domain with a dot in it. if (!domain || !domain.includes('.')) { slot.style.display = 'none'; slot.innerHTML = ''; lastDomainAsked = ''; setPasswordVisible(true); return; } if (domain === lastDomainAsked) return; try { const res = await fetch(`/api/auth/sso/discover?email=${encodeURIComponent(email)}`); /* * ⚠️ Check the STATUS, not just that a body parsed. * * The comment below has always said a tripped rate limit must not poison the domain — and it * did anyway, because a 429 body is perfectly valid JSON: res.json() resolved, `data.sso` * came back undefined, so the single sign-on button was hidden, the password box restored, * and `lastDomainAsked` recorded — permanently, for the life of the page. On an SSO-only * domain that is the worst possible outcome: the password box the user is then offered gets * 403, and the button they are told to use is not on the screen. Discover is 10/min per IP, * so a handful of colleagues behind one office address is enough to trigger it. */ if (!res.ok) throw new Error(`discover ${res.status}`); const data = await res.json(); // Remembered only after a SUCCESSFUL answer. lastDomainAsked = domain; if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; setPasswordVisible(true); return; } /* * When the organization REQUIRES its identity provider, the password box is not merely going * to fail — it is the wrong thing to offer. Showing it invites someone to type a password, * be refused, and go and reset a password that will never work again. Hidden, not disabled, * so there is one obvious way forward. */ setPasswordVisible(!data.required); /* * A FORM, not a link, and a deliberately generic label. * * The lookup tells us only that this domain uses SSO — never which provider or whose it is, * because that would identify a customer to anyone who guessed a domain. The server does the * mapping again on submit, so the slug is never published to the page. POST keeps the address * out of the URL, browser history and any Referer the provider's page would send. */ /* * A BUTTON that fetches and then navigates — not a form that submits. * * The dashboard's CSP is `form-action 'self'`, and Chrome applies it across the whole * redirect chain, so a form POST that 302s on to the customer's identity provider was * ABORTED with nothing shown to the user at all. The provider origins cannot be allowlisted * because customers supply them. A script-initiated navigation is not covered by * form-action, so the page asks the server where to go and goes there. * * Styled secondary: "Sign In" is the primary action while a password still works, and two * identical blue buttons stacked one above the other sent people to their IdP by muscle * memory after typing a password. */ slot.innerHTML = `

${t('auth.sso_org_hint')}
`; slot.style.display = ''; const btn = slot.querySelector('#orgSsoBtn'); if (btn) btn.addEventListener('click', async () => { btn.disabled = true; try { const r = await fetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ email }), }); const body = await r.json().catch(() => ({})); if (!r.ok || !body.start_url) throw new Error(body.error || `start ${r.status}`); window.location.assign(body.start_url); } catch { btn.disabled = false; showError(t('auth.sso_err_provider_unavailable')); } }); } catch { // A failed lookup must never block a password login — the form still works, and the password // box comes back rather than leaving someone staring at a form with no way to submit it. slot.style.display = 'none'; slot.innerHTML = ''; setPasswordVisible(true); } } document.getElementById('loginEmail')?.addEventListener('input', (e) => { clearTimeout(ssoLookupTimer); const value = e.target.value; ssoLookupTimer = setTimeout(() => lookupOrgSso(value), 400); }); /* * Completing an SSO login. * * The callback no longer hands the session token back in the URL — that was a login-CSRF hole, * because a crafted link could install an ATTACKER'S token and quietly sign the victim into their * account. The server now leaves it in a one-shot httpOnly cookie and we exchange it here, which * a link cannot forge. * * Wrapped in an async IIFE because setupHandlers() is not async; `await` at this level is a * SyntaxError that takes the whole module graph down with it, since app.js imports this file * statically and there is no bundler to catch it first. */ const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || '')); const ssoReturning = ssoParams.get('sso') === '1'; const ssoError = ssoParams.get('sso_error'); if (ssoReturning || ssoError) { // Keep any real query string; only the hash carried the SSO markers. history.replaceState(null, '', window.location.pathname + window.location.search + '#/login'); } if (ssoReturning) { (async () => { try { const res = await fetch('/api/auth/sso/claim', { method: 'POST' }); if (!res.ok) throw new Error('claim rejected'); const data = await res.json(); onAuthSuccess(data); } catch { showToast(t('auth.sso_failed'), 'error'); } })(); } else if (ssoError) { // Every code the callback can emit has a message; an unknown one still says something true // rather than failing silently, which is how the previous implementation behaved on every click. const known = ['expired', 'bad_state', 'no_code', 'no_email', 'email_unverified', 'verification_failed', 'provider_refused', 'provider_unavailable', 'unknown_provider', 'registration_disabled', 'account_exists_local', 'subject_mismatch', 'server_error', 'domain_not_allowed', 'account_exists_other_provider', 'sso_required']; const key = known.includes(ssoError) ? `auth.sso_err_${ssoError}` : 'auth.sso_failed'; showToast(t(key), 'error'); } } function onAuthSuccess(data) { // Defensive: only a response that actually carries a session token logs the user in. A // tokenless response (e.g. verification_required / mfa_required) must never be stored as a // session — otherwise isAuthenticated() would pass on the string "undefined" and the router // would bounce an un-authenticated browser into the app / setup wizard. if (!data || !data.token) return; localStorage.setItem('token', data.token); localStorage.setItem('user', JSON.stringify(data.user)); window.location.hash = '#/'; window.location.reload(); } export function cleanup() {}