// Autenticación (modal login/registro + estado de sesión). // Puerto TS de js/auth.js, apuntando a los nuevos endpoints FastAPI /api/auth/*. import { elId, elIdOpt } from './dom.js'; import { manejadorAsync, sinEsperar } from './async.js'; let estadoAuth: boolean | null = null; let accionPendiente: (() => void) | null = null; // Extrae un mensaje de error legible de una respuesta FastAPI ({detail: str | [...]}). function mensajeError(datos: unknown, fallback: string): string { if (datos && typeof datos === 'object' && 'detail' in datos) { const d = (datos as { detail: unknown }).detail; if (typeof d === 'string') return d; if (Array.isArray(d) && d.length && typeof d[0]?.msg === 'string') return d[0].msg; } if (datos && typeof datos === 'object' && 'error' in datos) { return String((datos as { error: unknown }).error); } return fallback; } // Verificación de sesión export async function verificarAuth(): Promise { if (estadoAuth !== null) return estadoAuth; try { const resp = await fetch('/api/auth', { credentials: 'same-origin' }); const datos = await resp.json(); estadoAuth = Boolean(datos.autenticado); if (estadoAuth) actualizarBtnUsuario(datos.nombre); } catch { estadoAuth = false; } return estadoAuth; } // Modal const modal = elId('modal-auth'); const overlay = elId('modal-auth-overlay'); const btnCerrar = elId('modal-auth-cerrar'); const tabLogin = elId('auth-tab-login'); const tabRegistro = elId('auth-tab-registro'); const panelLogin = elId('auth-panel-login'); const panelRegistro = elId('auth-panel-registro'); const formLogin = elId('form-login'); const formRegistro = elId('form-registro'); const errorLogin = elId('auth-error-login'); const errorRegistro = elId('auth-error-registro'); function abrirModal(): void { modal.hidden = false; requestAnimationFrame(() => { modal.classList.add('visible'); overlay.classList.add('activo'); }); formLogin.reset(); formRegistro.reset(); [formLogin, formRegistro].forEach((f) => f.querySelectorAll('input').forEach(limpiarCampo), ); errorLogin.textContent = ''; errorRegistro.textContent = ''; activarTab('login'); } function cerrarModal(): void { modal.classList.remove('visible'); overlay.classList.remove('activo'); modal.addEventListener('transitionend', () => { modal.hidden = true; }, { once: true }); accionPendiente = null; } function activarTab(cual: string): void { const esLogin = cual === 'login'; tabLogin.classList.toggle('activo', esLogin); tabRegistro.classList.toggle('activo', !esLogin); panelLogin.hidden = !esLogin; panelRegistro.hidden = esLogin; } export function abrirModalAuth(callbackExito?: () => void): void { accionPendiente = callbackExito ?? null; abrirModal(); } // Botón de usuario en header const btnUsuario = elIdOpt('btn-usuario'); const SVG_LOGIN = ``; const SVG_LOGOUT = ``; function actualizarBtnUsuario(nombre?: string): void { if (!btnUsuario) return; btnUsuario.innerHTML = SVG_LOGOUT; btnUsuario.append(' ', nombre ?? 'Usuario'); btnUsuario.dataset.tooltip = 'Cerrar sesión'; } function resetearBtnUsuario(): void { if (!btnUsuario) return; btnUsuario.innerHTML = `${SVG_LOGIN} Login`; btnUsuario.dataset.tooltip = 'Iniciar sesión'; } // Validación en tiempo real function esEmailValido(v: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()); } function marcarCampo(input: HTMLInputElement, valido: boolean): void { input.classList.toggle('campo-valido', valido); input.classList.toggle('campo-invalido', !valido); } function limpiarCampo(input: HTMLInputElement): void { input.classList.remove('campo-valido', 'campo-invalido'); } function activarValidacionCampo(input: HTMLInputElement, reglaDeFalso: () => boolean): void { let tocado = false; input.addEventListener('blur', () => { tocado = true; marcarCampo(input, !reglaDeFalso()); }); input.addEventListener('input', () => { if (tocado) marcarCampo(input, !reglaDeFalso()); }); } function q(form: HTMLFormElement, name: string): HTMLInputElement { return form.querySelector(`[name="${name}"]`)!; } function inicializarValidacionLogin(): void { const email = q(formLogin, 'email'); const password = q(formLogin, 'password'); activarValidacionCampo(email, () => !esEmailValido(email.value)); activarValidacionCampo(password, () => password.value.length < 1); } function inicializarValidacionRegistro(): void { const nombre = q(formRegistro, 'nombre'); const apellido = q(formRegistro, 'apellido'); const email = q(formRegistro, 'email'); const password = q(formRegistro, 'password'); const password2 = q(formRegistro, 'password2'); activarValidacionCampo(nombre, () => nombre.value.trim().length < 1); activarValidacionCampo(apellido, () => apellido.value.trim().length < 1); activarValidacionCampo(email, () => !esEmailValido(email.value)); activarValidacionCampo(password, () => password.value.length < 8); let tocadoP2 = false; const validarP2 = () => password.value === password2.value && password2.value.length > 0; password2.addEventListener('blur', () => { tocadoP2 = true; marcarCampo(password2, validarP2()); }); password2.addEventListener('input', () => { if (tocadoP2) marcarCampo(password2, validarP2()); }); password.addEventListener('input', () => { if (tocadoP2) marcarCampo(password2, validarP2()); }); } inicializarValidacionLogin(); inicializarValidacionRegistro(); // Manejo de formularios async function enviarAuth(ruta: string, campos: Record): Promise<{ ok?: boolean; nombre?: string; _error?: string }> { try { const resp = await fetch(`/api/auth/${ruta}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos), }); const datos = await resp.json().catch(() => ({})); if (!resp.ok) return { _error: mensajeError(datos, `Error ${resp.status}`) }; return datos; } catch { return { _error: 'Error de conexión. Verifica tu red.' }; } } formLogin.addEventListener('submit', manejadorAsync('Inicio de sesión', async (e: Event) => { e.preventDefault(); errorLogin.textContent = ''; const btn = formLogin.querySelector('button[type="submit"]')!; btn.disabled = true; btn.textContent = 'Ingresando…'; try { const datos = await enviarAuth('login', { email: q(formLogin, 'email').value, password: q(formLogin, 'password').value, }); if (datos._error) { errorLogin.textContent = datos._error; return; } estadoAuth = true; actualizarBtnUsuario(datos.nombre); cerrarModal(); accionPendiente?.(); accionPendiente = null; } finally { // En finally: si algo lanza, el botón no puede quedarse deshabilitado para siempre. btn.disabled = false; btn.textContent = 'Ingresar'; } })); formRegistro.addEventListener('submit', manejadorAsync('Registro', async (e: Event) => { e.preventDefault(); errorRegistro.textContent = ''; const btn = formRegistro.querySelector('button[type="submit"]')!; btn.disabled = true; btn.textContent = 'Registrando…'; try { const password = q(formRegistro, 'password').value; const password2 = q(formRegistro, 'password2').value; const avisoLegal = formRegistro.querySelector('[name="aviso-legal"]'); if (avisoLegal && !avisoLegal.checked) { errorRegistro.textContent = 'Debes aceptar el aviso antes de crear una cuenta.'; return; } if (password !== password2) { errorRegistro.textContent = 'Las contraseñas no coinciden.'; return; } const datos = await enviarAuth('registro', { nombre: q(formRegistro, 'nombre').value, apellido: q(formRegistro, 'apellido').value, email: q(formRegistro, 'email').value, password, }); if (datos._error) { errorRegistro.textContent = datos._error; return; } estadoAuth = true; actualizarBtnUsuario(datos.nombre); cerrarModal(); accionPendiente?.(); accionPendiente = null; } finally { // Un único punto de restauración del botón: antes se repetía en cada rama de salida y no // cubría el caso de excepción. btn.disabled = false; btn.textContent = 'Crear cuenta'; } })); // Eventos de UI tabLogin.addEventListener('click', () => activarTab('login')); tabRegistro.addEventListener('click', () => activarTab('registro')); btnCerrar.addEventListener('click', cerrarModal); overlay.addEventListener('click', cerrarModal); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !modal.hidden) cerrarModal(); }); btnUsuario?.addEventListener('click', manejadorAsync('Sesión', async () => { const autenticado = await verificarAuth(); if (!autenticado) { abrirModal(); } else { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); estadoAuth = false; resetearBtnUsuario(); } })); // Comprobación de sesión al cargar: no hay nada que esperar, pero un fallo debe verse. sinEsperar('Comprobación de sesión', verificarAuth());