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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user