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,211 @@
|
||||
// =====================================================================
|
||||
// Shell del portal: sesión, menú según permisos y ruteo por hash.
|
||||
// El menú se pinta con lo que el servidor autoriza, no con una lista
|
||||
// escrita a mano: así no puede quedar desfasado de la API.
|
||||
// =====================================================================
|
||||
import { api, esc, icono, ficha, escalonar, activarCopiado, cargando } from './ui.mjs';
|
||||
import * as V from './vistas.mjs';
|
||||
|
||||
const VISTAS = {
|
||||
inicio: V.inicio,
|
||||
oferta: V.oferta,
|
||||
requisitos: V.requisitos,
|
||||
becas: V.becas,
|
||||
clases: V.clases,
|
||||
preguntas: V.preguntas,
|
||||
mensajes: V.mensajes,
|
||||
contacto: V.contacto,
|
||||
equipo: V.equipo,
|
||||
bitacora: V.bitacora,
|
||||
};
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
// El contenedor de la vista se reemplaza por uno nuevo en cada render.
|
||||
// Si se reusara el mismo nodo, cada montar() volvería a registrar sus
|
||||
// listeners sobre él y al recargar una sección se acumularían: un clic en
|
||||
// "Editar" abriría tantas ventanas como veces se hubiera recargado.
|
||||
let vista = $('#vista');
|
||||
|
||||
function lienzoNuevo() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lienzo__interno';
|
||||
el.id = 'vista';
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
vista.replaceWith(el);
|
||||
vista = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
// Qué bloques plegables dejó abiertos la persona. Sin esto, al guardar un
|
||||
// cambio la sección se repinta y el bloque en el que estaba trabajando se
|
||||
// vuelve a plegar, así que pierde su lugar.
|
||||
const plegablesAbiertos = new Map();
|
||||
|
||||
function recordarPlegables(caja) {
|
||||
caja.querySelectorAll('details.plegable[data-plegable]').forEach((d) => {
|
||||
const guardado = plegablesAbiertos.get(d.dataset.plegable);
|
||||
if (guardado !== undefined) d.open = guardado;
|
||||
});
|
||||
// `toggle` no burbujea, pero un listener en captura sí lo recibe.
|
||||
caja.addEventListener('toggle', (ev) => {
|
||||
const d = ev.target.closest?.('details.plegable[data-plegable]');
|
||||
if (d) plegablesAbiertos.set(d.dataset.plegable, d.open);
|
||||
}, true);
|
||||
}
|
||||
|
||||
// ---- sesión ----------------------------------------------------------
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
const yo = await api('/auth/yo');
|
||||
ctx = {
|
||||
usuario: yo.usuario,
|
||||
permisos: new Set(yo.permisos),
|
||||
navegacion: yo.navegacion,
|
||||
grupos: yo.grupos,
|
||||
recargar: () => pintar(actual, { forzar: true }),
|
||||
};
|
||||
} catch {
|
||||
location.href = '/';
|
||||
throw new Error('sin sesión');
|
||||
}
|
||||
|
||||
// ---- menú ------------------------------------------------------------
|
||||
|
||||
function botonNav(s) {
|
||||
return `<button class="nav-item" type="button" data-seccion="${esc(s.id)}" title="${esc(s.resumen)}">
|
||||
${icono(s.icono)}<span class="crece recorte">${esc(s.titulo)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function pintarMenu() {
|
||||
const sueltas = ctx.navegacion.filter((s) => !s.grupo);
|
||||
const bloques = ctx.grupos
|
||||
.map((g) => ({ ...g, items: ctx.navegacion.filter((s) => s.grupo === g.id) }))
|
||||
.filter((g) => g.items.length);
|
||||
|
||||
$('#nav').innerHTML = [
|
||||
sueltas.map(botonNav).join(''),
|
||||
...bloques.map((g) => `<div class="barra__grupo"><span class="ceja">${esc(g.titulo)}</span></div>${g.items.map(botonNav).join('')}`),
|
||||
].join('');
|
||||
|
||||
$('#pie-barra').innerHTML = `
|
||||
<div class="usuario" style="--rol:${esc(ctx.usuario.rol_color)}">
|
||||
${ficha(ctx.usuario.iniciales, ctx.usuario.rol_color)}
|
||||
<span class="usuario__txt">
|
||||
<b class="recorte">${esc(ctx.usuario.nombre)}</b>
|
||||
<span>${esc(ctx.usuario.rol_nombre)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="nav-item" id="salir" type="button">${icono('salir')}<span>Cerrar sesión</span></button>`;
|
||||
|
||||
$('#ficha-movil').innerHTML = ficha(ctx.usuario.iniciales, ctx.usuario.rol_color);
|
||||
|
||||
$('#salir').addEventListener('click', async () => {
|
||||
await api('/auth/salir', { method: 'POST' }).catch(() => {});
|
||||
location.href = '/';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ruteo -----------------------------------------------------------
|
||||
|
||||
let actual = null;
|
||||
// Cada render lleva su número. Si la persona cambia de sección mientras una
|
||||
// carga, el render viejo se descarta al llegar en lugar de bloquear el menú:
|
||||
// antes, con una bandera de "ocupado", el clic se ignoraba y el menú parecía
|
||||
// muerto hasta ir a otra sección y volver.
|
||||
let renderActual = 0;
|
||||
|
||||
function seccionValida(id) {
|
||||
return ctx.navegacion.some((s) => s.id === id) && VISTAS[id];
|
||||
}
|
||||
|
||||
async function pintar(id, { forzar = false } = {}) {
|
||||
if (!seccionValida(id)) id = ctx.navegacion[0]?.id ?? 'inicio';
|
||||
if (id === actual && !forzar) return;
|
||||
const miRender = ++renderActual;
|
||||
actual = id;
|
||||
|
||||
document.querySelectorAll('.nav-item[data-seccion]').forEach((b) => {
|
||||
if (b.dataset.seccion === id) b.setAttribute('aria-current', 'page');
|
||||
else b.removeAttribute('aria-current');
|
||||
});
|
||||
cerrarCajon();
|
||||
|
||||
lienzoNuevo().innerHTML = cargando(3, 120);
|
||||
try {
|
||||
const v = await VISTAS[id](ctx);
|
||||
if (miRender !== renderActual) return; // llegó tarde: ya se pidió otra sección
|
||||
const caja = lienzoNuevo();
|
||||
caja.innerHTML = `
|
||||
<header class="cabecera-seccion">
|
||||
<span class="ceja">${esc(v.ceja ?? '')}</span>
|
||||
<h1>${esc(v.titulo)}</h1>
|
||||
${v.resumen ? `<p>${esc(v.resumen)}</p>` : ''}
|
||||
${v.acciones ? `<div class="cabecera-seccion__acciones">${v.acciones}</div>` : ''}
|
||||
</header>
|
||||
${v.cuerpo}`;
|
||||
const contenido = caja.querySelector('.pila, .rejilla');
|
||||
if (contenido) escalonar(contenido);
|
||||
recordarPlegables(caja);
|
||||
v.montar?.(caja);
|
||||
document.title = `${v.titulo} · Portal INSTA`;
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
} catch (err) {
|
||||
if (miRender !== renderActual) return;
|
||||
vista.innerHTML = `
|
||||
<header class="cabecera-seccion"><span class="ceja">Algo falló</span><h1>No se pudo abrir esta sección</h1></header>
|
||||
<div class="aviso aviso--error">
|
||||
${esc(err.message)}
|
||||
${err.estado === 403 ? '<br>Tu rol no tiene acceso a esto. Si lo necesitas, pídeselo a dirección.' : ''}
|
||||
</div>
|
||||
<div style="margin-top:16px"><button class="boton boton--fantasma" type="button" data-ir="inicio">Volver al inicio</button></div>`;
|
||||
if (err.estado === 401) location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
const nav = ev.target.closest('.nav-item[data-seccion]');
|
||||
if (nav) { location.hash = `#/${nav.dataset.seccion}`; return; }
|
||||
const ir = ev.target.closest('[data-ir]');
|
||||
if (ir) { location.hash = `#/${ir.dataset.ir}`; }
|
||||
});
|
||||
|
||||
addEventListener('hashchange', () => pintar(location.hash.replace(/^#\/?/, '') || 'inicio'));
|
||||
|
||||
// ---- cajón móvil -----------------------------------------------------
|
||||
|
||||
function abrirCajon() {
|
||||
$('#barra').dataset.abierta = '1';
|
||||
$('#abrir-menu').setAttribute('aria-expanded', 'true');
|
||||
if (!document.querySelector('.velo')) {
|
||||
const velo = document.createElement('button');
|
||||
velo.className = 'velo';
|
||||
velo.type = 'button';
|
||||
velo.setAttribute('aria-label', 'Cerrar el menú');
|
||||
velo.addEventListener('click', cerrarCajon);
|
||||
document.body.appendChild(velo);
|
||||
}
|
||||
}
|
||||
|
||||
function cerrarCajon() {
|
||||
$('#barra').dataset.abierta = '0';
|
||||
$('#abrir-menu')?.setAttribute('aria-expanded', 'false');
|
||||
document.querySelector('.velo')?.remove();
|
||||
}
|
||||
|
||||
$('#abrir-menu').innerHTML = icono('menu');
|
||||
$('#abrir-menu').addEventListener('click', () => {
|
||||
($('#barra').dataset.abierta === '1' ? cerrarCajon : abrirCajon)();
|
||||
});
|
||||
// Escape cierra el cajón, pero no si hay una ventana de edición abierta.
|
||||
addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape' && !document.querySelector('dialog.editor[open]')) cerrarCajon();
|
||||
});
|
||||
|
||||
// ---- arranque --------------------------------------------------------
|
||||
|
||||
pintarMenu();
|
||||
activarCopiado();
|
||||
await pintar(location.hash.replace(/^#\/?/, '') || 'inicio');
|
||||
Reference in New Issue
Block a user