Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol

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]>
This commit is contained in:
Uriel Jareth
2026-07-29 23:20:34 -06:00
co-authored by Claude Opus 5
commit 0b9c9c1dc2
34 changed files with 8307 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
// =====================================================================
// 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();