Base de conocimiento para el personal del Instituto Santiago Tapia: oferta educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas de inscripcion, links de clases de Google Meet, preguntas frecuentes, mensajes listos y datos del instituto. Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos canjes simultaneos del mismo enlace no pueden crear dos sesiones. Tres roles con 19 permisos. El catalogo lo edita direccion; los links de clases, direccion y administracion; el resto del equipo consulta. El menu se pinta con lo que el servidor autoriza y cada endpoint exige la misma clave, asi que no hay pantallas alcanzables escribiendo la direccion a mano. Toda edicion pasa por una lista blanca de campos validados y queda en la bitacora. Un precio vacio no se guarda como cero, un link que no es una direccion se rechaza, y no se puede marcar una validez oficial como confirmada sin numero de acuerdo. Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de 44 px, tema claro y bloques plegados en telefono. La oferta, los costos, las becas, los requisitos y el contacto son los reales del instituto, tomados del CSV de oferta educativa y de la base de conocimiento. Los usuarios de acceso y los links de Meet son de demostracion. Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y iPhone 13. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
90 lines
3.3 KiB
JavaScript
90 lines
3.3 KiB
JavaScript
// =====================================================================
|
|
// Pantalla de entrada · Magic Login
|
|
// Dos caminos al mismo sitio: acceso rápido por rol (demo) o correo.
|
|
// =====================================================================
|
|
import { api, esc, ficha, brindis, activarCopiado } from './ui.mjs';
|
|
|
|
const $ = (s) => document.querySelector(s);
|
|
|
|
const MOTIVOS = {
|
|
invalido: 'Ese enlace no es válido. Genera uno nuevo.',
|
|
'ya-usado': 'Ese enlace ya se usó. Los enlaces de acceso sirven una sola vez.',
|
|
vencido: 'El enlace venció. Genera uno nuevo.',
|
|
'usuario-inactivo': 'Tu cuenta está desactivada. Habla con dirección.',
|
|
};
|
|
|
|
function mostrarError(texto) {
|
|
const caja = $('#error-entrada');
|
|
caja.hidden = false;
|
|
caja.className = 'aviso aviso--error';
|
|
caja.textContent = texto;
|
|
}
|
|
|
|
// Si venimos rebotados de /acceso/:token con un problema, decirlo aquí.
|
|
const motivo = new URLSearchParams(location.search).get('error');
|
|
if (motivo) mostrarError(MOTIVOS[motivo] ?? 'No se pudo validar el enlace.');
|
|
|
|
/** Genera el enlace y lo canjea de inmediato: es el flujo real, sin el correo. */
|
|
async function entrar(email, boton) {
|
|
const previo = boton?.textContent;
|
|
if (boton) { boton.disabled = true; boton.textContent = 'Generando enlace…'; }
|
|
$('#error-entrada').hidden = true;
|
|
$('#resultado').innerHTML = '';
|
|
|
|
try {
|
|
const r = await api('/auth/enlace', { method: 'POST', body: { email } });
|
|
|
|
$('#resultado').innerHTML = `
|
|
<div class="aviso aviso--ok enlace-magico">
|
|
<strong>Enlace listo para ${esc(r.usuario.nombre)}</strong> · ${esc(r.usuario.rol_nombre)}.
|
|
Vence en ${esc(r.expiraEnMinutos)} minutos y sirve una sola vez.
|
|
${r.enlace ? `<code>${esc(location.origin + r.enlace)}</code>` : ''}
|
|
</div>`;
|
|
|
|
if (!r.token) {
|
|
brindis('Enlace enviado a tu correo');
|
|
return;
|
|
}
|
|
// En producción esto lo haría la persona al abrir su correo.
|
|
await api('/auth/consumir', { method: 'POST', body: { token: r.token } });
|
|
location.href = '/portal';
|
|
} catch (err) {
|
|
mostrarError(err.message);
|
|
if (boton) { boton.disabled = false; boton.textContent = previo; }
|
|
}
|
|
}
|
|
|
|
// ---- accesos rápidos por rol ----------------------------------------
|
|
|
|
try {
|
|
const { usuarios, vigenciaMinutos } = await api('/auth/demo');
|
|
$('#vigencia').textContent = vigenciaMinutos;
|
|
$('#roles').innerHTML = usuarios.map((u) => `
|
|
<button class="tarjeta-rol" type="button" data-email="${esc(u.email)}" style="--rol:${esc(u.rol_color)}">
|
|
${ficha(u.iniciales, u.rol_color)}
|
|
<span class="tarjeta-rol__txt">
|
|
<b>${esc(u.rol_nombre)}</b>
|
|
<span>${esc(u.nombre)} · ${esc(u.puesto)}</span>
|
|
</span>
|
|
</button>`).join('');
|
|
|
|
$('#roles').addEventListener('click', (ev) => {
|
|
const btn = ev.target.closest('.tarjeta-rol');
|
|
if (!btn) return;
|
|
entrar(btn.dataset.email, btn);
|
|
});
|
|
} catch {
|
|
$('#roles').innerHTML = `<div class="aviso aviso--error">
|
|
No se pudo hablar con el servidor. Revisa que esté corriendo <code>npm start</code>
|
|
y que el contenedor de MySQL esté arriba.</div>`;
|
|
}
|
|
|
|
$('#forma').addEventListener('submit', (ev) => {
|
|
ev.preventDefault();
|
|
const email = $('#email').value.trim();
|
|
if (!email) return mostrarError('Escribe tu correo institucional.');
|
|
entrar(email, $('#enviar'));
|
|
});
|
|
|
|
activarCopiado();
|