// public/assets/login.js /* English comments: login + MFA flow */ const qs = (id) => document.getElementById(id); const i18n = window.I18N || {}; const t = (key, fallback) => { if (key && Object.prototype.hasOwnProperty.call(i18n, key)) { const val = i18n[key]; return val !== '' ? val : fallback; } return fallback; }; const errorKeyByMessage = { 'Email and password required': 'auth.error.email_password_required', 'Invalid credentials': 'auth.error.invalid_credentials', 'Email required': 'auth.error.email_required', 'Request failed': 'auth.error.request_failed', 'Code required': 'auth.error.code_required', 'Invalid code': 'auth.error.invalid_code', }; function normalizeErrorMessage(message, fallbackKey, fallbackText) { if (message && Object.prototype.hasOwnProperty.call(errorKeyByMessage, message)) { return t(errorKeyByMessage[message], message); } return message || t(fallbackKey, fallbackText); } function showError(msg) { const el = qs('loginError'); if (!el) return; el.textContent = msg; el.classList.remove('is-hidden'); } function clearError() { if (retryTimer) { clearInterval(retryTimer); retryTimer = null; } const el = qs('loginError'); if (!el) return; el.textContent = ''; el.classList.add('is-hidden'); } let retryTimer = null; let retryUntilTs = 0; let retryShown = null; function formatRetryMessage(secondsLeft) { return t('auth.error.too_many_attempts', 'Too many attempts. Try again in {seconds}s.') .replace('{seconds}', String(secondsLeft)); } function showRetryError(seconds) { const el = qs('loginError'); if (!el) return; const now = Date.now(); retryUntilTs = now + Math.max(0, seconds) * 1000; retryShown = null; el.textContent = formatRetryMessage(Math.max(0, Math.ceil((retryUntilTs - now) / 1000))); el.classList.remove('is-hidden'); if (retryTimer) { clearInterval(retryTimer); } retryTimer = setInterval(() => { const remaining = Math.max(0, Math.ceil((retryUntilTs - Date.now()) / 1000)); if (retryShown === null) { retryShown = remaining; } else if (retryShown > remaining) { // Smoothly converge if timer drifts or tab throttles. retryShown = Math.max(remaining, retryShown - 1); } else { retryShown = remaining; } el.textContent = formatRetryMessage(retryShown); if (remaining <= 0) { clearInterval(retryTimer); retryTimer = null; } }, 250); } async function login() { clearError(); const email = qs('loginEmail')?.value || ''; const password = qs('loginPassword')?.value || ''; const remember = !!qs('loginRemember')?.checked; if (!email || !password) { showError(t('auth.error.email_password_required', 'Email and password required')); return; } const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, client_type: 'web' }), }); const data = await res.json(); if (!data?.ok) { const retry = data?.error?.retry_after; if (retry) { showRetryError(retry); } else { const msg = normalizeErrorMessage(data?.error?.message || '', 'auth.error.login_failed', 'Login failed'); showError(msg); } return; } if (data?.data?.mfa_required) { window.__mfaChallenge = data?.data?.challenge_token || ''; window.__rememberSession = remember; qs('loginForm')?.classList.add('is-hidden'); qs('mfaForm')?.classList.remove('is-hidden'); return; } window.Auth.setTokens(data?.data || {}, 'web', remember); window.location.href = '/'; } function showForgot() { qs('loginForm')?.classList.add('is-hidden'); qs('mfaForm')?.classList.add('is-hidden'); qs('forgotForm')?.classList.remove('is-hidden'); } function showLoginForm() { qs('forgotForm')?.classList.add('is-hidden'); qs('mfaForm')?.classList.add('is-hidden'); qs('loginForm')?.classList.remove('is-hidden'); } async function sendForgot() { clearError(); const email = qs('forgotEmail')?.value || ''; if (!email) { showError(t('auth.error.email_required', 'Email required')); return; } const res = await fetch('/api/auth/password/forgot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const data = await res.json().catch(() => null); if (!data?.ok) { showError(normalizeErrorMessage(data?.error?.message || '', 'auth.error.request_failed', 'Request failed')); return; } showError(t('auth.error.reset_sent', 'If this email exists, a reset link will be sent.')); } async function verifyMfa() { clearError(); const code = qs('mfaCode')?.value || ''; const challenge = window.__mfaChallenge || ''; const remember = window.__rememberSession !== false; if (!code || !challenge) { showError(t('auth.error.code_required', 'Code required')); return; } const res = await fetch('/api/auth/2fa/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge_token: challenge, code, client_type: 'web' }), }); const data = await res.json(); if (!data?.ok) { const retry = data?.error?.retry_after; if (retry) { showRetryError(retry); } else { const msg = normalizeErrorMessage(data?.error?.message || '', 'auth.error.invalid_code', 'Invalid code'); showError(msg); } return; } window.Auth.setTokens(data?.data || {}, 'web', remember); window.location.href = '/'; } function init() { const access = window.Auth?.getAccessToken?.() || ''; const refresh = window.Auth?.getRefreshToken?.() || ''; if (access && refresh && window.Auth?.refreshTokens) { window.Auth.refreshTokens().then((ok) => { if (ok) { window.location.href = '/'; } else if (window.Auth?.clearTokens) { window.Auth.clearTokens(); } }); return; } if (access && window.Auth?.clearTokens) { window.Auth.clearTokens(); } const loginBtn = qs('btnLogin'); if (loginBtn) loginBtn.addEventListener('click', login); const forgotBtn = qs('btnForgot'); if (forgotBtn) forgotBtn.addEventListener('click', showForgot); const forgotSend = qs('btnForgotSend'); if (forgotSend) forgotSend.addEventListener('click', sendForgot); const forgotBack = qs('btnForgotBack'); if (forgotBack) forgotBack.addEventListener('click', showLoginForm); const verifyBtn = qs('btnMfaVerify'); if (verifyBtn) verifyBtn.addEventListener('click', verifyMfa); } init();