212 lines
6.4 KiB
JavaScript
212 lines
6.4 KiB
JavaScript
// 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();
|