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:
@@ -124,6 +124,9 @@
|
|||||||
red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes }`.
|
red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes }`.
|
||||||
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
|
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
|
||||||
`GET /api/devices/software/devices?name=` — software transversal.
|
`GET /api/devices/software/devices?name=` — software transversal.
|
||||||
|
- `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de
|
||||||
|
dashboard/listado/detalle/software) también para usuarios con acceso compartido.
|
||||||
|
- `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin).
|
||||||
- `GET /api/devices/:id/raw` — último inventario crudo del agente.
|
- `GET /api/devices/:id/raw` — último inventario crudo del agente.
|
||||||
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
|
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
|
||||||
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
|
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
| `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` |
|
| `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` |
|
||||||
| `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte |
|
| `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte |
|
||||||
| `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) |
|
| `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) |
|
||||||
|
| `device_viewers` | usuarios con acceso de solo lectura compartido al menú Equipos (`user_id`, `granted_by`) |
|
||||||
| `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) |
|
| `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) |
|
||||||
|
|
||||||
## Relaciones clave
|
## Relaciones clave
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
- Tokens de ingesta recuperables: se guardan también cifrados (AES-256-GCM, migración `025`) y cada
|
- Tokens de ingesta recuperables: se guardan también cifrados (AES-256-GCM, migración `025`) y cada
|
||||||
token de la lista tiene un botón para descargar el instalador sin volver a pegarlo (queda en
|
token de la lista tiene un botón para descargar el instalador sin volver a pegarlo (queda en
|
||||||
auditoría; los tokens creados antes de esta versión no son recuperables).
|
auditoría; los tokens creados antes de esta versión no son recuperables).
|
||||||
|
- Equipos → Compartir (solo admin/superadmin): da acceso de **solo lectura** (Resumen, Equipos y
|
||||||
|
Software) a usuarios que por su rol no ven el menú. Migración `026` (`device_viewers`). Sin
|
||||||
|
editar, sin Agentes, sin inventario crudo. El menú Equipos aparece solo a quien tenga acceso.
|
||||||
- Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en
|
- Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en
|
||||||
`docs/ARQUITECTURA-INVENTARIO.md`.
|
`docs/ARQUITECTURA-INVENTARIO.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
|||||||
servidor GLPI). Diseño en `docs/ARQUITECTURA-INVENTARIO.md`.
|
servidor GLPI). Diseño en `docs/ARQUITECTURA-INVENTARIO.md`.
|
||||||
- Pestañas: Equipos (KPIs + listado + detalle General/Hardware/Red/Software/Historial),
|
- Pestañas: Equipos (KPIs + listado + detalle General/Hardware/Red/Software/Historial),
|
||||||
Software (transversal: en cuántos equipos está instalado) y Agentes (tokens de ingesta,
|
Software (transversal: en cuántos equipos está instalado) y Agentes (tokens de ingesta,
|
||||||
solo `superadmin`/`admin`).
|
solo `superadmin`/`admin`) y Compartir (da acceso de solo lectura a otros usuarios, `superadmin`/`admin`).
|
||||||
- Edición manual solo de: estado (activo/de baja), usuario asignado, departamento y notas; el
|
- Edición manual solo de: estado (activo/de baja), usuario asignado, departamento y notas; el
|
||||||
resto lo manda el agente. Historial de cambios entre reportes.
|
resto lo manda el agente. Historial de cambios entre reportes.
|
||||||
- Fuera de este módulo (fase 2): tickets/helpdesk, despliegue remoto de software, panel de
|
- Fuera de este módulo (fase 2): tickets/helpdesk, despliegue remoto de software, panel de
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Acceso de solo lectura al menu Equipos para usuarios que por su rol no lo ven (compartir el modulo).
|
||||||
|
-- Lo gestionan admin/superadmin. Idempotente.
|
||||||
|
CREATE TABLE IF NOT EXISTS device_viewers (
|
||||||
|
user_id INT NOT NULL PRIMARY KEY,
|
||||||
|
granted_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_device_viewers_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_device_viewers_granter FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -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 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 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 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)));
|
||||||
|
|||||||
@@ -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 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]);
|
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 ----------
|
// ---------- Ingesta ----------
|
||||||
|
|
||||||
// Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo).
|
// Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo).
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
||||||
|
import { fail } from '../../utils/http.js';
|
||||||
|
import { getAccess as getAccessFor } from './service.js';
|
||||||
import {
|
import {
|
||||||
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
||||||
getTokens, postToken, patchToken, deleteToken, getTokenSecret,
|
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente, solo admin/superadmin.
|
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
|
||||||
const CAN_VIEW = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
|
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
|
||||||
const CAN_MANAGE_TOKENS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
const CAN_EDIT = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
|
||||||
|
const CAN_MANAGE = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||||
|
|
||||||
router.use(requireAuth, requireRole(...CAN_VIEW));
|
async function canView(req, res, next) {
|
||||||
router.get('/dashboard', getDashboard);
|
try {
|
||||||
router.get('/software', getSoftware);
|
const access = await getAccessFor(req.currentUser);
|
||||||
router.get('/software/devices', getSoftwareDevices);
|
if (!access.canView) return fail(res, 403, 'No tienes permisos para esta accion');
|
||||||
router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens);
|
next();
|
||||||
router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken);
|
} catch (err) { next(err); }
|
||||||
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.use(requireAuth);
|
||||||
router.get('/', getDevices);
|
router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para decidir si se muestra
|
||||||
router.get('/:id', getDevice);
|
router.get('/dashboard', canView, getDashboard);
|
||||||
router.get('/:id/raw', getRaw);
|
router.get('/software', canView, getSoftware);
|
||||||
router.put('/:id', putDevice);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -51,6 +51,33 @@ export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() ||
|
|||||||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
||||||
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
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();
|
export const listTokens = () => repo.listTokens();
|
||||||
|
|
||||||
// El token en claro se devuelve UNA sola vez (aqui); en BD queda solo el hash.
|
// El token en claro se devuelve UNA sola vez (aqui); en BD queda solo el hash.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
|
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
import { useAuth } from '../context/auth-context.jsx';
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
import { useBranding } from '../context/branding-context.jsx';
|
import { useBranding } from '../context/branding-context.jsx';
|
||||||
import logoIcon from '../assets/logoLinkedTrackIcon.png';
|
import logoIcon from '../assets/logoLinkedTrackIcon.png';
|
||||||
|
import { getDevicesAccess } from '../services/devices.js';
|
||||||
import pkg from '../../package.json';
|
import pkg from '../../package.json';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
@@ -12,7 +14,7 @@ const ITEMS = [
|
|||||||
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
||||||
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
|
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
|
||||||
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
|
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
|
||||||
{ to: '/equipos', icon: Monitor, labelKey: 'nav_devices', roles: ['superadmin', 'admin', 'it_manager'] },
|
{ to: '/equipos', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true },
|
||||||
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
|
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
|
||||||
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
||||||
];
|
];
|
||||||
@@ -24,7 +26,13 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
|
|||||||
// En celular el drawer siempre se ve completo (con etiquetas), sin importar el modo
|
// En celular el drawer siempre se ve completo (con etiquetas), sin importar el modo
|
||||||
// compacto/expandido del riel de escritorio -- son dos cosas independientes.
|
// compacto/expandido del riel de escritorio -- son dos cosas independientes.
|
||||||
const compact = mode === 'compact' && !mobileOpen;
|
const compact = mode === 'compact' && !mobileOpen;
|
||||||
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.roles || item.roles.includes(user?.roleCode)));
|
// Equipos: lo ven roles de TI o quien tenga acceso compartido (lo decide el backend).
|
||||||
|
const [canSeeDevices, setCanSeeDevices] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
getDevicesAccess().then((a) => setCanSeeDevices(!!a?.canView)).catch(() => setCanSeeDevices(false));
|
||||||
|
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices));
|
||||||
const brandName = branding.name || t('appName');
|
const brandName = branding.name || t('appName');
|
||||||
return (
|
return (
|
||||||
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
|
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
|
||||||
|
|||||||
@@ -199,6 +199,17 @@ export const messages = {
|
|||||||
eq_installer_tag: 'Etiqueta opcional (ej. Contabilidad)',
|
eq_installer_tag: 'Etiqueta opcional (ej. Contabilidad)',
|
||||||
eq_installer_download: 'Descargar instalador',
|
eq_installer_download: 'Descargar instalador',
|
||||||
eq_installer_note: 'El .bat contiene el token: compártelo solo con quien instale. Descarga el agente oficial de GitHub; si el equipo no tiene internet, copia el .msi junto al .bat.',
|
eq_installer_note: 'El .bat contiene el token: compártelo solo con quien instale. Descarga el agente oficial de GitHub; si el equipo no tiene internet, copia el .msi junto al .bat.',
|
||||||
|
eq_tab_share: 'Compartir',
|
||||||
|
eq_share_title: 'Compartir el menú Equipos',
|
||||||
|
eq_share_hint: 'Da acceso de solo lectura (Resumen, Equipos y Software) a usuarios que por su rol no lo ven. No podrán editar, ver agentes ni compartir.',
|
||||||
|
eq_share_pick: 'Elegir usuario...',
|
||||||
|
eq_share_add: 'Dar acceso',
|
||||||
|
eq_share_empty: 'Aún no has compartido Equipos con nadie.',
|
||||||
|
eq_share_role: 'Rol',
|
||||||
|
eq_share_since: 'Acceso desde',
|
||||||
|
eq_share_remove: 'Quitar acceso',
|
||||||
|
eq_share_remove_title: 'Quitar acceso a Equipos',
|
||||||
|
eq_share_remove_msg: 'dejará de ver el menú Equipos.',
|
||||||
eq_tab_summary: 'Resumen',
|
eq_tab_summary: 'Resumen',
|
||||||
eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
|
eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
|
||||||
eq_btn_info: 'Información',
|
eq_btn_info: 'Información',
|
||||||
@@ -663,6 +674,17 @@ export const messages = {
|
|||||||
eq_installer_tag: 'Optional tag (e.g. Accounting)',
|
eq_installer_tag: 'Optional tag (e.g. Accounting)',
|
||||||
eq_installer_download: 'Download installer',
|
eq_installer_download: 'Download installer',
|
||||||
eq_installer_note: 'The .bat contains the token: share it only with whoever installs. It downloads the official agent from GitHub; if the machine has no internet, put the .msi next to the .bat.',
|
eq_installer_note: 'The .bat contains the token: share it only with whoever installs. It downloads the official agent from GitHub; if the machine has no internet, put the .msi next to the .bat.',
|
||||||
|
eq_tab_share: 'Share',
|
||||||
|
eq_share_title: 'Share the Devices menu',
|
||||||
|
eq_share_hint: 'Give read-only access (Summary, Devices and Software) to users whose role does not see it. They cannot edit, see agents or share.',
|
||||||
|
eq_share_pick: 'Pick a user...',
|
||||||
|
eq_share_add: 'Grant access',
|
||||||
|
eq_share_empty: 'You have not shared Devices with anyone yet.',
|
||||||
|
eq_share_role: 'Role',
|
||||||
|
eq_share_since: 'Access since',
|
||||||
|
eq_share_remove: 'Remove access',
|
||||||
|
eq_share_remove_title: 'Remove access to Devices',
|
||||||
|
eq_share_remove_msg: 'will no longer see the Devices menu.',
|
||||||
eq_tab_summary: 'Summary',
|
eq_tab_summary: 'Summary',
|
||||||
eq_show_components: 'Show updates, drivers and system components',
|
eq_show_components: 'Show updates, drivers and system components',
|
||||||
eq_btn_info: 'Information',
|
eq_btn_info: 'Information',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Pencil, Save, Plus, Trash2, Power, Copy, Info, Download } from 'lucide-react';
|
import { Pencil, Save, Plus, Trash2, Power, Copy, Info, Download } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import {
|
import {
|
||||||
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
||||||
|
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
|
||||||
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
||||||
} from '../../services/devices.js';
|
} from '../../services/devices.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
@@ -51,7 +53,7 @@ function SimpleTable({ columns, rows, empty }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceDetail({ id, onClose, onChanged }) {
|
function DeviceDetail({ id, onClose, onChanged, canEdit }) {
|
||||||
const { t, pushToast } = useUi();
|
const { t, pushToast } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
|
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
|
||||||
@@ -176,7 +178,7 @@ function DeviceDetail({ id, onClose, onChanged }) {
|
|||||||
<>
|
<>
|
||||||
{showRows(manualRows)}
|
{showRows(manualRows)}
|
||||||
{q && showRows(infoRows).length + showRows(manualRows).length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
|
{q && showRows(infoRows).length + showRows(manualRows).length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
|
||||||
<div style={{ marginTop: 10 }}><Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button></div>
|
{canEdit && <div style={{ marginTop: 10 }}><Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button></div>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -281,7 +283,7 @@ function SummaryTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DevicesTab() {
|
function DevicesTab({ canEdit }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [kind, setKind] = useState('');
|
const [kind, setKind] = useState('');
|
||||||
@@ -331,7 +333,7 @@ function DevicesTab() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
|
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
|
||||||
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} />}
|
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit} />}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -375,6 +377,60 @@ function SoftwareTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShareTab() {
|
||||||
|
const { t, openDialog } = useUi();
|
||||||
|
const runAction = useAction();
|
||||||
|
const { data: viewers, reload } = useAsync(listDeviceViewers, []);
|
||||||
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
|
||||||
|
if (!viewers || !users) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
const taken = new Set(viewers.map((v) => v.user_id));
|
||||||
|
// Los roles de TI ya ven Equipos completo; solo se comparte con el resto de usuarios activos.
|
||||||
|
const options = users
|
||||||
|
.filter((u) => u.is_active && !taken.has(u.id) && !['superadmin', 'admin', 'it_manager'].includes(u.role_code))
|
||||||
|
.map((u) => ({ value: u.id, label: `${u.name} · ${u.email}` }));
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
runAction(async () => { await addDeviceViewer(Number(userId)); setUserId(''); await reload(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRemove(v) {
|
||||||
|
openDialog({
|
||||||
|
tone: 'warning', title: t('eq_share_remove_title'), message: `${v.name}: ${t('eq_share_remove_msg')}`,
|
||||||
|
actions: [
|
||||||
|
{ label: t('common_cancel'), variant: 'standard' },
|
||||||
|
{ label: t('eq_share_remove'), variant: 'danger', onClick: () => runAction(async () => { await removeDeviceViewer(v.user_id); await reload(); }) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="kpi-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="kpi-label" style={{ marginBottom: 6 }}>{t('eq_share_title')}</div>
|
||||||
|
<div style={{ fontSize: '.8rem', marginBottom: 10 }}>{t('eq_share_hint')}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, maxWidth: 460 }}><ComboBox options={options} value={userId} onChange={setUserId} placeholder={t('eq_share_pick')} /></div>
|
||||||
|
<Button variant="accent" icon={Plus} onClick={add} disabled={!userId}>{t('eq_share_add')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{viewers.length === 0 ? <div className="text-muted">{t('eq_share_empty')}</div> : (
|
||||||
|
<ListView
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
{ key: 'role_name', label: t('eq_share_role') },
|
||||||
|
{ key: 'created_at', label: t('eq_share_since'), render: (r) => fmtDate(r.created_at) },
|
||||||
|
{ key: 'actions', label: '', sortable: false, render: (r) => <button className="icon-btn" title={t('eq_share_remove')} onClick={() => confirmRemove(r)}><Trash2 size={15} /></button> },
|
||||||
|
]}
|
||||||
|
rows={viewers} getRowKey={(r) => r.user_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AgentsTab() {
|
function AgentsTab() {
|
||||||
const { t, pushToast, openDialog } = useUi();
|
const { t, pushToast, openDialog } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
@@ -490,14 +546,15 @@ function AgentsTab() {
|
|||||||
|
|
||||||
export default function EquiposPage() {
|
export default function EquiposPage() {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const { user } = useAuth();
|
const { data: access } = useAsync(getDevicesAccess, []);
|
||||||
const canTokens = ['superadmin', 'admin'].includes(user?.roleCode);
|
|
||||||
const [tab, setTab] = useState('summary');
|
const [tab, setTab] = useState('summary');
|
||||||
|
if (!access) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
if (!access.canView) return <Navigate to="/" replace />;
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'summary', label: t('eq_tab_summary') },
|
{ key: 'summary', label: t('eq_tab_summary') },
|
||||||
{ key: 'devices', label: t('eq_tab_devices') },
|
{ key: 'devices', label: t('eq_tab_devices') },
|
||||||
{ key: 'software', label: t('eq_tab_software_all') },
|
{ key: 'software', label: t('eq_tab_software_all') },
|
||||||
...(canTokens ? [{ key: 'agents', label: t('eq_tab_agents') }] : []),
|
...(access.canManage ? [{ key: 'agents', label: t('eq_tab_agents') }, { key: 'share', label: t('eq_tab_share') }] : []),
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -505,9 +562,10 @@ export default function EquiposPage() {
|
|||||||
<Tabs tabs={tabs} active={tab} onChange={setTab} />
|
<Tabs tabs={tabs} active={tab} onChange={setTab} />
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
{tab === 'summary' && <SummaryTab />}
|
{tab === 'summary' && <SummaryTab />}
|
||||||
{tab === 'devices' && <DevicesTab />}
|
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
||||||
{tab === 'software' && <SoftwareTab />}
|
{tab === 'software' && <SoftwareTab />}
|
||||||
{tab === 'agents' && canTokens && <AgentsTab />}
|
{tab === 'agents' && access.canManage && <AgentsTab />}
|
||||||
|
{tab === 'share' && access.canManage && <ShareTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,3 +15,8 @@ export const createInventoryToken = (label) => api.post('/devices/tokens', { lab
|
|||||||
export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive });
|
export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive });
|
||||||
export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);
|
export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);
|
||||||
export const getInventoryTokenSecret = (id) => api.get(`/devices/tokens/${id}/secret`);
|
export const getInventoryTokenSecret = (id) => api.get(`/devices/tokens/${id}/secret`);
|
||||||
|
|
||||||
|
export const getDevicesAccess = () => api.get('/devices/access');
|
||||||
|
export const listDeviceViewers = () => api.get('/devices/viewers');
|
||||||
|
export const addDeviceViewer = (userId) => api.post('/devices/viewers', { userId });
|
||||||
|
export const removeDeviceViewer = (userId) => api.delete(`/devices/viewers/${userId}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user