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,78 @@
|
||||
// =====================================================================
|
||||
// Control de acceso por rol
|
||||
// ---------------------------------------------------------------------
|
||||
// SECCIONES es la única fuente de verdad: de aquí sale el menú que ve
|
||||
// cada persona y el permiso que exige cada endpoint. No puede haber un
|
||||
// menú que muestre algo que la API niega, ni al revés.
|
||||
// =====================================================================
|
||||
import { registrar } from './bitacora.mjs';
|
||||
|
||||
export const GRUPOS = [
|
||||
{ id: 'interno', titulo: 'Uso interno' },
|
||||
];
|
||||
|
||||
export const SECCIONES = [
|
||||
{ id: 'inicio', titulo: 'Inicio', grupo: null, icono: 'inicio', permisos: ['inicio.ver'],
|
||||
resumen: 'Punto de partida' },
|
||||
|
||||
{ id: 'oferta', titulo: 'Oferta educativa', grupo: null, icono: 'birrete', permisos: ['oferta.ver'],
|
||||
resumen: 'Los 8 programas con costos, horarios y validez oficial' },
|
||||
{ id: 'requisitos', titulo: 'Requisitos y titulación', grupo: null, icono: 'documento', permisos: ['requisitos.ver'],
|
||||
resumen: 'Documentos por nivel y formas de titularse' },
|
||||
{ id: 'becas', titulo: 'Becas y fechas', grupo: null, icono: 'etiqueta', permisos: ['becas.ver'],
|
||||
resumen: 'Descuentos vigentes y fechas de inscripción' },
|
||||
{ id: 'clases', titulo: 'Links de clases', grupo: null, icono: 'video', permisos: ['clases.ver'],
|
||||
resumen: 'Los enlaces de Google Meet por programa y materia' },
|
||||
{ id: 'preguntas', titulo: 'Preguntas frecuentes', grupo: null, icono: 'libro', permisos: ['kb.ver'],
|
||||
resumen: 'La respuesta correcta a lo que más preguntan' },
|
||||
{ id: 'mensajes', titulo: 'Mensajes listos', grupo: null, icono: 'mensaje', permisos: ['guiones.ver'],
|
||||
resumen: 'Textos para copiar y enviar por WhatsApp' },
|
||||
{ id: 'contacto', titulo: 'Datos del instituto', grupo: null, icono: 'edificio', permisos: ['contacto.ver'],
|
||||
resumen: 'Dirección, teléfonos, horarios y misión' },
|
||||
|
||||
// Van bajo "Uso interno" y no bajo "Solo dirección": Equipo lo ve también
|
||||
// administración, y un encabezado que dijera lo contrario sería falso.
|
||||
{ id: 'equipo', titulo: 'Equipo', grupo: 'interno', icono: 'personas', permisos: ['equipo.ver'],
|
||||
resumen: 'Quién es quién y qué puede editar cada rol' },
|
||||
{ id: 'bitacora', titulo: 'Bitácora', grupo: 'interno', icono: 'reloj', permisos: ['bitacora.ver'],
|
||||
resumen: 'Quién entró y qué cambió' },
|
||||
];
|
||||
|
||||
/** ¿El usuario tiene al menos uno de estos permisos? */
|
||||
export function puede(req, ...claves) {
|
||||
return claves.some((c) => req.permisos?.has(c));
|
||||
}
|
||||
|
||||
/** Secciones visibles para este usuario. */
|
||||
export function navegacionDe(req) {
|
||||
return SECCIONES
|
||||
.filter((s) => puede(req, ...s.permisos))
|
||||
.map(({ id, titulo, grupo, icono, resumen }) => ({ id, titulo, grupo, icono, resumen }));
|
||||
}
|
||||
|
||||
// ---- middlewares -----------------------------------------------------
|
||||
|
||||
export function exigirSesion(req, res, next) {
|
||||
if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' });
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exige al menos uno de los permisos indicados. Un intento denegado queda
|
||||
* en la bitácora: es justo lo que dirección quiere poder auditar.
|
||||
*/
|
||||
export function exigir(...claves) {
|
||||
return async (req, res, next) => {
|
||||
if (!req.usuario) return res.status(401).json({ error: 'sin-sesion', mensaje: 'Necesitas iniciar sesión.' });
|
||||
if (puede(req, ...claves)) return next();
|
||||
await registrar(req, {
|
||||
accion: 'acceso.denegado', entidad: 'permiso', entidad_id: claves.join('|'),
|
||||
resultado: 'denegado', detalle: `${req.method} ${req.originalUrl}`,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: 'sin-permiso',
|
||||
mensaje: `Tu rol (${req.usuario.rol_nombre}) no puede hacer esto.`,
|
||||
requiere: claves,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user