import { showToast } from '../components/toast.js'; import { t } from '../i18n.js'; 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.googleEnabled || config.microsoftEnabled ? `

${t('auth.divider_or')}
` : ''} ${config.googleEnabled ? `
` : ''} ${config.microsoftEnabled ? ` ` : ''}
${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(); 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')); } } // Google Sign-In if (config.googleEnabled) { document.getElementById('googleSignInBtn')?.addEventListener('click', async () => { try { // Use Google's popup-based sign in const client = google.accounts.oauth2.initTokenClient({ client_id: config.googleClientId, scope: 'email profile', callback: async (response) => { if (response.access_token) { // Get ID token via Google's tokeninfo const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${response.access_token}`); const tokenData = await tokenRes.json(); // Send to our server const res = await fetch('/api/auth/google', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credential: response.access_token, email: tokenData.email }) }); const data = await res.json(); if (res.ok) onAuthSuccess(data); else showError(data.error); } } }); client.requestAccessToken(); } catch (err) { showError(t('auth.error_google_failed')); } }); } // Microsoft Sign-In if (config.microsoftEnabled) { document.getElementById('microsoftSignInBtn')?.addEventListener('click', async () => { try { const msalConfig = { auth: { clientId: config.microsoftClientId, authority: `https://login.microsoftonline.com/${config.microsoftTenantId}`, redirectUri: window.location.origin } }; const msalInstance = new msal.PublicClientApplication(msalConfig); await msalInstance.initialize(); const loginResponse = await msalInstance.loginPopup({ scopes: ['User.Read'] }); if (loginResponse.accessToken) { const res = await fetch('/api/auth/microsoft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: loginResponse.accessToken }) }); const data = await res.json(); if (res.ok) onAuthSuccess(data); else showError(data.error); } } catch (err) { showError(t('auth.error_microsoft_failed')); } }); } } 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() {}