import { showToast } from '../components/toast.js';
import { t } from '../i18n.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
? ` `
: `
`;
container.innerHTML = `
${logoHtml}
${brandEsc(brandName)}
${isSetup ? t('auth.subtitle_setup') : t('auth.subtitle_signin')}
${!isSetup && canRegister ? `
${t('auth.trial_notice')}
` : ''}
✉️
${t('auth.verify_title')}
${t('auth.verify_body')}
${t('auth.verify_resend')}
${t('auth.back_to_signin')}
${t('auth.support_access')}
${t('auth.support_authenticate')}
${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'));
}
}
/*
* 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.
*/
const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || ''));
const ssoToken = ssoParams.get('sso_token');
const ssoError = ssoParams.get('sso_error');
if (ssoToken || ssoError) {
history.replaceState(null, '', window.location.pathname + '#/login');
}
if (ssoToken) {
try {
const meRes = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${ssoToken}` } });
if (!meRes.ok) throw new Error('session rejected');
const me = await meRes.json();
onAuthSuccess({ token: ssoToken, user: me.user || me });
} 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'];
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() {}