Accesos remotos
This commit is contained in:
parent
2382e0ea20
commit
d5eafe6f85
@ -30,6 +30,7 @@ import { verifyInventory } from "./inventory.integration";
|
||||
import { verifyMail } from "./mail.integration";
|
||||
import { verifyPrinters } from "./printers.integration";
|
||||
import { verifyRelationships } from "./relationships.integration";
|
||||
import { verifyRemoteConnections } from "./remote.integration";
|
||||
import { verifyResourceMigration } from "./resource-migration.integration";
|
||||
import { verifyResources } from "./resources.integration";
|
||||
import { verifyTelework } from "./telework.integration";
|
||||
@ -63,6 +64,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await database.query("DELETE FROM application_configurations");
|
||||
await database.query("DELETE FROM certificate_configuration_revisions");
|
||||
await database.query("DELETE FROM certificate_configurations");
|
||||
await database.query("DELETE FROM remote_connection_revisions");
|
||||
await database.query("DELETE FROM remote_connections");
|
||||
await database.query("DELETE FROM telework_authorization_revisions");
|
||||
await database.query("DELETE FROM telework_authorizations");
|
||||
await database.query("DELETE FROM work_profile_revisions");
|
||||
@ -784,6 +787,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyRemoteConnections(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyPrinters(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
@ -863,6 +872,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(
|
||||
await operator.request(`/remote-connections/${customer.id}`),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(
|
||||
await operator.request(`/remote-connections/${customer.id}`, {}),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||
await assert.rejects(
|
||||
|
||||
284
apps/server/src/remote.integration.ts
Normal file
284
apps/server/src/remote.integration.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { InventoryResponseSchema as AssetsSchema } from "@metis/assets/common";
|
||||
import {
|
||||
CompanyResponseSchema,
|
||||
type RemoteConnectionInput,
|
||||
RemoteConnectionResponseSchema,
|
||||
RemoteConnectionsResponseSchema,
|
||||
TeleworkAuthorizationResponseSchema,
|
||||
} from "@metis/companies/common";
|
||||
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifyRemoteConnections(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize
|
||||
) {
|
||||
const personValues = {
|
||||
name: "Persona acceso remoto",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
};
|
||||
const personResponse = await request(`/companies/${companyId}/people`, personValues);
|
||||
assert.equal(personResponse.status, 201);
|
||||
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
|
||||
assert.ok(person);
|
||||
const assets = AssetsSchema.parse(await (await request(`/assets/${companyId}`)).json()).assets;
|
||||
const host = assets.find((a) => a.values.status !== "retired");
|
||||
assert.ok(host);
|
||||
const catalog = CatalogResponseSchema.parse(
|
||||
await (await request(`/resource-catalog/${companyId}`)).json()
|
||||
);
|
||||
const template = catalog.items.find((item) => item.builtinKey === "template:application");
|
||||
assert.ok(template);
|
||||
const resourceValues = {
|
||||
name: "ERP acceso remoto",
|
||||
templateId: template.id,
|
||||
active: true,
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
};
|
||||
const resourceResponse = await request(`/resources/${companyId}`, { values: resourceValues });
|
||||
assert.equal(resourceResponse.status, 201);
|
||||
const resource = ResourceResponseSchema.parse(await resourceResponse.json()).resource;
|
||||
const path = `/remote-connections/${companyId}`;
|
||||
const input: RemoteConnectionInput = {
|
||||
personId: person.id,
|
||||
sourceKind: "personal_computer",
|
||||
sourceAssetId: null,
|
||||
targetKind: "network",
|
||||
targetId: null,
|
||||
siteId: null,
|
||||
method: "VPN",
|
||||
requiresVpn: false,
|
||||
state: "configured",
|
||||
notes: null,
|
||||
};
|
||||
const list = async (scope = companyId) => {
|
||||
const response = await request(`/remote-connections/${scope}`);
|
||||
assert.equal(response.status, 200);
|
||||
return RemoteConnectionsResponseSchema.parse(await response.json()).connections;
|
||||
};
|
||||
const save = async (values: RemoteConnectionInput, id?: string) => {
|
||||
const response = await request(path + (id ? `/${id}/update` : ""), { values });
|
||||
assert.equal(response.status, id ? 200 : 201);
|
||||
return RemoteConnectionResponseSchema.parse(await response.json()).connection;
|
||||
};
|
||||
const error = async (values: unknown, code: string, id?: string, status = 409) => {
|
||||
const response = await request(path + (id ? `/${id}/update` : ""), { values });
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
|
||||
};
|
||||
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
|
||||
await error({ ...input, sourceAssetId: host.id }, "INVALID_REQUEST", undefined, 400);
|
||||
await error({ ...input, targetKind: "asset" }, "INVALID_REQUEST", undefined, 400);
|
||||
await error(
|
||||
{
|
||||
...input,
|
||||
sourceKind: "company_asset",
|
||||
sourceAssetId: host.id,
|
||||
targetKind: "asset",
|
||||
targetId: host.id,
|
||||
},
|
||||
"INVALID_REQUEST",
|
||||
undefined,
|
||||
400
|
||||
);
|
||||
await error({ ...input, personId: randomUUID() }, "REMOTE_REFERENCE_NOT_FOUND", undefined, 404);
|
||||
await error(
|
||||
{ ...input, targetKind: "asset", targetId: randomUUID() },
|
||||
"REMOTE_REFERENCE_NOT_FOUND",
|
||||
undefined,
|
||||
404
|
||||
);
|
||||
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
|
||||
assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403);
|
||||
const concurrent = await Promise.all([
|
||||
request(path, { values: input }),
|
||||
request(path, { values: { ...input, method: "vpn" } }),
|
||||
]);
|
||||
assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 409]);
|
||||
const createdResponse = concurrent.find((r) => r.status === 201);
|
||||
assert.ok(createdResponse);
|
||||
const created = RemoteConnectionResponseSchema.parse(await createdResponse.json()).connection;
|
||||
assert.equal(created.id[14], "7");
|
||||
assert.equal(created.history[0]?.id[14], "7");
|
||||
assert.ok(created.needsReview && !created.authorizationEffective);
|
||||
assert.deepEqual(await save(created.values, created.id), created);
|
||||
await error({ ...created.values, personId: randomUUID() }, "REMOTE_TARGET_IMMUTABLE", created.id);
|
||||
await error(
|
||||
{ ...created.values, sourceKind: "personal_mobile" },
|
||||
"REMOTE_TARGET_IMMUTABLE",
|
||||
created.id
|
||||
);
|
||||
await error(
|
||||
{ ...created.values, targetKind: "asset", targetId: host.id },
|
||||
"REMOTE_TARGET_IMMUTABLE",
|
||||
created.id
|
||||
);
|
||||
await error({ ...created.values, state: "removed" }, "INVALID_REMOTE_TRANSITION", created.id);
|
||||
assert.equal((await request(`/remote-connections/${otherId}`, { values: input })).status, 404);
|
||||
assert.equal((await list(otherId)).length, 0);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/remote-connections/${otherId}/${created.id}/update`, {
|
||||
values: created.values,
|
||||
})
|
||||
).status,
|
||||
404
|
||||
);
|
||||
const rdp = await save({
|
||||
...input,
|
||||
targetKind: "asset",
|
||||
targetId: host.id,
|
||||
method: "RDP",
|
||||
requiresVpn: true,
|
||||
});
|
||||
const mobile = await save({
|
||||
...input,
|
||||
sourceKind: "personal_mobile",
|
||||
targetKind: "resource",
|
||||
targetId: resource.id,
|
||||
method: "VPN móvil",
|
||||
});
|
||||
const corporate = await save({ ...input, sourceKind: "company_asset", sourceAssetId: host.id });
|
||||
assert.ok(rdp.values.requiresVpn && corporate.values.sourceAssetId === host.id);
|
||||
const alternative = await save({ ...input, method: "Herramienta propia" });
|
||||
await error(
|
||||
{ ...alternative.values, method: created.values.method },
|
||||
"REMOTE_ALREADY_OPEN",
|
||||
alternative.id
|
||||
);
|
||||
const teleworkValues = {
|
||||
personId: person.id,
|
||||
active: true,
|
||||
startsOn: null,
|
||||
expiresOn: null,
|
||||
notes: null,
|
||||
};
|
||||
const authResponse = await request(`/telework-authorizations/${companyId}`, {
|
||||
values: teleworkValues,
|
||||
});
|
||||
assert.equal(authResponse.status, 201);
|
||||
const auth = TeleworkAuthorizationResponseSchema.parse(await authResponse.json()).authorization;
|
||||
const current = async (id: string) => {
|
||||
const item = (await list()).find((item) => item.id === id);
|
||||
assert.ok(item);
|
||||
return item;
|
||||
};
|
||||
assert.equal((await current(created.id)).needsReview, false);
|
||||
assert.equal((await current(mobile.id)).needsReview, false);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/resources/${companyId}/${resource.id}/update`, {
|
||||
values: { ...resourceValues, active: false },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await current(mobile.id)).needsReview, true);
|
||||
assert.equal((await current(created.id)).needsReview, false);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/assets/${companyId}/${host.id}/update`, {
|
||||
values: { ...host.values, status: "retired" },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await current(corporate.id)).needsReview, true);
|
||||
assert.equal((await current(rdp.id)).needsReview, true);
|
||||
assert.equal(
|
||||
(await request(`/assets/${companyId}/${host.id}/update`, { values: host.values })).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await current(corporate.id)).needsReview, false);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/telework-authorizations/${companyId}/${auth.id}/update`, {
|
||||
values: { ...teleworkValues, expiresOn: "2000-01-01" },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await current(created.id)).needsReview, true);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/telework-authorizations/${companyId}/${auth.id}/update`, {
|
||||
values: teleworkValues,
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/companies/${companyId}/people/${person.id}/update`, {
|
||||
...personValues,
|
||||
active: false,
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await current(created.id)).authorizationEffective, false);
|
||||
assert.equal((await current(created.id)).history.length, 1);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/telework-authorizations/${companyId}/${auth.id}/update`, {
|
||||
values: { ...teleworkValues, active: false },
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/companies/${companyId}/people/${person.id}/update`, personValues)).status,
|
||||
200
|
||||
);
|
||||
assert.equal((await current(created.id)).needsReview, true);
|
||||
const retiring = await save({ ...created.values, state: "removal_pending" }, created.id);
|
||||
const removed = await save({ ...retiring.values, state: "removed" }, created.id);
|
||||
assert.equal(removed.needsReview, false);
|
||||
assert.equal(removed.history.length, 3);
|
||||
assert.ok(removed.history.every((r) => r.actorId !== person.id));
|
||||
assert.deepEqual(await save(removed.values, removed.id), removed);
|
||||
await error(
|
||||
{ ...removed.values, notes: "Cambiar cerrada" },
|
||||
"INVALID_REMOTE_TRANSITION",
|
||||
removed.id
|
||||
);
|
||||
const replacement = await save(created.values);
|
||||
assert.notEqual(replacement.id, created.id);
|
||||
await database.query("UPDATE remote_connections SET currentKey=:key WHERE id=:id", {
|
||||
replacements: { id: replacement.id, key: "invalid" },
|
||||
});
|
||||
try {
|
||||
const corrupt = await request(path);
|
||||
assert.equal(corrupt.status, 500);
|
||||
assert.ok(!(await corrupt.text()).includes("SELECT"));
|
||||
} finally {
|
||||
// El cierre conserva historial y libera la clave; este fixture se limpia al reiniciar la suite.
|
||||
await database.query("UPDATE remote_connections SET currentKey=SHA2(:key,256) WHERE id=:id", {
|
||||
replacements: {
|
||||
id: replacement.id,
|
||||
key: [
|
||||
person.id,
|
||||
input.sourceKind,
|
||||
"",
|
||||
input.targetKind,
|
||||
"",
|
||||
"",
|
||||
replacement.values.method.toLowerCase(),
|
||||
].join("|"),
|
||||
},
|
||||
});
|
||||
}
|
||||
assert.equal((await current(replacement.id)).history.length, 1);
|
||||
}
|
||||
61
docs/accesos-remotos.md
Normal file
61
docs/accesos-remotos.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Accesos remotos de las personas
|
||||
|
||||
Documenta el recorrido «equipo de origen → herramienta → destino». Complementa la autorización de [teletrabajo](teletrabajo.md), sin modificar los permisos ordinarios sobre recursos ni abrir o cerrar conexiones reales. La asistencia técnica remota es un caso separado y queda pendiente.
|
||||
|
||||
## Datos y referencias
|
||||
|
||||
Cada acceso pertenece a una compañía y persona. Tiene UUID v7, historial y actor obtenido de la sesión. Las personas de las compañías no son usuarios de Metis Desk.
|
||||
|
||||
Origen: activo de la compañía, ordenador personal o móvil personal. En los dos últimos casos se registra únicamente el tipo: no se crea un activo, ficha técnica ni identificación individual. Varios ordenadores personales de una misma persona no se distinguen en esta primera versión.
|
||||
|
||||
Destino: red de la compañía, red de una sede opcional, un activo concreto o un recurso concreto del nuevo catálogo de recursos. Todas las referencias deben pertenecer a la compañía. Origen y destino no pueden ser el mismo activo. Seleccionar cualquier recurso no concede permiso sobre él.
|
||||
|
||||
La herramienta es un texto corto, con sugerencias VPN, RDP, AnyDesk, TeamViewer y Supremo. Admite nombres propios. «Necesita una VPN previa» documenta un requisito independiente: no identifica ni comprueba automáticamente otra conexión VPN. Los permisos habituales no se duplican por utilizar VPN. Sin credenciales en el formulario.
|
||||
|
||||
Persona, origen, destino y sede son fijos al editar. Para cambiar el recorrido, cerrar el anterior y dar de alta otro. Se pueden modificar herramienta, requisito VPN y notas mientras el acceso está abierto.
|
||||
|
||||
No puede haber dos accesos abiertos con la misma persona, origen, destino, sede y herramienta (sin distinguir mayúsculas/minúsculas en la herramienta). Distintas herramientas permiten recorridos distintos. Un acceso cerrado permite una nueva alta del mismo recorrido.
|
||||
|
||||
## Preparación y retirada
|
||||
|
||||
Alta en Pendiente de configurar o Configurado.
|
||||
|
||||
- Pendiente de configurar → Configurado o Nunca configurado.
|
||||
- Configurado → Pendiente de retirar.
|
||||
- Pendiente de retirar → Retirado.
|
||||
|
||||
Retirado y Nunca configurado son terminales; se consultan sin editar. Una repetición idéntica es idempotente y no genera revisión.
|
||||
|
||||
Registrar un acceso Configurado describe una situación documentada, aunque la autorización ya no sea efectiva. No bloqueamos registrar precisamente aquello que debe revisarse o retirarse.
|
||||
|
||||
Revisión calculada al leer para accesos abiertos si están pendientes de retirar, falta autorización efectiva de teletrabajo, se retira el activo de origen/destino o se desactiva el recurso destino. La autorización depende de la vigencia y de que la persona esté activa. Los cambios de estas referencias no alteran el historial del acceso. Un acceso cerrado no muestra revisión.
|
||||
|
||||
La disponibilidad del recurso se comprueba mediante su estado activo; esta primera versión no verifica la conectividad real, restricciones de red, pertenencia a grupos ni permisos sobre cada destino.
|
||||
|
||||
## Interfaz
|
||||
|
||||
Sección Accesos remotos dentro del espacio de compañía, búsqueda por persona/origen/destino/herramienta y filtro para consultar cerrados. Alta desde la cabecera derecha, edición e historial en modales. Ayuda y ejemplos inmediatamente antes de Guardar y Cancelar.
|
||||
|
||||
SelectWithAction permite alta rápida de persona, activo, recurso o sede. Máximo un formulario hijo; cancelarlo conserva el padre. Guardar una referencia la conserva aunque después se cancele el acceso. Los tipos y estados son opciones fijas del proceso y no incorporan botón de alta.
|
||||
|
||||
Entorno actual muestra los accesos abiertos de la persona, con sus orígenes explícitos, separados de las configuraciones del perfil. Incluye accesos desde otros equipos o equipos personales de la misma persona. Gestionar abre un listado filtrado; el alta deja fija a la persona. Volver conserva el perfil.
|
||||
|
||||
Traducciones es/en y estados de carga, error y reintento. Se invalidan los avisos al modificar personas, autorizaciones, activos y recursos.
|
||||
|
||||
## DDD y persistencia
|
||||
|
||||
RemoteConnection es AggregateRoot; RemoteConnectionRevision es DomainEntity con identidad propia; RemoteConnectionValues encapsula referencias UniqueID, herramienta Name y opcionales Maybe. Create genera UUID v7; update conserva la identidad y compara semánticamente. Rehydrate reconstruye sin generar eventos.
|
||||
|
||||
Migraciones idempotentes para remote_connections y remote_connection_revisions, ejecutadas cuando ya existen activos y recursos. FK a compañía, persona, activo de origen/destino, recurso, sede y actor. El origen personal no tiene FK de inventario. Clave técnica única SHA-256 del recorrido abierto; se libera al cerrar. Posición de revisión única por acceso.
|
||||
|
||||
Escrituras en transacción con bloqueo de compañía. El mapper bidireccional valida UUID, referencias, historial, orden, transiciones, raíz y última revisión, y clave técnica. También se valida pertenencia a compañía al leer. Corrupción persistida falla sin exponer SQL.
|
||||
|
||||
GET /api/remote-connections/:companyId devuelve accesos, historial, efectividad de autorización y revisión. POST en esa ruta crea; POST /api/remote-connections/:companyId/:id/update modifica. Contratos Zod estrictos, sesión y protección de origen/CSRF. Los identificadores del actor nunca llegan del cliente.
|
||||
|
||||
## Validación
|
||||
|
||||
Diez pruebas nuevas de dominio/interfaz y cobertura HTTP con MariaDB exclusivamente en metis_desk_test: referencias, contratos estrictos, concurrencia, unicidad por herramienta, idempotencia, referencias fijas, estados, historial, actor, ámbito, autorización caducada/revocada, persona inactiva, activos retirados, recurso inactivo y fallo cerrado ante corrupción.
|
||||
|
||||
Suite completa: 142 pruebas correctas. Typecheck y build correctos. Lint y check mantienen respectivamente 32 y 90 errores previos, dos avisos y tres informativos; el bloque nuevo no añade incidencias.
|
||||
|
||||
Navegador: listado, formulario, cambio de origen personal, destinos, herramienta libre, altas rápidas y cancelación conservando el padre, ayuda inferior y gestión desde Entorno actual. No se guardan registros ficticios de desarrollo.
|
||||
@ -78,3 +78,7 @@ CertificateConfiguration es AggregateRoot; CertificateConfigurationRevision es D
|
||||
## Autorizaciones de teletrabajo
|
||||
|
||||
TeleworkAuthorization es AggregateRoot y TeleworkAuthorizationRevision es DomainEntity, ambas con UniqueID. TeleworkAuthorizationValues encapsula persona UniqueID, fechas CalendarDate y ausencia Maybe. Renovar y revocar conservan identidad e historial; la vigencia y efectividad se calculan sin duplicar datos. Consultar [Teletrabajo](../teletrabajo.md).
|
||||
|
||||
## Accesos remotos
|
||||
|
||||
RemoteConnection es AggregateRoot; RemoteConnectionRevision es DomainEntity con UUID v7 propio. RemoteConnectionValues conserva referencias UniqueID, herramienta Name y opcionales Maybe. Origen personal se expresa por tipo, sin crear un activo. Recorrido fijo, estados de preparación/retirada, auditoría y unicidad de accesos abiertos; autorización y revisión derivadas del contexto. Consultar [Accesos remotos](../accesos-remotos.md).
|
||||
|
||||
@ -35,3 +35,5 @@ En navegador se comprueban los datos reales del perfil, el filtro de atención,
|
||||
## Teletrabajo de la persona
|
||||
|
||||
La ficha consulta también la [autorización de teletrabajo](teletrabajo.md), común a los perfiles de la persona y separada de las configuraciones del dispositivo. Muestra vigencia y revisión o ausencia de registro; permite gestionar la autorización y volver al mismo perfil.
|
||||
|
||||
La ficha muestra también [accesos remotos](accesos-remotos.md) abiertos de la persona, incluidos los que parten de otros dispositivos o equipos personales. Los recorridos y avisos aparecen en un bloque independiente de las configuraciones del perfil. Se excluyen cerrados y registros de otras personas/compañías. Gestionar abre la consulta filtrada y Volver conserva el perfil seleccionado.
|
||||
|
||||
@ -71,10 +71,14 @@ Ficha de consulta por perfil con equipo, características registradas, composici
|
||||
|
||||
Autorización por persona, fechas opcionales inclusivas, revocación y renovación con historial, consulta desde Entorno actual y modales con alta rápida de persona. No modifica los permisos ni las conexiones reales. Consultar [Teletrabajo](teletrabajo.md).
|
||||
|
||||
## Accesos remotos de personas: implementado
|
||||
|
||||
Origen corporativo o personal sin inventario detallado, herramienta libre, destino red/sede/equipo/recurso, requisito VPN, preparación y retirada, historial y revisión según autorización y disponibilidad. Consulta desde Entorno actual. Documenta accesos; no actúa sobre conexiones reales. Consultar [Accesos remotos](accesos-remotos.md).
|
||||
|
||||
## Siguientes entregas: orden actualizado
|
||||
|
||||
1. Accesos remotos: equipo de origen corporativo o personal sin inventario detallado, conectividad VPN y destino (equipo o recurso). La autorización de teletrabajo ya está implementada. Asistencia técnica remota independiente.
|
||||
2. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
||||
1. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
||||
2. Asistencia técnica remota independiente de los accesos de las personas. Vinculación explícita entre un acceso y su VPN previa si resulta necesaria.
|
||||
|
||||
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Teletrabajo: autorización por persona
|
||||
|
||||
Primera parte del bloque de teletrabajo y accesos remotos. Documenta la autorización para trabajar fuera de la sede. Los equipos de origen, las conexiones VPN, los destinos RDP y la asistencia técnica remota todavía no se registran en esta sección.
|
||||
Primera parte del bloque de teletrabajo y accesos remotos. Documenta la autorización para trabajar fuera de la sede. Los equipos de origen, conexiones VPN y destinos se documentan en [Accesos remotos](accesos-remotos.md). La asistencia técnica remota queda pendiente y es independiente.
|
||||
|
||||
## Reglas
|
||||
|
||||
|
||||
@ -97,7 +97,10 @@ export function AssetForm({
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id),
|
||||
onSuccess: async (response) => {
|
||||
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }),
|
||||
client.invalidateQueries({ queryKey: ["remote-connections", companyId] }),
|
||||
]);
|
||||
if (mounted.current) {
|
||||
onSaved(response.asset.id);
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/web/remote/remote.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { RemoteConnection } from "../domain/remote-connection.aggregate";
|
||||
import {
|
||||
RemoteConnectionError,
|
||||
type RemoteConnectionInput,
|
||||
} from "../domain/remote-connection-values";
|
||||
import { RemoteConnectionValues } from "../domain/value-objects/remote-connection-values";
|
||||
|
||||
import type { RemoteContext } from "./remote-connection.repository";
|
||||
import { remoteReferences } from "./remote-connection-policy";
|
||||
export function mapRemoteInput(
|
||||
input: RemoteConnectionInput,
|
||||
context: RemoteContext,
|
||||
previous?: RemoteConnection
|
||||
) {
|
||||
const mapped = RemoteConnectionValues.create(input);
|
||||
if (!mapped.isSuccess) {
|
||||
return mapped;
|
||||
}
|
||||
const values = mapped.data;
|
||||
if (previous && !previous.values.sameTarget(values)) {
|
||||
return Result.fail(new RemoteConnectionError("REMOTE_TARGET_IMMUTABLE"));
|
||||
}
|
||||
if (!remoteReferences(values, context)) {
|
||||
return Result.fail(new RemoteConnectionError("REMOTE_REFERENCE_NOT_FOUND"));
|
||||
}
|
||||
// Se documentan conexiones existentes aunque su autorización ya no sea vigente; se señalan para revisión.
|
||||
if (
|
||||
!values.closed &&
|
||||
context.connections.some(
|
||||
(item) =>
|
||||
!(item.id.equals(previous?.id) || item.values.closed) &&
|
||||
item.values.connectionKey === values.connectionKey
|
||||
)
|
||||
) {
|
||||
return Result.fail(new RemoteConnectionError("REMOTE_ALREADY_OPEN"));
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import type { RemoteConnectionValues } from "../domain/value-objects/remote-connection-values";
|
||||
|
||||
import type { RemoteContext } from "./remote-connection.repository";
|
||||
import { teleworkAuthorizationState } from "./telework-authorization-policy";
|
||||
export function remoteReferences(values: RemoteConnectionValues, context: RemoteContext) {
|
||||
const v = values.toPrimitive();
|
||||
return (
|
||||
context.people.some((p) => p.id.equals(values.personId)) &&
|
||||
(!v.sourceAssetId || context.assets.some((a) => a.id.toPrimitive() === v.sourceAssetId)) &&
|
||||
(!v.siteId || context.sites.some((s) => s.toPrimitive() === v.siteId)) &&
|
||||
(v.targetKind === "network" ||
|
||||
(v.targetKind === "asset"
|
||||
? context.assets.some((a) => a.id.toPrimitive() === v.targetId)
|
||||
: context.resources.some((r) => r.id.toPrimitive() === v.targetId)))
|
||||
);
|
||||
}
|
||||
export function remoteAuthorizationEffective(
|
||||
values: RemoteConnectionValues,
|
||||
context: RemoteContext
|
||||
) {
|
||||
const authorization = context.authorizations.find((item) =>
|
||||
item.values.personId.equals(values.personId)
|
||||
);
|
||||
return Boolean(authorization && teleworkAuthorizationState(authorization, context).effective);
|
||||
}
|
||||
export function remoteNeedsReview(values: RemoteConnectionValues, context: RemoteContext) {
|
||||
if (values.closed) {
|
||||
return false;
|
||||
}
|
||||
const v = values.toPrimitive();
|
||||
return (
|
||||
values.state === "removal_pending" ||
|
||||
!remoteAuthorizationEffective(values, context) ||
|
||||
Boolean(
|
||||
v.sourceAssetId &&
|
||||
!context.assets.some((a) => a.id.toPrimitive() === v.sourceAssetId && a.available)
|
||||
) ||
|
||||
(v.targetKind === "asset" &&
|
||||
!context.assets.some((a) => a.id.toPrimitive() === v.targetId && a.available)) ||
|
||||
(v.targetKind === "resource" &&
|
||||
!context.resources.some((r) => r.id.toPrimitive() === v.targetId && r.active))
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import type { RemoteConnectionDTO } from "../../common/remote-connections";
|
||||
import type { RemoteConnection } from "../domain/remote-connection.aggregate";
|
||||
|
||||
import type { RemoteContext } from "./remote-connection.repository";
|
||||
import { remoteAuthorizationEffective, remoteNeedsReview } from "./remote-connection-policy";
|
||||
export function buildRemoteConnectionResponse(
|
||||
item: RemoteConnection,
|
||||
context: RemoteContext
|
||||
): RemoteConnectionDTO {
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
values: item.values.toPrimitive(),
|
||||
needsReview: remoteNeedsReview(item.values, context),
|
||||
authorizationEffective: remoteAuthorizationEffective(item.values, context),
|
||||
history: item.history.map((r) => ({
|
||||
id: r.id.toPrimitive(),
|
||||
actorId: r.actorId.toPrimitive(),
|
||||
actorName: context.actors.get(r.actorId.toPrimitive()) ?? r.actorId.toPrimitive(),
|
||||
recordedAt: r.recordedAt,
|
||||
values: r.values.toPrimitive(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { RemoteConnection } from "../domain/remote-connection.aggregate";
|
||||
import type { RemoteConnectionError } from "../domain/remote-connection-values";
|
||||
import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate";
|
||||
export type RemoteContext = Readonly<{
|
||||
connections: readonly RemoteConnection[];
|
||||
authorizations: readonly TeleworkAuthorization[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
sites: readonly UniqueID[];
|
||||
assets: readonly { id: UniqueID; available: boolean }[];
|
||||
resources: readonly { id: UniqueID; active: boolean }[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
today: string;
|
||||
}>;
|
||||
export interface IRemoteConnectionRepository {
|
||||
get(company: UniqueID): Promise<Result<RemoteContext, RemoteConnectionError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
action: (context: RemoteContext) => Result<RemoteConnection, RemoteConnectionError>
|
||||
): Promise<
|
||||
Result<{ connection: RemoteConnection; context: RemoteContext }, RemoteConnectionError>
|
||||
>;
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { RemoteConnection } from "../domain/remote-connection.aggregate";
|
||||
import type { RemoteConnectionInput } from "../domain/remote-connection-values";
|
||||
import { RemoteConnectionError } from "../domain/remote-connection-values";
|
||||
|
||||
import type { IRemoteConnectionRepository } from "./remote-connection.repository";
|
||||
import { mapRemoteInput } from "./remote-connection-input.mapper";
|
||||
export class RemoteConnectionUseCases {
|
||||
constructor(private readonly repository: IRemoteConnectionRepository) {}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
? this.repository.get(company.data)
|
||||
: Promise.resolve(Result.fail(new RemoteConnectionError("INVALID_REQUEST")));
|
||||
}
|
||||
save(companyId: string, id: string | undefined, input: RemoteConnectionInput, actorId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const actor = UniqueID.create(actorId);
|
||||
const identity = id ? UniqueID.create(id) : undefined;
|
||||
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new RemoteConnectionError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.save(company.data, (context) => {
|
||||
const previous = identity?.isSuccess
|
||||
? context.connections.find((item) => item.id.equals(identity.data))
|
||||
: undefined;
|
||||
if (id && !previous) {
|
||||
return Result.fail(new RemoteConnectionError("REMOTE_CONNECTION_NOT_FOUND"));
|
||||
}
|
||||
const values = mapRemoteInput(input, context, previous);
|
||||
if (!values.isSuccess) {
|
||||
return Result.fail(values.error);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
return previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: RemoteConnection.create(company.data, values.data, actor.data, now);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { RemoteConnectionValues } from "./value-objects/remote-connection-values";
|
||||
export type RemoteConnectionRevisionProps = Readonly<{
|
||||
values: RemoteConnectionValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class RemoteConnectionRevision extends DomainEntity<RemoteConnectionRevisionProps> {
|
||||
static create(props: RemoteConnectionRevisionProps) {
|
||||
return new RemoteConnectionRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: RemoteConnectionRevisionProps, id: UniqueID) {
|
||||
return new RemoteConnectionRevision(Object.freeze({ ...props }), id);
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get actorId() {
|
||||
return this.props.actorId;
|
||||
}
|
||||
get recordedAt() {
|
||||
return this.props.recordedAt;
|
||||
}
|
||||
}
|
||||
42
modules/companies/src/api/domain/remote-connection-values.ts
Normal file
42
modules/companies/src/api/domain/remote-connection-values.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ConfigurationSchema } from "./access/access-definition";
|
||||
export const RemoteConnectionInputSchema = z
|
||||
.strictObject({
|
||||
personId: z.uuid(),
|
||||
sourceKind: z.enum(["company_asset", "personal_computer", "personal_mobile"]),
|
||||
sourceAssetId: z.uuid().nullable(),
|
||||
targetKind: z.enum(["network", "asset", "resource"]),
|
||||
targetId: z.uuid().nullable(),
|
||||
siteId: z.uuid().nullable(),
|
||||
method: z.string().trim().min(1).max(80),
|
||||
requiresVpn: z.boolean(),
|
||||
state: ConfigurationSchema,
|
||||
notes: z.string().trim().max(4000).nullable(),
|
||||
})
|
||||
.refine((v) => (v.sourceKind === "company_asset") === (v.sourceAssetId !== null), {
|
||||
path: ["sourceAssetId"],
|
||||
message: "INVALID_REQUEST",
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.targetKind === "network" ? v.targetId === null : v.targetId !== null && v.siteId === null,
|
||||
{ path: ["targetId"], message: "INVALID_REQUEST" }
|
||||
)
|
||||
.refine((v) => v.targetKind !== "asset" || !v.sourceAssetId || v.sourceAssetId !== v.targetId, {
|
||||
path: ["targetId"],
|
||||
message: "INVALID_REQUEST",
|
||||
});
|
||||
export type RemoteConnectionInput = z.infer<typeof RemoteConnectionInputSchema>;
|
||||
export class RemoteConnectionError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export const REMOTE_STATE_TRANSITIONS = {
|
||||
pending: ["pending", "configured", "never_configured"],
|
||||
configured: ["configured", "removal_pending"],
|
||||
removal_pending: ["removal_pending", "removed"],
|
||||
removed: ["removed"],
|
||||
never_configured: ["never_configured"],
|
||||
} as const;
|
||||
@ -0,0 +1,89 @@
|
||||
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { RemoteConnectionRevision } from "./remote-connection-revision.entity";
|
||||
import { REMOTE_STATE_TRANSITIONS, RemoteConnectionError } from "./remote-connection-values";
|
||||
import type { RemoteConnectionValues } from "./value-objects/remote-connection-values";
|
||||
|
||||
type Props = Readonly<{
|
||||
companyId: UniqueID;
|
||||
values: RemoteConnectionValues;
|
||||
history: readonly RemoteConnectionRevision[];
|
||||
}>;
|
||||
export class RemoteConnection extends AggregateRoot<Props> {
|
||||
private constructor(props: Props, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
values: RemoteConnectionValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<RemoteConnection, RemoteConnectionError> {
|
||||
if (
|
||||
!(
|
||||
z.iso.datetime().safeParse(recordedAt).success &&
|
||||
["pending", "configured"].includes(values.state)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new RemoteConnectionError("INVALID_REMOTE_TRANSITION"));
|
||||
}
|
||||
return Result.ok(
|
||||
new RemoteConnection({
|
||||
companyId,
|
||||
values,
|
||||
history: [RemoteConnectionRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: Props, id: UniqueID) {
|
||||
return new RemoteConnection(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(
|
||||
values: RemoteConnectionValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<RemoteConnection, RemoteConnectionError> {
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (!this.values.sameTarget(values)) {
|
||||
return Result.fail(new RemoteConnectionError("REMOTE_TARGET_IMMUTABLE"));
|
||||
}
|
||||
if (
|
||||
!REMOTE_STATE_TRANSITIONS[this.values.state].some((state) => state === values.state) ||
|
||||
(this.values.closed && !this.values.equals(values))
|
||||
) {
|
||||
return Result.fail(new RemoteConnectionError("INVALID_REMOTE_TRANSITION"));
|
||||
}
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new RemoteConnectionError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new RemoteConnection(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [
|
||||
...this.history,
|
||||
RemoteConnectionRevision.create({ values, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
270
modules/companies/src/api/domain/remote-connection.test.ts
Normal file
270
modules/companies/src/api/domain/remote-connection.test.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { RemoteContext } from "../application/remote-connection.repository";
|
||||
import { mapRemoteInput } from "../application/remote-connection-input.mapper";
|
||||
import { remoteNeedsReview } from "../application/remote-connection-policy";
|
||||
import { RemoteConnectionPersistenceMapper } from "../infrastructure/persistence/remote-connection-persistence.mapper";
|
||||
|
||||
import { RemoteConnection } from "./remote-connection.aggregate";
|
||||
import {
|
||||
type RemoteConnectionInput,
|
||||
RemoteConnectionInputSchema,
|
||||
} from "./remote-connection-values";
|
||||
import { TeleworkAuthorization } from "./telework-authorization.aggregate";
|
||||
import { RemoteConnectionValues } from "./value-objects/remote-connection-values";
|
||||
import { TeleworkAuthorizationValues } from "./value-objects/telework-authorization-values";
|
||||
|
||||
const company = UniqueID.generateNewID();
|
||||
const person = UniqueID.generateNewID();
|
||||
const source = UniqueID.generateNewID();
|
||||
const target = UniqueID.generateNewID();
|
||||
const resource = UniqueID.generateNewID();
|
||||
const site = UniqueID.generateNewID();
|
||||
const actor = UniqueID.generateNewID();
|
||||
const now = "2026-10-06T12:00:00.000Z";
|
||||
const input: RemoteConnectionInput = {
|
||||
personId: person.toPrimitive(),
|
||||
sourceKind: "company_asset",
|
||||
sourceAssetId: source.toPrimitive(),
|
||||
targetKind: "network",
|
||||
targetId: null,
|
||||
siteId: null,
|
||||
method: "VPN",
|
||||
requiresVpn: false,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
};
|
||||
const context: RemoteContext = {
|
||||
connections: [],
|
||||
authorizations: [],
|
||||
people: [{ id: person, active: true }],
|
||||
sites: [site],
|
||||
assets: [
|
||||
{ id: source, available: true },
|
||||
{ id: target, available: true },
|
||||
],
|
||||
resources: [{ id: resource, active: true }],
|
||||
actors: new Map(),
|
||||
today: "2026-10-06",
|
||||
};
|
||||
function values(input: RemoteConnectionInput) {
|
||||
const result = RemoteConnectionValues.create(input);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
function create(v: RemoteConnectionInput = input) {
|
||||
const result = RemoteConnection.create(company, values(v), actor, now);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
function authorization(active = true, expiresOn: string | null = null) {
|
||||
const v = TeleworkAuthorizationValues.create({
|
||||
personId: person.toPrimitive(),
|
||||
active,
|
||||
startsOn: null,
|
||||
expiresOn,
|
||||
notes: null,
|
||||
});
|
||||
assert.ok(v.isSuccess);
|
||||
const result = TeleworkAuthorization.create(company, v.data, actor, now);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
test("remotos: origen personal sin detalle, destinos tipados y contrato estricto", () => {
|
||||
assert.ok(
|
||||
RemoteConnectionInputSchema.safeParse({
|
||||
...input,
|
||||
sourceKind: "personal_computer",
|
||||
sourceAssetId: null,
|
||||
}).success
|
||||
);
|
||||
assert.ok(
|
||||
!RemoteConnectionInputSchema.safeParse({ ...input, sourceKind: "personal_mobile" }).success
|
||||
);
|
||||
assert.ok(!RemoteConnectionInputSchema.safeParse({ ...input, sourceAssetId: null }).success);
|
||||
assert.ok(
|
||||
!RemoteConnectionInputSchema.safeParse({ ...input, targetId: target.toPrimitive() }).success
|
||||
);
|
||||
assert.ok(
|
||||
!RemoteConnectionInputSchema.safeParse({
|
||||
...input,
|
||||
targetKind: "asset",
|
||||
targetId: source.toPrimitive(),
|
||||
}).success
|
||||
);
|
||||
assert.ok(
|
||||
!RemoteConnectionInputSchema.safeParse({
|
||||
...input,
|
||||
targetKind: "resource",
|
||||
targetId: resource.toPrimitive(),
|
||||
siteId: site.toPrimitive(),
|
||||
}).success
|
||||
);
|
||||
assert.ok(!RemoteConnectionInputSchema.safeParse({ ...input, method: " " }).success);
|
||||
assert.ok(!RemoteConnectionInputSchema.safeParse({ ...input, method: "x".repeat(81) }).success);
|
||||
assert.ok(!RemoteConnectionInputSchema.safeParse({ ...input, password: "x" }).success);
|
||||
assert.equal(
|
||||
values({ ...input, method: " Herramienta propia ", notes: " " }).toPrimitive().method,
|
||||
"Herramienta propia"
|
||||
);
|
||||
assert.equal(values({ ...input, notes: " " }).toPrimitive().notes, null);
|
||||
});
|
||||
test("remotos: UUID e historial, destino fijo, idempotencia y cierre terminal", () => {
|
||||
const original = create();
|
||||
assert.equal(original.id.toPrimitive()[14], "7");
|
||||
assert.equal(original.history[0]?.id.toPrimitive()[14], "7");
|
||||
const same = original.update(values(input), actor, now);
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, original);
|
||||
const ready = original.update(
|
||||
values({ ...input, state: "configured", requiresVpn: true }),
|
||||
actor,
|
||||
now
|
||||
);
|
||||
assert.ok(ready.isSuccess);
|
||||
assert.equal(ready.data.history.length, 2);
|
||||
assert.ok(ready.data.id.equals(original.id));
|
||||
const removal = ready.data.update(values({ ...input, state: "removal_pending" }), actor, now);
|
||||
assert.ok(removal.isSuccess);
|
||||
const removed = removal.data.update(values({ ...input, state: "removed" }), actor, now);
|
||||
assert.ok(removed.isSuccess);
|
||||
assert.ok(
|
||||
!removed.data.update(
|
||||
values({ ...input, state: "removed", notes: "alterar cierre" }),
|
||||
actor,
|
||||
now
|
||||
).isSuccess
|
||||
);
|
||||
assert.ok(!ready.data.update(values({ ...input, state: "removed" }), actor, now).isSuccess);
|
||||
const changed = original.update(
|
||||
values({ ...input, targetKind: "asset", targetId: target.toPrimitive() }),
|
||||
actor,
|
||||
now
|
||||
);
|
||||
assert.ok(!changed.isSuccess);
|
||||
assert.equal(changed.error.code, "REMOTE_TARGET_IMMUTABLE");
|
||||
assert.ok(
|
||||
!RemoteConnection.create(company, values({ ...input, state: "removed" }), actor, now).isSuccess
|
||||
);
|
||||
});
|
||||
test("remotos: referencias de compañía y duplicados abiertos por recorrido y herramienta", () => {
|
||||
const missing = mapRemoteInput({ ...input, sourceAssetId: actor.toPrimitive() }, context);
|
||||
assert.ok(!missing.isSuccess);
|
||||
assert.equal(missing.error.code, "REMOTE_REFERENCE_NOT_FOUND");
|
||||
assert.ok(mapRemoteInput({ ...input, siteId: site.toPrimitive() }, context).isSuccess);
|
||||
assert.ok(
|
||||
mapRemoteInput({ ...input, targetKind: "resource", targetId: resource.toPrimitive() }, context)
|
||||
.isSuccess
|
||||
);
|
||||
const duplicate = mapRemoteInput(
|
||||
{ ...input, method: "vpn" },
|
||||
{ ...context, connections: [create()] }
|
||||
);
|
||||
assert.ok(!duplicate.isSuccess);
|
||||
assert.equal(duplicate.error.code, "REMOTE_ALREADY_OPEN");
|
||||
assert.ok(
|
||||
mapRemoteInput({ ...input, method: "WireGuard" }, { ...context, connections: [create()] })
|
||||
.isSuccess
|
||||
);
|
||||
const first = create();
|
||||
const closed = first.update(values({ ...input, state: "never_configured" }), actor, now);
|
||||
assert.ok(closed.isSuccess);
|
||||
assert.ok(mapRemoteInput(input, { ...context, connections: [closed.data] }).isSuccess);
|
||||
});
|
||||
test("remotos: configuración y autorización independientes; caducidad y revocación no borran acceso", () => {
|
||||
const configured = values({ ...input, state: "configured" });
|
||||
assert.ok(mapRemoteInput(configured.toPrimitive(), context).isSuccess);
|
||||
assert.equal(remoteNeedsReview(configured, context), true);
|
||||
assert.equal(
|
||||
remoteNeedsReview(configured, { ...context, authorizations: [authorization()] }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
remoteNeedsReview(configured, { ...context, authorizations: [authorization(false)] }),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
remoteNeedsReview(configured, {
|
||||
...context,
|
||||
authorizations: [authorization(true, "2026-10-05")],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
remoteNeedsReview(configured, {
|
||||
...context,
|
||||
authorizations: [authorization(true, "2026-10-06")],
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
remoteNeedsReview(configured, {
|
||||
...context,
|
||||
authorizations: [authorization()],
|
||||
people: [{ id: person, active: false }],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(remoteNeedsReview(values({ ...input, state: "removed" }), context), false);
|
||||
});
|
||||
test("remotos: disponibilidad de origen y destino, retirada y método personalizado", () => {
|
||||
const authorized = { ...context, authorizations: [authorization()] };
|
||||
assert.equal(
|
||||
remoteNeedsReview(values(input), {
|
||||
...authorized,
|
||||
assets: [
|
||||
{ id: source, available: false },
|
||||
{ id: target, available: true },
|
||||
],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
remoteNeedsReview(
|
||||
values({ ...input, targetKind: "resource", targetId: resource.toPrimitive() }),
|
||||
{ ...authorized, resources: [{ id: resource, active: false }] }
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(remoteNeedsReview(values({ ...input, state: "removal_pending" }), authorized), true);
|
||||
assert.equal(
|
||||
remoteNeedsReview(
|
||||
values({
|
||||
...input,
|
||||
sourceKind: "personal_mobile",
|
||||
sourceAssetId: null,
|
||||
method: "Herramienta propia",
|
||||
}),
|
||||
authorized
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
test("remotos: persistencia conserva auditoría y detecta referencias, claves e historial corruptos", () => {
|
||||
const item = create();
|
||||
const mapper = new RemoteConnectionPersistenceMapper();
|
||||
const record = mapper.toPersistence(item);
|
||||
const restored = mapper.toDomain(record.root, record.revisions);
|
||||
assert.ok(restored.id.equals(item.id));
|
||||
assert.ok(restored.values.equals(item.values));
|
||||
assert.throws(() => mapper.toDomain({ ...record.root, currentKey: "wrong" }, record.revisions));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain({ ...record.root, targetAssetId: target.toPrimitive() }, record.revisions)
|
||||
);
|
||||
assert.throws(() => mapper.toDomain(record.root, []));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, position: 1 }))
|
||||
)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, connectionId: actor.toPrimitive() }))
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,110 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
RemoteConnectionError,
|
||||
type RemoteConnectionInput,
|
||||
RemoteConnectionInputSchema,
|
||||
} from "../remote-connection-values";
|
||||
export class RemoteConnectionValues {
|
||||
private constructor(
|
||||
public readonly personId: UniqueID,
|
||||
public readonly sourceKind: RemoteConnectionInput["sourceKind"],
|
||||
public readonly sourceAssetId: Maybe<UniqueID>,
|
||||
public readonly targetKind: RemoteConnectionInput["targetKind"],
|
||||
public readonly targetId: Maybe<UniqueID>,
|
||||
public readonly siteId: Maybe<UniqueID>,
|
||||
public readonly method: Name,
|
||||
public readonly requiresVpn: boolean,
|
||||
public readonly state: RemoteConnectionInput["state"],
|
||||
public readonly notes: Maybe<string>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(
|
||||
input: RemoteConnectionInput
|
||||
): Result<RemoteConnectionValues, RemoteConnectionError> {
|
||||
const parsed = RemoteConnectionInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new RemoteConnectionError("INVALID_REQUEST"));
|
||||
}
|
||||
const v = parsed.data;
|
||||
const person = UniqueID.create(v.personId);
|
||||
const method = Name.create(v.method, 80);
|
||||
const source = v.sourceAssetId ? UniqueID.create(v.sourceAssetId) : undefined;
|
||||
const target = v.targetId ? UniqueID.create(v.targetId) : undefined;
|
||||
const site = v.siteId ? UniqueID.create(v.siteId) : undefined;
|
||||
if (
|
||||
!(person.isSuccess && method.isSuccess) ||
|
||||
(source && !source.isSuccess) ||
|
||||
(target && !target.isSuccess) ||
|
||||
(site && !site.isSuccess)
|
||||
) {
|
||||
return Result.fail(new RemoteConnectionError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new RemoteConnectionValues(
|
||||
person.data,
|
||||
v.sourceKind,
|
||||
source?.isSuccess ? Maybe.some(source.data) : Maybe.none(),
|
||||
v.targetKind,
|
||||
target?.isSuccess ? Maybe.some(target.data) : Maybe.none(),
|
||||
site?.isSuccess ? Maybe.some(site.data) : Maybe.none(),
|
||||
method.data,
|
||||
v.requiresVpn,
|
||||
v.state,
|
||||
v.notes ? Maybe.some(v.notes) : Maybe.none()
|
||||
)
|
||||
);
|
||||
}
|
||||
get closed() {
|
||||
return this.state === "removed" || this.state === "never_configured";
|
||||
}
|
||||
sameTarget(other: RemoteConnectionValues) {
|
||||
const a = this.toPrimitive();
|
||||
const b = other.toPrimitive();
|
||||
return (
|
||||
this.personId.equals(other.personId) &&
|
||||
a.sourceKind === b.sourceKind &&
|
||||
a.sourceAssetId === b.sourceAssetId &&
|
||||
a.targetKind === b.targetKind &&
|
||||
a.targetId === b.targetId &&
|
||||
a.siteId === b.siteId
|
||||
);
|
||||
}
|
||||
get connectionKey() {
|
||||
const v = this.toPrimitive();
|
||||
return [
|
||||
v.personId,
|
||||
v.sourceKind,
|
||||
v.sourceAssetId ?? "",
|
||||
v.targetKind,
|
||||
v.targetId ?? "",
|
||||
v.siteId ?? "",
|
||||
v.method.toLowerCase(),
|
||||
].join("|");
|
||||
}
|
||||
equals(other: RemoteConnectionValues) {
|
||||
return (
|
||||
this.sameTarget(other) &&
|
||||
this.method.equals(other.method) &&
|
||||
this.requiresVpn === other.requiresVpn &&
|
||||
this.state === other.state &&
|
||||
this.notes.getOrUndefined() === other.notes.getOrUndefined()
|
||||
);
|
||||
}
|
||||
toPrimitive(): RemoteConnectionInput {
|
||||
return {
|
||||
personId: this.personId.toPrimitive(),
|
||||
sourceKind: this.sourceKind,
|
||||
sourceAssetId: this.sourceAssetId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
targetKind: this.targetKind,
|
||||
targetId: this.targetId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
siteId: this.siteId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
method: this.method.toPrimitive(),
|
||||
requiresVpn: this.requiresVpn,
|
||||
state: this.state,
|
||||
notes: this.notes.getOrUndefined() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { type Application, type RequestHandler, type Response, Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
RemoteItemParamsSchema,
|
||||
RemoteParamsSchema,
|
||||
SaveRemoteConnectionRequestSchema,
|
||||
} from "../../../common/remote-connections";
|
||||
import type { RemoteConnectionUseCases } from "../../application/remote-connection.use-cases";
|
||||
import { buildRemoteConnectionResponse } from "../../application/remote-connection-response.builder";
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
export function registerRemoteConnectionRoutes(
|
||||
app: Application,
|
||||
useCases: RemoteConnectionUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/:companyId", (request, response, next) => {
|
||||
const params = RemoteParamsSchema.safeParse(request.params);
|
||||
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.get(params.data.companyId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
connections: result.data.connections.map((item) =>
|
||||
buildRemoteConnectionResponse(item, result.data)
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (
|
||||
path.endsWith("update") ? RemoteItemParamsSchema : RemoteParamsSchema
|
||||
).safeParse(request.params);
|
||||
const body = SaveRemoteConnectionRequestSchema.safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
body.success &&
|
||||
actor.success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
useCases
|
||||
.save(params.data.companyId, id, body.data.values, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.status(id ? 200 : 201).json({
|
||||
connection: buildRemoteConnectionResponse(result.data.connection, result.data.context),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/remote-connections", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
export async function migrateRemoteConnections(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const reference = (table: string, nullable = false) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: nullable,
|
||||
references: { model: table, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
if (!tables.some((row) => row.name === "remote_connections")) {
|
||||
await query.createTable("remote_connections", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: reference("companies"),
|
||||
personId: reference("company_contacts"),
|
||||
sourceAssetId: reference("assets", true),
|
||||
targetAssetId: reference("assets", true),
|
||||
targetResourceId: reference("resources", true),
|
||||
siteId: reference("company_sites", true),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
currentKey: { type: DataTypes.STRING(64), allowNull: true, unique: true },
|
||||
});
|
||||
}
|
||||
if (!tables.some((row) => row.name === "remote_connection_revisions")) {
|
||||
await query.createTable("remote_connection_revisions", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
connectionId: reference("remote_connections"),
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
actorId: reference("users"),
|
||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
});
|
||||
}
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='remote_connection_revisions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((row) => row.name === "remote_revision_position")) {
|
||||
await query.addIndex("remote_connection_revisions", ["connectionId", "position"], {
|
||||
unique: true,
|
||||
name: "remote_revision_position",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { RemoteConnection } from "../../domain/remote-connection.aggregate";
|
||||
import { RemoteConnectionRevision } from "../../domain/remote-connection-revision.entity";
|
||||
import {
|
||||
REMOTE_STATE_TRANSITIONS,
|
||||
RemoteConnectionInputSchema,
|
||||
} from "../../domain/remote-connection-values";
|
||||
import { RemoteConnectionValues } from "../../domain/value-objects/remote-connection-values";
|
||||
|
||||
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
|
||||
export type RemoteRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
personId: string;
|
||||
sourceAssetId: string | null;
|
||||
targetAssetId: string | null;
|
||||
targetResourceId: string | null;
|
||||
siteId: string | null;
|
||||
values: unknown;
|
||||
currentKey: string | null;
|
||||
};
|
||||
export type RemoteRevisionRow = {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
position: number;
|
||||
values: unknown;
|
||||
actorId: string;
|
||||
recordedAt: string;
|
||||
};
|
||||
function mapped(raw: unknown) {
|
||||
const parsed = RemoteConnectionValues.create(
|
||||
RemoteConnectionInputSchema.parse(persistedJson(raw))
|
||||
);
|
||||
if (!parsed.isSuccess) {
|
||||
throw new Error("Valores de acceso remoto persistidos inconsistentes.");
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
export class RemoteConnectionPersistenceMapper {
|
||||
toDomain(row: RemoteRow, revisions: readonly RemoteRevisionRow[]) {
|
||||
const values = mapped(row.values);
|
||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||
const history = ordered.map((revision, index) => {
|
||||
const value = mapped(revision.values);
|
||||
const previous = ordered[index - 1];
|
||||
if (
|
||||
revision.connectionId !== row.id ||
|
||||
revision.position !== index ||
|
||||
!z.iso.datetime().safeParse(revision.recordedAt).success ||
|
||||
revision.recordedAt < (previous?.recordedAt ?? "") ||
|
||||
!values.sameTarget(value)
|
||||
) {
|
||||
throw new Error("Historial de acceso remoto persistido inconsistente.");
|
||||
}
|
||||
return RemoteConnectionRevision.rehydrate(
|
||||
{
|
||||
values: value,
|
||||
actorId: profileIdentity(revision.actorId),
|
||||
recordedAt: revision.recordedAt,
|
||||
},
|
||||
profileIdentity(revision.id)
|
||||
);
|
||||
});
|
||||
if (!(history.length && ["pending", "configured"].includes(history[0]?.values.state ?? ""))) {
|
||||
throw new Error("Estado inicial de acceso remoto persistido inconsistente.");
|
||||
}
|
||||
for (const [index, revision] of history.entries()) {
|
||||
const previous = history[index - 1]?.values;
|
||||
if (
|
||||
previous &&
|
||||
(!REMOTE_STATE_TRANSITIONS[previous.state].some(
|
||||
(state) => state === revision.values.state
|
||||
) ||
|
||||
(previous.closed && !previous.equals(revision.values)))
|
||||
) {
|
||||
throw new Error("Estados de acceso remoto históricos inconsistentes.");
|
||||
}
|
||||
}
|
||||
const keys = remoteKeys(values);
|
||||
if (
|
||||
!history.at(-1)?.values.equals(values) ||
|
||||
row.personId !== values.personId.toPrimitive() ||
|
||||
row.sourceAssetId !== values.toPrimitive().sourceAssetId ||
|
||||
row.targetAssetId !==
|
||||
(values.targetKind === "asset" ? values.toPrimitive().targetId : null) ||
|
||||
row.targetResourceId !==
|
||||
(values.targetKind === "resource" ? values.toPrimitive().targetId : null) ||
|
||||
row.siteId !== values.toPrimitive().siteId ||
|
||||
row.currentKey !== keys.currentKey
|
||||
) {
|
||||
throw new Error("Referencias de acceso remoto persistidas inconsistentes.");
|
||||
}
|
||||
return RemoteConnection.rehydrate(
|
||||
{ companyId: profileIdentity(row.companyId), values, history },
|
||||
profileIdentity(row.id)
|
||||
);
|
||||
}
|
||||
toPersistence(item: RemoteConnection) {
|
||||
const values = item.values.toPrimitive();
|
||||
return {
|
||||
root: {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
personId: values.personId,
|
||||
sourceAssetId: values.sourceAssetId,
|
||||
targetAssetId: values.targetKind === "asset" ? values.targetId : null,
|
||||
targetResourceId: values.targetKind === "resource" ? values.targetId : null,
|
||||
siteId: values.siteId,
|
||||
values: JSON.stringify(values),
|
||||
...remoteKeys(item.values),
|
||||
},
|
||||
revisions: item.history.map((revision, position) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
connectionId: item.id.toPrimitive(),
|
||||
position,
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
values: JSON.stringify(revision.values.toPrimitive()),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
function remoteKeys(values: RemoteConnectionValues) {
|
||||
return {
|
||||
currentKey: values.closed
|
||||
? null
|
||||
: createHash("sha256").update(values.connectionKey).digest("hex"),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
import type { IResourceInventoryService } from "@metis/resources/services";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type {
|
||||
IRemoteConnectionRepository,
|
||||
RemoteContext,
|
||||
} from "../../application/remote-connection.repository";
|
||||
import { remoteReferences } from "../../application/remote-connection-policy";
|
||||
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
|
||||
import { RemoteConnectionError } from "../../domain/remote-connection-values";
|
||||
|
||||
import {
|
||||
RemoteConnectionPersistenceMapper,
|
||||
type RemoteRevisionRow,
|
||||
type RemoteRow,
|
||||
} from "./remote-connection-persistence.mapper";
|
||||
import {
|
||||
TeleworkAuthorizationPersistenceMapper,
|
||||
type TeleworkRevisionRow,
|
||||
type TeleworkRow,
|
||||
teleworkIdentity,
|
||||
} from "./telework-authorization-persistence.mapper";
|
||||
|
||||
export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRepository {
|
||||
private readonly mapper = new RemoteConnectionPersistenceMapper();
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly inventory: IWorkProfileInventoryService,
|
||||
private readonly resources: IResourceInventoryService
|
||||
) {}
|
||||
private async load(
|
||||
company: UniqueID,
|
||||
transaction: Transaction,
|
||||
write: boolean
|
||||
): Promise<Result<RemoteContext, RemoteConnectionError>> {
|
||||
const companyId = company.toPrimitive();
|
||||
const companies = await this.database.query<{ id: string }>(
|
||||
`SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`,
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!companies.length) {
|
||||
return Result.fail(new RemoteConnectionError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const rows = await this.database.query<RemoteRow>(
|
||||
"SELECT * FROM remote_connections WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<RemoteRevisionRow>(
|
||||
"SELECT r.* FROM remote_connection_revisions r JOIN remote_connections c ON c.id=r.connectionId WHERE c.companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const people = await this.database.query<{ id: string; active: boolean }>(
|
||||
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const sites = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM company_sites WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const authorizations = await this.database.query<TeleworkRow>(
|
||||
"SELECT * FROM telework_authorizations WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const authRevisions = await this.database.query<TeleworkRevisionRow>(
|
||||
"SELECT r.* FROM telework_authorization_revisions r JOIN telework_authorizations a ON a.id=r.authorizationId WHERE a.companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const actors = await this.database.query<{ id: string; name: string }>(
|
||||
"SELECT id,name FROM users",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const context: RemoteContext = {
|
||||
connections: rows.map((row) =>
|
||||
this.mapper.toDomain(
|
||||
row,
|
||||
revisions.filter((r) => r.connectionId === row.id)
|
||||
)
|
||||
),
|
||||
authorizations: authorizations.map((row) =>
|
||||
new TeleworkAuthorizationPersistenceMapper().toDomain(
|
||||
row,
|
||||
authRevisions.filter((r) => r.authorizationId === row.id)
|
||||
)
|
||||
),
|
||||
people: people.map((p) => ({ id: teleworkIdentity(p.id), active: Boolean(p.active) })),
|
||||
sites: sites.map((s) => teleworkIdentity(s.id)),
|
||||
assets: await this.inventory.list(company),
|
||||
resources: await this.resources.list(company),
|
||||
actors: new Map(actors.map((a) => [a.id, a.name])),
|
||||
today: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
if (
|
||||
context.connections.some((item) => !remoteReferences(item.values, context)) ||
|
||||
context.authorizations.some(
|
||||
(item) => !context.people.some((p) => p.id.equals(item.values.personId))
|
||||
)
|
||||
) {
|
||||
throw new Error("Ámbito de acceso remoto persistido inconsistente.");
|
||||
}
|
||||
return Result.ok(context);
|
||||
}
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((t) => this.load(company, t, false));
|
||||
}
|
||||
save(company: UniqueID, action: Parameters<IRemoteConnectionRepository["save"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(company, transaction, true);
|
||||
if (!loaded.isSuccess) {
|
||||
return loaded;
|
||||
}
|
||||
const candidate = action(loaded.data);
|
||||
if (!candidate.isSuccess) {
|
||||
return candidate;
|
||||
}
|
||||
const previous = loaded.data.connections.find((item) => item.id.equals(candidate.data.id));
|
||||
if (previous !== candidate.data) {
|
||||
const record = this.mapper.toPersistence(candidate.data);
|
||||
await this.database.query(
|
||||
"INSERT INTO remote_connections (id,companyId,personId,sourceAssetId,targetAssetId,targetResourceId,siteId,`values`,currentKey) VALUES (:id,:companyId,:personId,:sourceAssetId,:targetAssetId,:targetResourceId,:siteId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
|
||||
{ replacements: record.root, transaction }
|
||||
);
|
||||
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
|
||||
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
|
||||
await this.database.query(
|
||||
"INSERT INTO remote_connection_revisions (id,connectionId,position,actorId,recordedAt,`values`) VALUES (:id,:connectionId,:position,:actorId,:recordedAt,:values)",
|
||||
{ replacements: revision, transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
return Result.ok({ connection: candidate.data, context: loaded.data });
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import type { IMailInventoryService } from "./application/mail-configuration.rep
|
||||
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||
import { MailboxUseCases } from "./application/mailbox.use-cases";
|
||||
import { PrinterConfigurationUseCases } from "./application/printer-configuration.use-cases";
|
||||
import { RemoteConnectionUseCases } from "./application/remote-connection.use-cases";
|
||||
import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases";
|
||||
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
|
||||
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
||||
@ -23,6 +24,7 @@ import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mappin
|
||||
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
||||
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes";
|
||||
import { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes";
|
||||
import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes";
|
||||
import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
|
||||
import {
|
||||
@ -41,6 +43,7 @@ import { migrateCompanies } from "./infrastructure/persistence/migrate-companies
|
||||
import { migratePeople } from "./infrastructure/persistence/migrate-people";
|
||||
import { initializePeopleModels } from "./infrastructure/persistence/people.model";
|
||||
import { migratePrinterConfigurations } from "./infrastructure/persistence/printer-configuration-migration";
|
||||
import { migrateRemoteConnections } from "./infrastructure/persistence/remote-connection-migration";
|
||||
import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository";
|
||||
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
|
||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||
@ -48,6 +51,7 @@ import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/s
|
||||
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
||||
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
|
||||
import { SequelizeRemoteConnectionRepository } from "./infrastructure/persistence/sequelize-remote-connection.repository";
|
||||
import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository";
|
||||
import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
|
||||
import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration";
|
||||
@ -77,6 +81,10 @@ export async function createCompanyModule(
|
||||
inventory: IWorkProfileInventoryService,
|
||||
resources: IResourceInventoryService
|
||||
) => {
|
||||
await migrateRemoteConnections(database);
|
||||
const remote = new RemoteConnectionUseCases(
|
||||
new SequelizeRemoteConnectionRepository(database, inventory, resources)
|
||||
);
|
||||
await migrateWorkProfiles(database);
|
||||
await migrateFolderMappings(database);
|
||||
await migratePrinterConfigurations(database);
|
||||
@ -99,6 +107,7 @@ export async function createCompanyModule(
|
||||
);
|
||||
return {
|
||||
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
||||
registerRemoteConnectionRoutes(app, remote, authenticate);
|
||||
registerWorkProfileRoutes(app, profiles, authenticate);
|
||||
registerFolderMappingRoutes(app, folders, authenticate);
|
||||
registerPrinterConfigurationRoutes(app, printers, authenticate);
|
||||
|
||||
@ -11,5 +11,6 @@ export * from "./folder-mappings";
|
||||
export * from "./mail-configurations";
|
||||
export * from "./mailboxes";
|
||||
export * from "./printer-configurations";
|
||||
export * from "./remote-connections";
|
||||
export * from "./telework-authorizations";
|
||||
export * from "./work-profiles";
|
||||
|
||||
39
modules/companies/src/common/remote-connections.ts
Normal file
39
modules/companies/src/common/remote-connections.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { RemoteConnectionInputSchema } from "../api/domain/remote-connection-values";
|
||||
|
||||
export type { RemoteConnectionInput } from "../api/domain/remote-connection-values";
|
||||
export {
|
||||
REMOTE_STATE_TRANSITIONS,
|
||||
RemoteConnectionInputSchema,
|
||||
} from "../api/domain/remote-connection-values";
|
||||
export const RemoteParamsSchema = z.strictObject({ companyId: z.uuid() });
|
||||
export const RemoteItemParamsSchema = RemoteParamsSchema.extend({ id: z.uuid() });
|
||||
export const SaveRemoteConnectionRequestSchema = z.strictObject({
|
||||
values: RemoteConnectionInputSchema,
|
||||
});
|
||||
export const RemoteConnectionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
values: RemoteConnectionInputSchema,
|
||||
needsReview: z.boolean(),
|
||||
authorizationEffective: z.boolean(),
|
||||
history: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
values: RemoteConnectionInputSchema,
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
export const RemoteConnectionsResponseSchema = z.strictObject({
|
||||
connections: z.array(RemoteConnectionSchema),
|
||||
});
|
||||
export const RemoteConnectionResponseSchema = z.strictObject({
|
||||
connection: RemoteConnectionSchema,
|
||||
});
|
||||
export type RemoteConnectionDTO = z.infer<typeof RemoteConnectionSchema>;
|
||||
@ -28,6 +28,7 @@ import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { MailboxRelations } from "../mail/mailbox-relations";
|
||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||
import { WorkProfilesPage } from "../profiles/work-profiles-page";
|
||||
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
|
||||
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
@ -111,6 +112,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
||||
);
|
||||
}
|
||||
type CompanyTab =
|
||||
| "remote"
|
||||
| "telework"
|
||||
| "people"
|
||||
| "departments"
|
||||
@ -219,6 +221,7 @@ export function CompanyDetail({
|
||||
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
||||
["profiles", t("profiles.title"), UsersIcon],
|
||||
["telework", t("telework.title"), KeyRoundIcon],
|
||||
["remote", t("remote.title"), KeyRoundIcon],
|
||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||
[
|
||||
"departments",
|
||||
@ -268,6 +271,8 @@ function CompanySection({
|
||||
tab: CompanyTab;
|
||||
}) {
|
||||
switch (tab) {
|
||||
case "remote":
|
||||
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
|
||||
case "telework":
|
||||
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "people":
|
||||
|
||||
@ -15,6 +15,8 @@ import { PersonRequestSchema } from "../../common";
|
||||
import { accessKeys } from "../access/access-api";
|
||||
import { EntityHelp } from "../access/entity-help";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
import { remoteKeys } from "../remote/remote-api";
|
||||
import { teleworkKeys } from "../telework/telework-api";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
|
||||
@ -65,7 +67,11 @@ export function PersonForm({
|
||||
csrfToken
|
||||
),
|
||||
onSuccess: async (response) => {
|
||||
await client.invalidateQueries({ queryKey: accessKeys.scope(companyId) });
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: accessKeys.scope(companyId) }),
|
||||
client.invalidateQueries({ queryKey: remoteKeys.company(companyId) }),
|
||||
client.invalidateQueries({ queryKey: teleworkKeys.company(companyId) }),
|
||||
]);
|
||||
client.setQueryData(companyKeys.detail(companyId), response);
|
||||
// El alta devuelve el agregado con la nueva persona añadida al final.
|
||||
const saved = person
|
||||
|
||||
@ -6,9 +6,11 @@ import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
||||
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
|
||||
import { environmentEnglish, environmentSpanish } from "./profiles/environment-i18n";
|
||||
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
||||
import { remoteEnglish, remoteSpanish } from "./remote/remote-i18n";
|
||||
import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
|
||||
|
||||
export const companiesSpanish = {
|
||||
remote: remoteSpanish,
|
||||
telework: teleworkSpanish,
|
||||
environment: environmentSpanish,
|
||||
certificates: certificatesSpanish,
|
||||
@ -132,6 +134,7 @@ export const companiesSpanish = {
|
||||
},
|
||||
};
|
||||
export const companiesEnglish = {
|
||||
remote: remoteEnglish,
|
||||
telework: teleworkEnglish,
|
||||
environment: environmentEnglish,
|
||||
certificates: certificatesEnglish,
|
||||
|
||||
@ -11,6 +11,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, WorkProfileDTO } from "../../common";
|
||||
import type { RemoteConnectionDTO } from "../../common/remote-connections";
|
||||
import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
@ -19,6 +20,8 @@ import { certificateApi, certificateKeys } from "../certificates/certificate-api
|
||||
import { folderApi, folderKeys } from "../folders/folder-api";
|
||||
import { mailApi, mailKeys } from "../mail/mail-api";
|
||||
import { printerApi, printerKeys } from "../printers/printer-api";
|
||||
import { remoteApi, remoteKeys } from "../remote/remote-api";
|
||||
import { RemoteSummary } from "../remote/remote-summary";
|
||||
import { teleworkApi, teleworkKeys } from "../telework/telework-api";
|
||||
import { TeleworkAuthorizationSummary } from "../telework/telework-authorization-summary";
|
||||
|
||||
@ -71,7 +74,12 @@ function useEnvironmentQueries(companyId: string) {
|
||||
queryKey: teleworkKeys.company(companyId),
|
||||
queryFn: () => teleworkApi.get(companyId),
|
||||
});
|
||||
const remote = useQuery({
|
||||
queryKey: remoteKeys.company(companyId),
|
||||
queryFn: () => remoteApi.get(companyId),
|
||||
});
|
||||
const queries = [
|
||||
remote,
|
||||
telework,
|
||||
profiles,
|
||||
assets,
|
||||
@ -88,6 +96,7 @@ function useEnvironmentQueries(companyId: string) {
|
||||
const retry = () => Promise.all(queries.map((query) => query.refetch()));
|
||||
if (
|
||||
!(
|
||||
remote.data &&
|
||||
telework.data &&
|
||||
profiles.data &&
|
||||
assets.data &&
|
||||
@ -121,6 +130,7 @@ function useEnvironmentQueries(companyId: string) {
|
||||
profiles: profiles.data.profiles,
|
||||
sources,
|
||||
authorizations: telework.data.authorizations,
|
||||
connections: remote.data.connections,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -132,7 +142,7 @@ export function WorkEnvironmentPage({
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
profileId: string;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework") => void;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework" | "remote") => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
@ -165,6 +175,7 @@ export function WorkEnvironmentPage({
|
||||
<EnvironmentContent
|
||||
authorizations={query.data.authorizations}
|
||||
company={company}
|
||||
connections={query.data.connections}
|
||||
key={profile.id}
|
||||
onManage={onManage}
|
||||
onProfile={onProfile}
|
||||
@ -175,6 +186,7 @@ export function WorkEnvironmentPage({
|
||||
);
|
||||
}
|
||||
function EnvironmentContent({
|
||||
connections,
|
||||
authorizations,
|
||||
company,
|
||||
profile,
|
||||
@ -185,15 +197,22 @@ function EnvironmentContent({
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
profile: WorkProfileDTO;
|
||||
connections: readonly RemoteConnectionDTO[];
|
||||
authorizations: readonly TeleworkAuthorizationDTO[];
|
||||
profiles: readonly WorkProfileDTO[];
|
||||
sources: EnvironmentSources;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework") => void;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework" | "remote") => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const [attention, setAttention] = useState(false);
|
||||
const model = buildEnvironment(profile, sources);
|
||||
const remoteConnections = connections.filter(
|
||||
(item) =>
|
||||
item.companyId === company.id &&
|
||||
item.values.personId === profile.values.personId &&
|
||||
!["removed", "never_configured"].includes(item.values.state)
|
||||
);
|
||||
const person = company.people.find((item) => item.id === profile.values.personId);
|
||||
const account = sources.access.find((item) => item.id === profile.values.accountId);
|
||||
const others = profiles.filter(
|
||||
@ -308,6 +327,31 @@ function EnvironmentContent({
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 pt-5">
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-semibold">{t("remote.title")}</h3>
|
||||
<Button onClick={() => onManage("remote")} size="sm" variant="outline">
|
||||
{t("remote.manage")}
|
||||
</Button>
|
||||
</header>
|
||||
{!remoteConnections.length && (
|
||||
<p className="text-sm text-muted-foreground">{t("remote.empty")}</p>
|
||||
)}
|
||||
<ul className="divide-y">
|
||||
{remoteConnections.map((item) => (
|
||||
<li className="py-3" key={item.id}>
|
||||
<RemoteSummary
|
||||
assets={sources.assets}
|
||||
company={company}
|
||||
connection={item}
|
||||
resources={sources.resources}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section aria-label={t("environment.configurations")} className="space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{(["configured", "pending", "removal", "review"] as const).map((key) => (
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
PrinterConfigurationSchema,
|
||||
WorkProfileSchema,
|
||||
} from "../../common";
|
||||
import { RemoteConnectionSchema } from "../../common/remote-connections";
|
||||
import { TeleworkAuthorizationSchema } from "../../common/telework-authorizations";
|
||||
import { accessKeys } from "../access/access-api";
|
||||
import { applicationKeys } from "../applications/application-api";
|
||||
@ -26,6 +27,7 @@ import { folderKeys } from "../folders/folder-api";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
import { mailKeys } from "../mail/mail-api";
|
||||
import { printerKeys } from "../printers/printer-api";
|
||||
import { remoteKeys } from "../remote/remote-api";
|
||||
import { teleworkKeys } from "../telework/telework-api";
|
||||
|
||||
import { type EnvironmentSources, buildEnvironment } from "./work-environment.model";
|
||||
@ -263,7 +265,7 @@ test("entorno: composición vigente del puesto y datos actuales sin duplicación
|
||||
});
|
||||
async function render(
|
||||
language: string,
|
||||
mode: "ready" | "pending" | "error" | "missing" | "archived" | "telework" = "ready"
|
||||
mode: "ready" | "pending" | "error" | "missing" | "archived" | "telework" | "remote" = "ready"
|
||||
) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
@ -274,6 +276,45 @@ async function render(
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
if (mode !== "pending") {
|
||||
const remote = RemoteConnectionSchema.parse({
|
||||
...record(90, {
|
||||
personId: profile.values.personId,
|
||||
sourceKind: "personal_computer",
|
||||
sourceAssetId: null,
|
||||
targetKind: "asset",
|
||||
targetId: host.id,
|
||||
siteId: null,
|
||||
method: "RDP prueba",
|
||||
requiresVpn: true,
|
||||
state: "configured",
|
||||
notes: "Acceso actual Ana",
|
||||
}),
|
||||
authorizationEffective: false,
|
||||
});
|
||||
client.setQueryData(remoteKeys.company(company.id), {
|
||||
connections:
|
||||
mode === "remote"
|
||||
? [
|
||||
remote,
|
||||
{
|
||||
...remote,
|
||||
id: id(91),
|
||||
values: { ...remote.values, state: "removed", notes: "Cerrada excluida" },
|
||||
},
|
||||
{
|
||||
...remote,
|
||||
id: id(92),
|
||||
companyId: id(99),
|
||||
values: { ...remote.values, notes: "Compañía excluida" },
|
||||
},
|
||||
{
|
||||
...remote,
|
||||
id: id(93),
|
||||
values: { ...remote.values, personId: id(98), notes: "Persona excluida" },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
client.setQueryData(teleworkKeys.company(company.id), {
|
||||
authorizations:
|
||||
mode === "telework"
|
||||
@ -393,3 +434,17 @@ test("entorno: consulta autorización por persona, común a sus perfiles y sin c
|
||||
const empty = await render("es");
|
||||
assert.ok(empty.includes(companiesSpanish.telework.notRecorded));
|
||||
});
|
||||
|
||||
test("entorno: accesos abiertos de la persona y sus orígenes sin confundirlos con configuraciones del perfil", async () => {
|
||||
const html = await render("es", "remote");
|
||||
assert.ok(html.includes("Acceso actual Ana") && html.includes("RDP prueba"));
|
||||
assert.ok(html.includes(companiesSpanish.remote.personal_computer));
|
||||
assert.ok(html.includes(companiesSpanish.remote.manage));
|
||||
assert.ok(
|
||||
!(
|
||||
html.includes("Cerrada excluida") ||
|
||||
html.includes("Compañía excluida") ||
|
||||
html.includes("Persona excluida")
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@ import { CertificateConfigurationsPage } from "../certificates/certificate-confi
|
||||
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
|
||||
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
||||
|
||||
import { ProfileFeedback } from "./profile-feedback";
|
||||
@ -24,7 +25,7 @@ import { profileApi, profileKeys } from "./work-profile-api";
|
||||
import { WorkProfileForm } from "./work-profile-form";
|
||||
|
||||
type ProfileSelection = {
|
||||
kind: ProfileConfigurationKind | "environment" | "telework";
|
||||
kind: ProfileConfigurationKind | "environment" | "telework" | "remote";
|
||||
profileId: string;
|
||||
fromEnvironment?: boolean;
|
||||
};
|
||||
@ -324,6 +325,9 @@ function ProfileConfigurations({
|
||||
profileId={selection.profileId}
|
||||
/>
|
||||
)}
|
||||
{selection.kind === "remote" && (
|
||||
<RemoteConnectionsPage company={company} csrfToken={csrfToken} initialPersonId={personId} />
|
||||
)}
|
||||
{selection.kind === "telework" && (
|
||||
<TeleworkAuthorizationsPage
|
||||
company={company}
|
||||
|
||||
40
modules/companies/src/web/remote/remote-api.ts
Normal file
40
modules/companies/src/web/remote/remote-api.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
type RemoteConnectionInput,
|
||||
RemoteConnectionResponseSchema,
|
||||
RemoteConnectionsResponseSchema,
|
||||
} from "../../common/remote-connections";
|
||||
export const remoteKeys = { company: (id: string) => ["remote-connections", id] };
|
||||
export class RemoteApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(
|
||||
companyId: string,
|
||||
values?: RemoteConnectionInput,
|
||||
token?: string,
|
||||
id?: string
|
||||
) {
|
||||
const response = await fetch(`/api/remote-connections/${companyId}${id ? `/${id}/update` : ""}`, {
|
||||
method: values ? "POST" : "GET",
|
||||
credentials: "same-origin",
|
||||
headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {},
|
||||
...(values ? { body: JSON.stringify({ values }) } : {}),
|
||||
});
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = z.object({ code: z.string() }).safeParse(body);
|
||||
throw new RemoteApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
export const remoteApi = {
|
||||
async get(companyId: string) {
|
||||
return RemoteConnectionsResponseSchema.parse(await request(companyId));
|
||||
},
|
||||
async save(companyId: string, values: RemoteConnectionInput, token: string, id?: string) {
|
||||
return RemoteConnectionResponseSchema.parse(await request(companyId, values, token, id));
|
||||
},
|
||||
};
|
||||
235
modules/companies/src/web/remote/remote-connection-form.tsx
Normal file
235
modules/companies/src/web/remote/remote-connection-form.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client";
|
||||
import {
|
||||
inventoryApi as resourceApi,
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import {
|
||||
REMOTE_STATE_TRANSITIONS,
|
||||
type RemoteConnectionDTO,
|
||||
type RemoteConnectionInput,
|
||||
RemoteConnectionInputSchema,
|
||||
} from "../../common/remote-connections";
|
||||
|
||||
import { remoteApi, remoteKeys } from "./remote-api";
|
||||
import { RemoteEndpointFields } from "./remote-endpoint-fields";
|
||||
import { RemoteFeedback } from "./remote-feedback";
|
||||
import { RemoteQuickEditor, type RemoteQuickKind } from "./remote-quick-editor";
|
||||
|
||||
const quickFields = {
|
||||
person: "personId",
|
||||
source: "sourceAssetId",
|
||||
target: "targetId",
|
||||
resource: "targetId",
|
||||
site: "siteId",
|
||||
} satisfies Record<RemoteQuickKind, "personId" | "sourceAssetId" | "targetId" | "siteId">;
|
||||
|
||||
export function RemoteConnectionForm({
|
||||
company,
|
||||
csrfToken,
|
||||
connection,
|
||||
initialPersonId,
|
||||
onSaved,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
connection?: RemoteConnectionDTO | undefined;
|
||||
initialPersonId?: string | undefined;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const [quick, setQuick] = useState<RemoteQuickKind>();
|
||||
const assets = useQuery({
|
||||
queryKey: assetKeys.company(company.id),
|
||||
queryFn: () => assetApi.get(company.id),
|
||||
});
|
||||
const resources = useQuery({
|
||||
queryKey: resourceKeys.company(company.id),
|
||||
queryFn: () => resourceApi.get(company.id),
|
||||
});
|
||||
const form = useForm<RemoteConnectionInput>({
|
||||
resolver: zodResolver(RemoteConnectionInputSchema),
|
||||
defaultValues: connection?.values ?? {
|
||||
personId: initialPersonId ?? "",
|
||||
sourceKind: "company_asset",
|
||||
sourceAssetId: null,
|
||||
targetKind: "network",
|
||||
targetId: null,
|
||||
siteId: null,
|
||||
method: "",
|
||||
requiresVpn: false,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
const v = form.watch();
|
||||
const fixed = Boolean(connection);
|
||||
const personFixed = Boolean(connection || initialPersonId);
|
||||
const ready = Boolean(assets.data && resources.data && !assets.isError && !resources.isError);
|
||||
const states: readonly RemoteConnectionInput["state"][] = connection
|
||||
? REMOTE_STATE_TRANSITIONS[connection.values.state]
|
||||
: ["pending", "configured"];
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: RemoteConnectionInput) =>
|
||||
remoteApi.save(company.id, values, csrfToken, connection?.id),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: remoteKeys.company(company.id) });
|
||||
if (mounted.current) {
|
||||
onSaved();
|
||||
}
|
||||
},
|
||||
});
|
||||
const assetOptions = (assets.data?.assets ?? []).map((a) => ({
|
||||
value: a.id,
|
||||
label: a.values.name,
|
||||
}));
|
||||
const resourceOptions = (resources.data?.resources ?? []).map((r) => ({
|
||||
value: r.id,
|
||||
label: r.values.name,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("remote.person")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
personFixed
|
||||
? undefined
|
||||
: { label: t("newPerson"), onClick: () => setQuick("person") }
|
||||
}
|
||||
disabled={personFixed}
|
||||
id={`${prefix}-person`}
|
||||
label={t("remote.person")}
|
||||
onValueChange={(id) => form.setValue("personId", id)}
|
||||
options={company.people.map((p) => ({ value: p.id, label: p.name }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={v.personId}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<RemoteEndpointFields
|
||||
assetOptions={assetOptions}
|
||||
company={company}
|
||||
fixed={fixed}
|
||||
form={form}
|
||||
prefix={prefix}
|
||||
ready={ready}
|
||||
resourceOptions={resourceOptions}
|
||||
setQuick={setQuick}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-method`}>{t("remote.method")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-method`}
|
||||
list={`${prefix}-methods`}
|
||||
maxLength={80}
|
||||
placeholder={t("remote.methodExample")}
|
||||
required
|
||||
{...form.register("method")}
|
||||
/>
|
||||
<datalist id={`${prefix}-methods`}>
|
||||
{["VPN", "RDP", "AnyDesk", "TeamViewer", "Supremo"].map((method) => (
|
||||
<option key={method} value={method} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = RemoteConnectionInputSchema.shape.state.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("state", parsed.data);
|
||||
}
|
||||
}}
|
||||
options={states.map((value) => ({ value, label: t(`mail.${value}`) }))}
|
||||
placeholder={t("mail.choose")}
|
||||
value={v.state}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={v.requiresVpn}
|
||||
id={`${prefix}-vpn`}
|
||||
onCheckedChange={(checked) => form.setValue("requiresVpn", checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${prefix}-vpn`}>{t("remote.requiresVpn")}</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
rows={3}
|
||||
{...form.register("notes")}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{fixed && <p className="text-sm text-muted-foreground">{t("remote.fixed")}</p>}
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("remote.errors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<RemoteFeedback error={mutation.error ?? assets.error ?? resources.error} />
|
||||
<ContextHelp
|
||||
description={t("remote.description")}
|
||||
examples={t("remote.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("remote.guidance")}
|
||||
scopeNote={t("remote.scope")}
|
||||
title={t("remote.help")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={!ready || mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<RemoteQuickEditor
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
kind={quick}
|
||||
onClose={() => setQuick(undefined)}
|
||||
onSaved={(id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue(quickFields[quick], id);
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
personId={v.personId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
200
modules/companies/src/web/remote/remote-connections-page.tsx
Normal file
200
modules/companies/src/web/remote/remote-connections-page.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client";
|
||||
import {
|
||||
inventoryApi as resourceApi,
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
|
||||
import { remoteApi, remoteKeys } from "./remote-api";
|
||||
import { RemoteConnectionForm } from "./remote-connection-form";
|
||||
import { RemoteFeedback } from "./remote-feedback";
|
||||
import { RemoteSummary, remoteEndpoints } from "./remote-summary";
|
||||
|
||||
export function RemoteConnectionsPage({
|
||||
company,
|
||||
csrfToken,
|
||||
initialPersonId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
initialPersonId?: string | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [closed, setClosed] = useState(false);
|
||||
const query = useQuery({
|
||||
queryKey: remoteKeys.company(company.id),
|
||||
queryFn: () => remoteApi.get(company.id),
|
||||
});
|
||||
const assets = useQuery({
|
||||
queryKey: assetKeys.company(company.id),
|
||||
queryFn: () => assetApi.get(company.id),
|
||||
});
|
||||
const resources = useQuery({
|
||||
queryKey: resourceKeys.company(company.id),
|
||||
queryFn: () => resourceApi.get(company.id),
|
||||
});
|
||||
const connections = query.data?.connections ?? [];
|
||||
const edited = connections.find((c) => c.id === editor);
|
||||
const viewed = connections.find((c) => c.id === view);
|
||||
const person = (id: string) => company.people.find((p) => p.id === id)?.name ?? id;
|
||||
const inventory = assets.data?.assets ?? [];
|
||||
const entries = resources.data?.resources ?? [];
|
||||
const isClosed = (state: string) => state === "removed" || state === "never_configured";
|
||||
const filtered = connections.filter((c) => {
|
||||
const route = remoteEndpoints(c.values, company, inventory, entries, t);
|
||||
return (
|
||||
(!initialPersonId || c.values.personId === initialPersonId) &&
|
||||
(closed || !isClosed(c.values.state)) &&
|
||||
[person(c.values.personId), route.source, route.target, c.values.method].some((text) =>
|
||||
text.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
});
|
||||
const error = query.error ?? assets.error ?? resources.error;
|
||||
const ready = Boolean(query.data && assets.data && resources.data && !error);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("remote.title")}</h2>
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
{t("remote.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("remote.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("remote.search")}
|
||||
value={search}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={closed}
|
||||
onChange={(event) => setClosed(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("remote.showClosed")}
|
||||
</label>
|
||||
{[query, assets, resources].some((q) => q.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<RemoteFeedback error={error} />
|
||||
{error && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([query.refetch(), assets.refetch(), resources.refetch()]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(connections.length ? "noResults" : "remote.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((c) => (
|
||||
<div className="space-y-3 py-4" key={c.id}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-medium">{person(c.values.personId)}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(c.id)} size="sm" variant="outline">
|
||||
{t("remote.history")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isClosed(c.values.state)}
|
||||
onClick={() => setEditor(c.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<RemoteSummary
|
||||
assets={inventory}
|
||||
company={company}
|
||||
connection={c}
|
||||
resources={entries}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(edited ? "remote.edit" : "remote.new")}
|
||||
wide
|
||||
>
|
||||
<RemoteConnectionForm
|
||||
company={company}
|
||||
connection={edited}
|
||||
csrfToken={csrfToken}
|
||||
initialPersonId={initialPersonId}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{viewed && (
|
||||
<FormDialog
|
||||
cancelLabel={t("close")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setView(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("remote.history")}
|
||||
wide
|
||||
>
|
||||
<p className="font-medium">{person(viewed.values.personId)}</p>
|
||||
<ol className="space-y-3">
|
||||
{[...viewed.history].reverse().map((r) => {
|
||||
const route = remoteEndpoints(r.values, company, inventory, entries, t);
|
||||
return (
|
||||
<li className="rounded-md border p-3 text-sm" key={r.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(r.recordedAt))}{" "}
|
||||
· {r.actorName}
|
||||
</p>
|
||||
<p>
|
||||
{route.source} → {r.values.method} → {route.target}
|
||||
</p>
|
||||
<p>{t(`mail.${r.values.state}`)}</p>
|
||||
{r.values.requiresVpn && <p>{t("remote.vpnRequired")}</p>}
|
||||
{r.values.notes && <p className="mt-2 whitespace-pre-wrap">{r.values.notes}</p>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
modules/companies/src/web/remote/remote-endpoint-fields.tsx
Normal file
141
modules/companies/src/web/remote/remote-endpoint-fields.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import {
|
||||
type RemoteConnectionInput,
|
||||
RemoteConnectionInputSchema,
|
||||
} from "../../common/remote-connections";
|
||||
|
||||
import type { RemoteQuickKind } from "./remote-quick-editor";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
const targetActions = {
|
||||
network: { label: "newSite", kind: "site" },
|
||||
asset: { label: "remote.newAsset", kind: "target" },
|
||||
resource: { label: "remote.newResource", kind: "resource" },
|
||||
} satisfies Record<RemoteConnectionInput["targetKind"], { label: string; kind: RemoteQuickKind }>;
|
||||
|
||||
export function RemoteEndpointFields({
|
||||
company,
|
||||
form,
|
||||
prefix,
|
||||
fixed,
|
||||
ready,
|
||||
assetOptions,
|
||||
resourceOptions,
|
||||
setQuick,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
form: UseFormReturn<RemoteConnectionInput>;
|
||||
prefix: string;
|
||||
fixed: boolean;
|
||||
ready: boolean;
|
||||
assetOptions: Option[];
|
||||
resourceOptions: Option[];
|
||||
setQuick: (kind: RemoteQuickKind) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const v = form.watch();
|
||||
const targetAction = targetActions[v.targetKind];
|
||||
const targetOptions = {
|
||||
network: [
|
||||
{ value: "", label: t("remote.allCompany") },
|
||||
...company.sites.map((s) => ({ value: s.id, label: s.name })),
|
||||
],
|
||||
asset: assetOptions.filter((a) => a.value !== v.sourceAssetId),
|
||||
resource: resourceOptions,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-source-kind`}>{t("remote.sourceKind")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={fixed}
|
||||
id={`${prefix}-source-kind`}
|
||||
label={t("remote.sourceKind")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = RemoteConnectionInputSchema.shape.sourceKind.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("sourceKind", parsed.data);
|
||||
form.setValue("sourceAssetId", null);
|
||||
}
|
||||
}}
|
||||
options={["company_asset", "personal_computer", "personal_mobile"].map((value) => ({
|
||||
value,
|
||||
label: t(`remote.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
value={v.sourceKind}
|
||||
/>
|
||||
</div>
|
||||
{v.sourceKind === "company_asset" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-source`}>{t("remote.sourceAsset")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed ? undefined : { label: t("remote.newAsset"), onClick: () => setQuick("source") }
|
||||
}
|
||||
disabled={fixed || !ready}
|
||||
id={`${prefix}-source`}
|
||||
label={t("remote.sourceAsset")}
|
||||
onValueChange={(id) => form.setValue("sourceAssetId", id || null)}
|
||||
options={assetOptions}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={v.sourceAssetId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-target-kind`}>{t("remote.targetKind")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={fixed}
|
||||
id={`${prefix}-target-kind`}
|
||||
label={t("remote.targetKind")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = RemoteConnectionInputSchema.shape.targetKind.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("targetKind", parsed.data);
|
||||
form.setValue("targetId", null);
|
||||
form.setValue("siteId", null);
|
||||
}
|
||||
}}
|
||||
options={["network", "asset", "resource"].map((value) => ({
|
||||
value,
|
||||
label: t(`remote.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
value={v.targetKind}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-target`}>
|
||||
{t(v.targetKind === "network" ? "remote.site" : "remote.target")}
|
||||
</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed
|
||||
? undefined
|
||||
: {
|
||||
label: t(targetAction.label),
|
||||
onClick: () => setQuick(targetAction.kind),
|
||||
}
|
||||
}
|
||||
disabled={fixed || !ready}
|
||||
id={`${prefix}-target`}
|
||||
label={t(v.targetKind === "network" ? "remote.site" : "remote.target")}
|
||||
onValueChange={(id) =>
|
||||
form.setValue(v.targetKind === "network" ? "siteId" : "targetId", id || null)
|
||||
}
|
||||
options={targetOptions[v.targetKind]}
|
||||
placeholder={t(v.targetKind === "network" ? "remote.allCompany" : "mail.choose")}
|
||||
required={v.targetKind !== "network"}
|
||||
value={(v.targetKind === "network" ? v.siteId : v.targetId) ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
modules/companies/src/web/remote/remote-feedback.tsx
Normal file
18
modules/companies/src/web/remote/remote-feedback.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { RemoteApiError } from "./remote-api";
|
||||
export function RemoteFeedback({ error }: { error: unknown }) {
|
||||
const { t } = useTranslation("companies");
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
const key =
|
||||
error instanceof RemoteApiError
|
||||
? `remote.errors.${error.code}`
|
||||
: "remote.errors.INTERNAL_ERROR";
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(key, { defaultValue: t("remote.errors.INTERNAL_ERROR") })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
116
modules/companies/src/web/remote/remote-i18n.ts
Normal file
116
modules/companies/src/web/remote/remote-i18n.ts
Normal file
@ -0,0 +1,116 @@
|
||||
export const remoteSpanish = {
|
||||
title: "Accesos remotos",
|
||||
new: "Nuevo acceso remoto",
|
||||
edit: "Editar acceso remoto",
|
||||
person: "Persona",
|
||||
sourceKind: "Equipo de origen",
|
||||
company_asset: "Activo de la compañía",
|
||||
personal_computer: "Ordenador personal",
|
||||
personal_mobile: "Móvil personal",
|
||||
sourceAsset: "Activo de origen",
|
||||
targetKind: "Tipo de destino",
|
||||
network: "Red de compañía / sede",
|
||||
asset: "Equipo concreto",
|
||||
resource: "Recurso concreto",
|
||||
target: "Destino",
|
||||
site: "Sede (opcional)",
|
||||
allCompany: "Red de la compañía",
|
||||
method: "Método o herramienta",
|
||||
methodExample: "VPN, RDP, AnyDesk…",
|
||||
requiresVpn: "Necesita una VPN previa",
|
||||
vpnRequired: "Requiere VPN previa",
|
||||
vpnNote: "La conexión VPN necesaria debe comprobarse por separado.",
|
||||
authorizationMissing:
|
||||
"La persona no tiene autorización de teletrabajo vigente. Revisar el acceso existente.",
|
||||
review:
|
||||
"Revisar: retirada pendiente, autorización no vigente o persona, origen o destino no disponibles.",
|
||||
search: "Buscar persona, origen, destino o herramienta…",
|
||||
empty: "Todavía no hay accesos remotos registrados.",
|
||||
showClosed: "Mostrar también los retirados y nunca configurados",
|
||||
fixed:
|
||||
"La persona, el origen y el destino quedan fijos. Para otro recorrido, cierra este acceso y crea uno nuevo.",
|
||||
history: "Historial del acceso",
|
||||
manage: "Gestionar accesos remotos",
|
||||
newAsset: "Nuevo activo",
|
||||
newResource: "Nuevo recurso",
|
||||
help: "¿Para qué sirve este acceso?",
|
||||
description:
|
||||
"Documenta cómo una persona accede desde fuera a la red, a un equipo o a un recurso de la compañía.",
|
||||
scope:
|
||||
"Es un acceso de la persona para trabajar. La asistencia del técnico es independiente. Un equipo personal solo se identifica por su tipo; no se inventaría como activo de la compañía.",
|
||||
examples:
|
||||
"Portátil corporativo → VPN → red de la sede. Ordenador personal → RDP → PC de oficina, con VPN previa. Móvil personal → acceso a un recurso concreto.",
|
||||
guidance:
|
||||
"La herramienta admite nombres propios. Configurado describe el acceso documentado, no concede permiso. Puedes registrar un acceso que siga funcionando aunque la persona ya no esté autorizada; aparecerá para revisión. No se duplican los permisos de carpetas o aplicaciones, ni se establecen o cortan conexiones reales. No guardes contraseñas. Las altas rápidas guardadas permanecen aunque canceles este formulario.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Revisa la persona, el origen, el destino y la herramienta.",
|
||||
REMOTE_TARGET_IMMUTABLE: "La persona, el origen y el destino no se pueden cambiar.",
|
||||
REMOTE_REFERENCE_NOT_FOUND: "Selecciona referencias de esta compañía.",
|
||||
REMOTE_ALREADY_OPEN:
|
||||
"Ya existe un acceso abierto con este origen, destino y herramienta para la persona.",
|
||||
INVALID_REMOTE_TRANSITION: "El cambio de estado no está permitido.",
|
||||
REMOTE_CONNECTION_NOT_FOUND: "No se ha encontrado el acceso.",
|
||||
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
|
||||
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
|
||||
FORBIDDEN: "La sesión no permite esta operación. Vuelve a iniciar sesión.",
|
||||
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
|
||||
},
|
||||
};
|
||||
export const remoteEnglish = {
|
||||
title: "Remote access",
|
||||
new: "New remote access",
|
||||
edit: "Edit remote access",
|
||||
person: "Person",
|
||||
sourceKind: "Source device",
|
||||
company_asset: "Company asset",
|
||||
personal_computer: "Personal computer",
|
||||
personal_mobile: "Personal mobile",
|
||||
sourceAsset: "Source asset",
|
||||
targetKind: "Target type",
|
||||
network: "Company / site network",
|
||||
asset: "Specific device",
|
||||
resource: "Specific resource",
|
||||
target: "Target",
|
||||
site: "Site (optional)",
|
||||
allCompany: "Company network",
|
||||
method: "Method or tool",
|
||||
methodExample: "VPN, RDP, AnyDesk…",
|
||||
requiresVpn: "Requires a prior VPN connection",
|
||||
vpnRequired: "Prior VPN required",
|
||||
vpnNote: "The required VPN connection must be checked separately.",
|
||||
authorizationMissing:
|
||||
"The person has no current telework authorization. Review the existing access.",
|
||||
review:
|
||||
"Review: removal is pending, authorization is not current, or the person, source or target is unavailable.",
|
||||
search: "Search person, source, target or tool…",
|
||||
empty: "No remote access has been recorded yet.",
|
||||
showClosed: "Also show removed and never configured access",
|
||||
fixed:
|
||||
"The person, source and target are fixed. For a different route, close this access and create a new one.",
|
||||
history: "Access history",
|
||||
manage: "Manage remote access",
|
||||
newAsset: "New asset",
|
||||
newResource: "New resource",
|
||||
help: "What is this access for?",
|
||||
description:
|
||||
"Document how a person connects from outside to a company network, device or resource.",
|
||||
scope:
|
||||
"This is the person's access for work. Technician assistance is separate. Personal devices are identified only by type and are not company inventory assets.",
|
||||
examples:
|
||||
"Company laptop → VPN → site network. Personal computer → RDP → office PC, with prior VPN. Personal mobile → a specific resource.",
|
||||
guidance:
|
||||
"Custom tool names are allowed. Configured describes documented access and does not grant permission. You can record access that still works after authorization ends; it will be flagged for review. Folder and application permissions are not duplicated, and real connections are not established or disconnected. Do not store passwords. Saved quick additions remain if this form is cancelled.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Check the person, source, target and tool.",
|
||||
REMOTE_TARGET_IMMUTABLE: "The person, source and target cannot change.",
|
||||
REMOTE_REFERENCE_NOT_FOUND: "Select references from this company.",
|
||||
REMOTE_ALREADY_OPEN:
|
||||
"An open access with this source, target and tool already exists for the person.",
|
||||
INVALID_REMOTE_TRANSITION: "This state change is not allowed.",
|
||||
REMOTE_CONNECTION_NOT_FOUND: "Access not found.",
|
||||
COMPANY_NOT_FOUND: "Company not found.",
|
||||
INTERNAL_ERROR: "The operation could not be completed. Try again.",
|
||||
FORBIDDEN: "Your session does not allow this operation. Sign in again.",
|
||||
UNAUTHENTICATED: "Sign in again.",
|
||||
},
|
||||
};
|
||||
74
modules/companies/src/web/remote/remote-quick-editor.tsx
Normal file
74
modules/companies/src/web/remote/remote-quick-editor.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { ResourceEditor } from "@metis/resources/client";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import { ConfigurationQuickEditor } from "../components/configuration-quick-editor";
|
||||
import { SiteForm } from "../components/site-form";
|
||||
|
||||
export type RemoteQuickKind = "person" | "source" | "target" | "resource" | "site";
|
||||
export function RemoteQuickEditor({
|
||||
kind,
|
||||
company,
|
||||
csrfToken,
|
||||
personId,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
kind: RemoteQuickKind;
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
personId: string;
|
||||
onSaved: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
if (kind === "person" || kind === "source" || kind === "target") {
|
||||
return (
|
||||
<ConfigurationQuickEditor
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
data={undefined}
|
||||
kind={kind === "person" ? "person" : "asset"}
|
||||
onClose={onClose}
|
||||
onCreated={(_, id) => {
|
||||
if (id) {
|
||||
onSaved(id);
|
||||
}
|
||||
}}
|
||||
title={t(kind === "person" ? "newPerson" : "remote.newAsset")}
|
||||
values={{ personId, accountId: "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(kind === "site" ? "newSite" : "remote.newResource")}
|
||||
wide
|
||||
>
|
||||
{kind === "site" ? (
|
||||
<SiteForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={onSaved}
|
||||
showTitle={false}
|
||||
/>
|
||||
) : (
|
||||
<ResourceEditor
|
||||
allowQuickCreate={false}
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
67
modules/companies/src/web/remote/remote-summary.tsx
Normal file
67
modules/companies/src/web/remote/remote-summary.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import type { AssetDTO } from "@metis/assets/common";
|
||||
import type { ResourceDTO } from "@metis/resources/common";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import type { RemoteConnectionDTO, RemoteConnectionInput } from "../../common/remote-connections";
|
||||
|
||||
export function remoteEndpoints(
|
||||
values: RemoteConnectionInput,
|
||||
company: CompanyDTO,
|
||||
assets: readonly AssetDTO[],
|
||||
resources: readonly ResourceDTO[],
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const source =
|
||||
values.sourceKind === "company_asset"
|
||||
? (assets.find((item) => item.id === values.sourceAssetId)?.values.name ??
|
||||
values.sourceAssetId ??
|
||||
"—")
|
||||
: t(`remote.${values.sourceKind}`);
|
||||
const target =
|
||||
values.targetKind === "network"
|
||||
? `${t("remote.network")} · ${company.sites.find((site) => site.id === values.siteId)?.name ?? company.name}`
|
||||
: values.targetKind === "asset"
|
||||
? (assets.find((item) => item.id === values.targetId)?.values.name ??
|
||||
values.targetId ??
|
||||
"—")
|
||||
: (resources.find((item) => item.id === values.targetId)?.values.name ??
|
||||
values.targetId ??
|
||||
"—");
|
||||
return { source, target };
|
||||
}
|
||||
export function RemoteSummary({
|
||||
connection,
|
||||
company,
|
||||
assets,
|
||||
resources,
|
||||
}: {
|
||||
connection: RemoteConnectionDTO;
|
||||
company: CompanyDTO;
|
||||
assets: readonly AssetDTO[];
|
||||
resources: readonly ResourceDTO[];
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const endpoints = remoteEndpoints(connection.values, company, assets, resources, t);
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="break-words font-medium">
|
||||
{endpoints.source} → {connection.values.method} → {endpoints.target}
|
||||
</p>
|
||||
<p>{t(`mail.${connection.values.state}`)}</p>
|
||||
{connection.values.requiresVpn && (
|
||||
<p>
|
||||
{t("remote.vpnRequired")} · {t("remote.vpnNote")}
|
||||
</p>
|
||||
)}
|
||||
{!(
|
||||
connection.authorizationEffective ||
|
||||
["removed", "never_configured"].includes(connection.values.state)
|
||||
) && <p className="text-amber-700">{t("remote.authorizationMissing")}</p>}
|
||||
{connection.needsReview && <p className="text-amber-700">{t("remote.review")}</p>}
|
||||
{connection.values.notes && (
|
||||
<p className="whitespace-pre-wrap text-muted-foreground">{connection.values.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
modules/companies/src/web/remote/remote.test.tsx
Normal file
121
modules/companies/src/web/remote/remote.test.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { inventoryKeys as assetKeys } from "@metis/assets/client";
|
||||
import { inventoryKeys as resourceKeys } from "@metis/resources/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { RemoteConnectionSchema } from "../../common/remote-connections";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
|
||||
import { remoteKeys } from "./remote-api";
|
||||
import { RemoteConnectionForm } from "./remote-connection-form";
|
||||
import { RemoteConnectionsPage } from "./remote-connections-page";
|
||||
import { RemoteSummary } from "./remote-summary";
|
||||
|
||||
const company = {
|
||||
id: "0199b200-0000-7000-8000-000000000001",
|
||||
name: "Empresa test",
|
||||
active: true,
|
||||
people: [],
|
||||
contacts: [],
|
||||
sites: [],
|
||||
departments: [],
|
||||
assignments: [],
|
||||
};
|
||||
async function render(language: string, node: ReactNode) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
client.setQueryData(remoteKeys.company(company.id), { connections: [] });
|
||||
client.setQueryData(assetKeys.company(company.id), {
|
||||
assets: [],
|
||||
workstations: [],
|
||||
relationships: [],
|
||||
});
|
||||
client.setQueryData(resourceKeys.company(company.id), { resources: [] });
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("remotos: consulta sin formularios y traducciones", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<RemoteConnectionsPage company={company} csrfToken="test" />
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
assert.ok(html.includes(text.remote.title));
|
||||
assert.ok(html.includes(text.remote.new));
|
||||
assert.ok(!html.includes("<form"));
|
||||
}
|
||||
});
|
||||
test("remotos: formulario, altas rápidas y ayuda antes del pie", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<RemoteConnectionForm company={company} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
assert.ok(html.includes(text.newPerson));
|
||||
assert.ok(html.includes(text.remote.newAsset));
|
||||
assert.ok(html.includes(text.newSite));
|
||||
assert.ok(html.includes(text.remote.method));
|
||||
assert.ok(html.includes(text.remote.personal_computer));
|
||||
assert.ok(html.indexOf(text.remote.help) > html.indexOf(text.mail.notes));
|
||||
assert.ok(html.indexOf(text.save) > html.indexOf(text.remote.help));
|
||||
}
|
||||
});
|
||||
test("remotos: origen personal, acceso configurado sin autorización y VPN separada", async () => {
|
||||
const values = {
|
||||
personId: company.id,
|
||||
sourceKind: "personal_computer",
|
||||
sourceAssetId: null,
|
||||
targetKind: "network",
|
||||
targetId: null,
|
||||
siteId: null,
|
||||
method: "VPN",
|
||||
requiresVpn: true,
|
||||
state: "configured",
|
||||
notes: null,
|
||||
};
|
||||
const connection = RemoteConnectionSchema.parse({
|
||||
id: company.id,
|
||||
companyId: company.id,
|
||||
values,
|
||||
needsReview: true,
|
||||
authorizationEffective: false,
|
||||
history: [
|
||||
{
|
||||
id: company.id,
|
||||
actorId: company.id,
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-06T12:00:00.000Z",
|
||||
values,
|
||||
},
|
||||
],
|
||||
});
|
||||
const html = await render(
|
||||
"es",
|
||||
<RemoteSummary assets={[]} company={company} connection={connection} resources={[]} />
|
||||
);
|
||||
assert.ok(html.includes(companiesSpanish.remote.personal_computer));
|
||||
assert.ok(html.includes(companiesSpanish.remote.authorizationMissing));
|
||||
assert.ok(html.includes(companiesSpanish.remote.vpnNote));
|
||||
assert.ok(html.includes(companiesSpanish.remote.review));
|
||||
});
|
||||
@ -19,6 +19,7 @@ import {
|
||||
TeleworkAuthorizationInputSchema,
|
||||
} from "../../common/telework-authorizations";
|
||||
import { PersonForm } from "../components/person-form";
|
||||
import { remoteKeys } from "../remote/remote-api";
|
||||
|
||||
import { teleworkApi, teleworkKeys } from "./telework-api";
|
||||
import { TeleworkFeedback } from "./telework-feedback";
|
||||
@ -65,7 +66,10 @@ export function TeleworkAuthorizationForm({
|
||||
mutationFn: (input: TeleworkAuthorizationInput) =>
|
||||
teleworkApi.save(company.id, input, csrfToken, authorization?.id),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: teleworkKeys.company(company.id) });
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: teleworkKeys.company(company.id) }),
|
||||
client.invalidateQueries({ queryKey: remoteKeys.company(company.id) }),
|
||||
]);
|
||||
if (mounted.current) {
|
||||
onSaved();
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ export function ResourceForm({
|
||||
["folder-mappings"],
|
||||
["application-configurations"],
|
||||
["certificate-configurations"],
|
||||
["remote-connections", companyId],
|
||||
].map((queryKey) => client.invalidateQueries({ queryKey }))
|
||||
);
|
||||
if (mounted.current) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user