feat: version 2.16.0 -- contador de cierre en Contraseñas, cierre por inactividad y ajustes de Equipos

Contraseñas: el cierre automatico ahora es por inactividad (no saca al usuario a medio escribir),
token de desbloqueo renovable y contador visible junto al titulo. Equipos: GUID como componente.
Sube la version para que el panel de Actualizaciones ofrezca los cambios 2.15.0 -> 2.16.0
(migraciones 025 y 026, instalador del agente y pestaña Compartir).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-20 08:52:46 -05:00
parent d60b4b1d77
commit 4a305df73e
10 changed files with 88 additions and 23 deletions
+22 -9
View File
@@ -1,15 +1,7 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [2.15.0] - 2026-10-20 ## [2.16.0] - 2026-10-20
### Added ### Added
- Menú **Equipos**: inventario de TI alimentado por el agente GLPI oficial (sin servidor GLPI).
Ingesta `POST /api/inventory` (JSON/XML, gzip/zlib) con token de instalación, upsert por
`deviceid`, historial de cambios, KPIs, software transversal y pestaña Agentes para generar
tokens. Edición manual de estado, usuario asignado, departamento y notas.
- Migración `023_devices.sql` (devices + componentes, device_changes, inventory_tokens).
- Equipos → Software (general y por equipo): casilla "Mostrar actualizaciones, drivers y componentes
del sistema", desmarcada por defecto (solo aplicaciones). Migración `024` agrega `category` e
`is_component` a `device_software`; se decide por `system_category` del agente y por nombre.
- Equipos → Agentes: "Instalador para Windows": descarga un `.bat` (generado en el navegador, con - Equipos → Agentes: "Instalador para Windows": descarga un `.bat` (generado en el navegador, con
servidor y token ya puestos) que baja el MSI oficial del agente GLPI 1.21 y lo instala en silencio servidor y token ya puestos) que baja el MSI oficial del agente GLPI 1.21 y lo instala en silencio
con `SERVER/USER/PASSWORD/TAG`. Si el .msi está junto al .bat, no descarga. con `SERVER/USER/PASSWORD/TAG`. Si el .msi está junto al .bat, no descarga.
@@ -19,6 +11,27 @@
- Equipos → Compartir (solo admin/superadmin): da acceso de **solo lectura** (Resumen, Equipos y - 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 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. editar, sin Agentes, sin inventario crudo. El menú Equipos aparece solo a quien tenga acceso.
- Contraseñas: contador visible junto al título con el tiempo que falta para el cierre automático
(se pone amarillo en los últimos 30 s y se reinicia con cada acción).
### Fixed
- Contraseñas: ya no te saca del menú en medio de escribir/editar una credencial (se perdía lo no
guardado). El cierre automático ahora es por **inactividad**: 3 minutos sin teclado, clic, toque
o rueda; cada acción reinicia el contador. El token de desbloqueo del servidor ahora se renueva
en cada request válido (antes vencía a los 30 min aunque estuvieras trabajando). Salir a otro
menú o cerrar la pestaña sigue bloqueando igual.
- Equipos → Software: las entradas con nombre de código `{GUID}` (instaladores sin nombre) ahora
cuentan como componentes y quedan ocultas por defecto.
## [2.15.0] - 2026-10-19
### Added
- Menú **Equipos**: inventario de TI alimentado por el agente GLPI oficial (sin servidor GLPI).
Ingesta `POST /api/inventory` (JSON/XML, gzip/zlib) con token de instalación, upsert por
`deviceid`, historial de cambios, KPIs, software transversal y pestaña Agentes para generar
tokens. Edición manual de estado, usuario asignado, departamento y notas.
- Migración `023_devices.sql` (devices + componentes, device_changes, inventory_tokens).
- Equipos → Software (general y por equipo): casilla "Mostrar actualizaciones, drivers y componentes
del sistema", desmarcada por defecto (solo aplicaciones). Migración `024` agrega `category` e
`is_component` a `device_software`; se decide por `system_category` del agente y por nombre.
- 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`.
+1 -1
View File
@@ -1 +1 @@
2.15.0 2.16.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "2.15.0", "version": "2.16.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+1 -1
View File
@@ -35,7 +35,7 @@ export const app = express();
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false })); app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: env.corsOrigin, credentials: true })); app.use(cors({ origin: env.corsOrigin, credentials: true, exposedHeaders: ['X-Vault-Token'] }));
// Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente). // Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente).
app.use(inventoryIngestRoutes); app.use(inventoryIngestRoutes);
app.use(express.json({ limit: '2mb' })); app.use(express.json({ limit: '2mb' }));
+3
View File
@@ -17,5 +17,8 @@ export function requireVaultUnlock(req, res, next) {
if (payload.scope !== 'vault' || payload.sub !== req.currentUser.id) { if (payload.scope !== 'vault' || payload.sub !== req.currentUser.id) {
return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN'); return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN');
} }
// Token deslizante: mientras el usuario siga trabajando, cada request valido renueva la vigencia
// (el cliente guarda el nuevo). El cierre por inactividad lo decide el frontend (3 min sin actividad).
res.set('X-Vault-Token', jwt.sign({ sub: payload.sub, scope: 'vault' }, env.jwtSecret, { expiresIn: '30m' }));
next(); next();
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "2.15.0", "version": "2.16.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from 'react';
import { Timer } from 'lucide-react';
import { Badge } from './Badge.jsx';
// Cuenta regresiva de cierre por INACTIVIDAD: cada tecla/clic/toque/rueda la reinicia y, al llegar
// a 0, llama onExpire (ej. salir del menu). Aislado en su propio componente para que el tic de cada
// segundo no vuelva a renderizar la pantalla que lo contiene.
const ACTIVITY_EVENTS = ['keydown', 'input', 'mousedown', 'touchstart', 'wheel'];
export function AutoLockCountdown({ ms, onExpire, title }) {
const deadline = useRef(Date.now() + ms);
const expired = useRef(false);
const onExpireRef = useRef(onExpire);
onExpireRef.current = onExpire;
const [left, setLeft] = useState(ms);
useEffect(() => {
const reset = () => { deadline.current = Date.now() + ms; };
ACTIVITY_EVENTS.forEach((e) => window.addEventListener(e, reset, true));
const tick = setInterval(() => {
const remaining = deadline.current - Date.now();
setLeft(Math.max(0, remaining));
if (remaining <= 0 && !expired.current) {
expired.current = true;
onExpireRef.current();
}
}, 1000);
return () => {
clearInterval(tick);
ACTIVITY_EVENTS.forEach((e) => window.removeEventListener(e, reset, true));
};
}, [ms]);
const secs = Math.ceil(left / 1000);
const label = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;
return (
<span title={title} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Badge color={secs <= 30 ? 'warning' : 'muted'}>
<Timer size={12} style={{ verticalAlign: '-2px', marginRight: 4 }} />{label}
</Badge>
</span>
);
}
+2
View File
@@ -331,6 +331,7 @@ export const messages = {
vault_copy: 'Copiar', vault_copy: 'Copiar',
vault_copied: 'Copiado al portapapeles', vault_copied: 'Copiado al portapapeles',
vault_generate: 'Generar', vault_generate: 'Generar',
vault_countdown_hint: 'Tiempo para el cierre automático por inactividad. Se reinicia con cada tecla o clic.',
vault_auto_lock_notice: 'Saliste de Contraseñas por seguridad — ingresa tu PIN de nuevo para volver a entrar.', vault_auto_lock_notice: 'Saliste de Contraseñas por seguridad — ingresa tu PIN de nuevo para volver a entrar.',
vault_group_new: 'Nuevo grupo', vault_group_new: 'Nuevo grupo',
vault_group_name: 'Nombre del grupo', vault_group_name: 'Nombre del grupo',
@@ -806,6 +807,7 @@ export const messages = {
vault_copy: 'Copy', vault_copy: 'Copy',
vault_copied: 'Copied to clipboard', vault_copied: 'Copied to clipboard',
vault_generate: 'Generate', vault_generate: 'Generate',
vault_countdown_hint: 'Time until automatic close for inactivity. It resets with every key press or click.',
vault_auto_lock_notice: 'You left Passwords for security — enter your PIN again to come back in.', vault_auto_lock_notice: 'You left Passwords for security — enter your PIN again to come back in.',
vault_group_new: 'New group', vault_group_new: 'New group',
vault_group_name: 'Group name', vault_group_name: 'Group name',
@@ -22,11 +22,12 @@ import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Flyout, FlyoutItem } from '../../components/Flyout.jsx'; import { Flyout, FlyoutItem } from '../../components/Flyout.jsx';
import { SearchInput } from '../../components/SearchInput.jsx'; import { SearchInput } from '../../components/SearchInput.jsx';
import { ComboBox } from '../../components/ComboBox.jsx'; import { ComboBox } from '../../components/ComboBox.jsx';
import { AutoLockCountdown } from '../../components/AutoLockCountdown.jsx';
import { BRAND_ICONS } from '../../data/brandIcons.js'; import { BRAND_ICONS } from '../../data/brandIcons.js';
const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '', groupId: '' }; const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '', groupId: '' };
const EMPTY_GROUP_FORM = { name: '', iconKey: '' }; const EMPTY_GROUP_FORM = { name: '', iconKey: '' };
const AUTO_LOCK_MS = 3 * 60 * 1000; // 3 minutos: salir solo del menu si queda abierto sin atencion const AUTO_LOCK_MS = 3 * 60 * 1000; // 3 minutos SIN actividad (teclado/mouse/toque): sale solo del menu
// Catalogo fijo de iconos genericos (debe coincidir con backend/src/modules/vault/service.js#ICON_KEYS) -- // Catalogo fijo de iconos genericos (debe coincidir con backend/src/modules/vault/service.js#ICON_KEYS) --
// a proposito NO se usa ningun servicio externo de favicon (ej. google/s2/favicons): revelaria a // a proposito NO se usa ningun servicio externo de favicon (ej. google/s2/favicons): revelaria a
@@ -150,15 +151,13 @@ export default function ContrasenasPage() {
// PIN de nuevo, sin importar cuanto haya durado la sesion del sistema. // PIN de nuevo, sin importar cuanto haya durado la sesion del sistema.
useEffect(() => () => setVaultToken(null), []); useEffect(() => () => setVaultToken(null), []);
// Si queda abierto sin atencion, sale solo al Panel a los 3 minutos de haberse desbloqueado. // Cierre por inactividad (3 min sin teclado/clic/toque/rueda): lo cuenta <AutoLockCountdown>, que
useEffect(() => { // muestra el tiempo restante junto al titulo y reinicia con cada accion del usuario, asi que
if (locked) return; // nunca lo saca en medio de escribir/editar una credencial.
const timer = setTimeout(() => { function autoLockExit() {
pushToast({ tone: 'warning', message: t('vault_auto_lock_notice') }); pushToast({ tone: 'warning', message: t('vault_auto_lock_notice') });
navigate('/'); navigate('/');
}, AUTO_LOCK_MS); }
return () => clearTimeout(timer);
}, [locked]);
async function submitPin() { async function submitPin() {
setPinError(''); setPinError('');
@@ -433,6 +432,7 @@ export default function ContrasenasPage() {
<div> <div>
<div className="toolbar"> <div className="toolbar">
<h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('nav_passwords')}</h2> <h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('nav_passwords')}</h2>
<AutoLockCountdown ms={AUTO_LOCK_MS} onExpire={autoLockExit} title={t('vault_countdown_hint')} />
<SearchInput value={search} onChange={setSearch} /> <SearchInput value={search} onChange={setSearch} />
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button> <Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
+4
View File
@@ -21,6 +21,10 @@ async function request(method, path, { body, isForm, extraHeaders } = {}) {
body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined, body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined,
}); });
// El servidor renueva el token de desbloqueo de Contraseñas en cada request valido (ver vault/middleware.js).
const refreshedVaultToken = res.headers.get('x-vault-token');
if (refreshedVaultToken) sessionStorage.setItem('plc_vault_token', refreshedVaultToken);
if (res.status === 401) { if (res.status === 401) {
onUnauthorized(); onUnauthorized();
} }