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:
@@ -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();
|
||||
Reference in New Issue
Block a user