feat: version 2.15.0 -- pestaña Compartir en Equipos (acceso de solo lectura)

Admin/superadmin dan acceso de solo lectura (Resumen, Equipos, Software) a otros usuarios;
el backend valida el acceso en cada endpoint y el menu aparece solo a quien lo tenga.
Migracion 026 (device_viewers).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-20 08:32:53 -05:00
parent d5f09dc91b
commit d60b4b1d77
13 changed files with 209 additions and 29 deletions
@@ -18,3 +18,8 @@ export const postToken = asyncHandler(async (req, res) => ok(res, await service.
export const getTokenSecret = asyncHandler(async (req, res) => ok(res, await service.revealToken(Number(req.params.id), req.currentUser)));
export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser)));
export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser)));
export const getAccess = asyncHandler(async (req, res) => ok(res, await service.getAccess(req.currentUser)));
export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers()));
export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201));
export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser)));
+24
View File
@@ -46,6 +46,30 @@ export async function findTokenSecret(id) {
export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]);
export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]);
// ---------- Acceso compartido (solo lectura) ----------
export async function isViewer(userId) {
const [rows] = await pool.query('SELECT 1 FROM device_viewers WHERE user_id = ?', [userId]);
return rows.length > 0;
}
export async function listViewers() {
const [rows] = await pool.query(
`SELECT v.user_id, u.name, u.email, r.name AS role_name, v.created_at, g.name AS granted_by_name
FROM device_viewers v JOIN users u ON u.id = v.user_id JOIN roles r ON r.id = u.role_id
LEFT JOIN users g ON g.id = v.granted_by ORDER BY u.name`
);
return rows;
}
export const addViewer = (userId, grantedBy) => pool.query('INSERT IGNORE INTO device_viewers (user_id, granted_by) VALUES (?, ?)', [userId, grantedBy]);
export const removeViewer = (userId) => pool.query('DELETE FROM device_viewers WHERE user_id = ?', [userId]);
export async function findUserBrief(userId) {
const [[row]] = await pool.query('SELECT u.id, u.name, u.is_active, r.code AS role_code FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?', [userId]);
return row || null;
}
// ---------- Ingesta ----------
// Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo).
+32 -17
View File
@@ -1,28 +1,43 @@
import { Router } from 'express';
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
import { fail } from '../../utils/http.js';
import { getAccess as getAccessFor } from './service.js';
import {
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
getTokens, postToken, patchToken, deleteToken, getTokenSecret,
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer,
} from './controller.js';
const router = Router();
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente, solo admin/superadmin.
const CAN_VIEW = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
const CAN_MANAGE_TOKENS = [ROLES.SUPERADMIN, ROLES.ADMIN];
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
const CAN_EDIT = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
const CAN_MANAGE = [ROLES.SUPERADMIN, ROLES.ADMIN];
router.use(requireAuth, requireRole(...CAN_VIEW));
router.get('/dashboard', getDashboard);
router.get('/software', getSoftware);
router.get('/software/devices', getSoftwareDevices);
router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens);
router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken);
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_TOKENS), getTokenSecret);
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken);
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken);
router.get('/', getDevices);
router.get('/:id', getDevice);
router.get('/:id/raw', getRaw);
router.put('/:id', putDevice);
async function canView(req, res, next) {
try {
const access = await getAccessFor(req.currentUser);
if (!access.canView) return fail(res, 403, 'No tienes permisos para esta accion');
next();
} catch (err) { next(err); }
}
router.use(requireAuth);
router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para decidir si se muestra
router.get('/dashboard', canView, getDashboard);
router.get('/software', canView, getSoftware);
router.get('/software/devices', canView, getSoftwareDevices);
router.get('/viewers', requireRole(...CAN_MANAGE), getViewers);
router.post('/viewers', requireRole(...CAN_MANAGE), postViewer);
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE), deleteViewer);
router.get('/tokens', requireRole(...CAN_MANAGE), getTokens);
router.post('/tokens', requireRole(...CAN_MANAGE), postToken);
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE), getTokenSecret);
router.patch('/tokens/:id', requireRole(...CAN_MANAGE), patchToken);
router.delete('/tokens/:id', requireRole(...CAN_MANAGE), deleteToken);
router.get('/', canView, getDevices);
router.get('/:id', canView, getDevice);
router.get('/:id/raw', requireRole(...CAN_EDIT), getRaw);
router.put('/:id', requireRole(...CAN_EDIT), putDevice);
export default router;
+27
View File
@@ -51,6 +51,33 @@ export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() ||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
export const getDashboard = () => repo.dashboard(STALE_DAYS);
const FULL_ACCESS_ROLES = ['superadmin', 'admin', 'it_manager'];
const MANAGE_ROLES = ['superadmin', 'admin'];
// Que puede hacer este usuario en Equipos: ver (rol de TI o compartido), editar, gestionar (agentes/compartir).
export async function getAccess(user) {
const full = FULL_ACCESS_ROLES.includes(user.roleCode);
const canView = full || (await repo.isViewer(user.id));
return { canView, canEdit: full, canManage: MANAGE_ROLES.includes(user.roleCode) };
}
export const listViewers = () => repo.listViewers();
export async function addViewer(userId, actor) {
const target = await repo.findUserBrief(userId);
if (!target || !target.is_active) throw new HttpError(404, 'Usuario no encontrado');
if (FULL_ACCESS_ROLES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol');
await repo.addViewer(userId, actor.id);
await logAudit({ userId: actor.id, action: 'share', entityType: 'devices', entityId: userId, detail: { name: target.name } });
return repo.listViewers();
}
export async function removeViewer(userId, actor) {
await repo.removeViewer(userId);
await logAudit({ userId: actor.id, action: 'unshare', entityType: 'devices', entityId: userId });
return repo.listViewers();
}
export const listTokens = () => repo.listTokens();
// El token en claro se devuelve UNA sola vez (aqui); en BD queda solo el hash.