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
+10 -2
View File
@@ -1,9 +1,11 @@
import { NavLink } from 'react-router-dom';
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 { useAuth } from '../context/auth-context.jsx';
import { useBranding } from '../context/branding-context.jsx';
import logoIcon from '../assets/logoLinkedTrackIcon.png';
import { getDevicesAccess } from '../services/devices.js';
import pkg from '../../package.json';
const ITEMS = [
@@ -12,7 +14,7 @@ const ITEMS = [
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
{ 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: '/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
// compacto/expandido del riel de escritorio -- son dos cosas independientes.
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');
return (
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
+22
View File
@@ -199,6 +199,17 @@ export const messages = {
eq_installer_tag: 'Etiqueta opcional (ej. Contabilidad)',
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_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_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
eq_btn_info: 'Información',
@@ -663,6 +674,17 @@ export const messages = {
eq_installer_tag: 'Optional tag (e.g. Accounting)',
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_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_show_components: 'Show updates, drivers and system components',
eq_btn_info: 'Information',
+67 -9
View File
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { Navigate } from 'react-router-dom';
import { Pencil, Save, Plus, Trash2, Power, Copy, Info, Download } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import {
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
} from '../../services/devices.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 runAction = useAction();
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
@@ -176,7 +178,7 @@ function DeviceDetail({ id, onClose, onChanged }) {
<>
{showRows(manualRows)}
{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 [search, setSearch] = 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}>
{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>
</>
);
@@ -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() {
const { t, pushToast, openDialog } = useUi();
const runAction = useAction();
@@ -490,14 +546,15 @@ function AgentsTab() {
export default function EquiposPage() {
const { t } = useUi();
const { user } = useAuth();
const canTokens = ['superadmin', 'admin'].includes(user?.roleCode);
const { data: access } = useAsync(getDevicesAccess, []);
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 = [
{ key: 'summary', label: t('eq_tab_summary') },
{ key: 'devices', label: t('eq_tab_devices') },
{ 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 (
<div>
@@ -505,9 +562,10 @@ export default function EquiposPage() {
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div style={{ marginTop: 14 }}>
{tab === 'summary' && <SummaryTab />}
{tab === 'devices' && <DevicesTab />}
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
{tab === 'software' && <SoftwareTab />}
{tab === 'agents' && canTokens && <AgentsTab />}
{tab === 'agents' && access.canManage && <AgentsTab />}
{tab === 'share' && access.canManage && <ShareTab />}
</div>
</div>
);
+5
View File
@@ -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 deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);
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}`);