diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 4722891..d589b64 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -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( diff --git a/apps/server/src/remote.integration.ts b/apps/server/src/remote.integration.ts new file mode 100644 index 0000000..4d1fe0d --- /dev/null +++ b/apps/server/src/remote.integration.ts @@ -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; +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); +} diff --git a/docs/accesos-remotos.md b/docs/accesos-remotos.md new file mode 100644 index 0000000..d77d88c --- /dev/null +++ b/docs/accesos-remotos.md @@ -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. diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index 2fc3721..503b909 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -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). diff --git a/docs/entorno-actual.md b/docs/entorno-actual.md index fcf9260..a0e4e82 100644 --- a/docs/entorno-actual.md +++ b/docs/entorno-actual.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. diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index fdfab66..1c15020 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -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. diff --git a/docs/teletrabajo.md b/docs/teletrabajo.md index aa3f1f5..b892acb 100644 --- a/docs/teletrabajo.md +++ b/docs/teletrabajo.md @@ -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 diff --git a/modules/assets/src/web/asset-form.tsx b/modules/assets/src/web/asset-form.tsx index 6e89a2a..2a5b4f9 100644 --- a/modules/assets/src/web/asset-form.tsx +++ b/modules/assets/src/web/asset-form.tsx @@ -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); } diff --git a/modules/companies/package.json b/modules/companies/package.json index 8b5aecf..07becee 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -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:*", diff --git a/modules/companies/src/api/application/remote-connection-input.mapper.ts b/modules/companies/src/api/application/remote-connection-input.mapper.ts new file mode 100644 index 0000000..7c5bef9 --- /dev/null +++ b/modules/companies/src/api/application/remote-connection-input.mapper.ts @@ -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; +} diff --git a/modules/companies/src/api/application/remote-connection-policy.ts b/modules/companies/src/api/application/remote-connection-policy.ts new file mode 100644 index 0000000..c320f19 --- /dev/null +++ b/modules/companies/src/api/application/remote-connection-policy.ts @@ -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)) + ); +} diff --git a/modules/companies/src/api/application/remote-connection-response.builder.ts b/modules/companies/src/api/application/remote-connection-response.builder.ts new file mode 100644 index 0000000..317527f --- /dev/null +++ b/modules/companies/src/api/application/remote-connection-response.builder.ts @@ -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(), + })), + }; +} diff --git a/modules/companies/src/api/application/remote-connection.repository.ts b/modules/companies/src/api/application/remote-connection.repository.ts new file mode 100644 index 0000000..98a701f --- /dev/null +++ b/modules/companies/src/api/application/remote-connection.repository.ts @@ -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; + today: string; +}>; +export interface IRemoteConnectionRepository { + get(company: UniqueID): Promise>; + save( + company: UniqueID, + action: (context: RemoteContext) => Result + ): Promise< + Result<{ connection: RemoteConnection; context: RemoteContext }, RemoteConnectionError> + >; +} diff --git a/modules/companies/src/api/application/remote-connection.use-cases.ts b/modules/companies/src/api/application/remote-connection.use-cases.ts new file mode 100644 index 0000000..9f708fe --- /dev/null +++ b/modules/companies/src/api/application/remote-connection.use-cases.ts @@ -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); + }); + } +} diff --git a/modules/companies/src/api/domain/remote-connection-revision.entity.ts b/modules/companies/src/api/domain/remote-connection-revision.entity.ts new file mode 100644 index 0000000..6342bc1 --- /dev/null +++ b/modules/companies/src/api/domain/remote-connection-revision.entity.ts @@ -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 { + 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; + } +} diff --git a/modules/companies/src/api/domain/remote-connection-values.ts b/modules/companies/src/api/domain/remote-connection-values.ts new file mode 100644 index 0000000..6fda13c --- /dev/null +++ b/modules/companies/src/api/domain/remote-connection-values.ts @@ -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; +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; diff --git a/modules/companies/src/api/domain/remote-connection.aggregate.ts b/modules/companies/src/api/domain/remote-connection.aggregate.ts new file mode 100644 index 0000000..8714c84 --- /dev/null +++ b/modules/companies/src/api/domain/remote-connection.aggregate.ts @@ -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 { + 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 { + 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 { + 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 + ) + ); + } +} diff --git a/modules/companies/src/api/domain/remote-connection.test.ts b/modules/companies/src/api/domain/remote-connection.test.ts new file mode 100644 index 0000000..f5efd2b --- /dev/null +++ b/modules/companies/src/api/domain/remote-connection.test.ts @@ -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() })) + ) + ); +}); diff --git a/modules/companies/src/api/domain/value-objects/remote-connection-values.ts b/modules/companies/src/api/domain/value-objects/remote-connection-values.ts new file mode 100644 index 0000000..1a7baa6 --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/remote-connection-values.ts @@ -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, + public readonly targetKind: RemoteConnectionInput["targetKind"], + public readonly targetId: Maybe, + public readonly siteId: Maybe, + public readonly method: Name, + public readonly requiresVpn: boolean, + public readonly state: RemoteConnectionInput["state"], + public readonly notes: Maybe + ) { + Object.freeze(this); + } + static create( + input: RemoteConnectionInput + ): Result { + 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, + }; + } +} diff --git a/modules/companies/src/api/infrastructure/http/remote-connection.routes.ts b/modules/companies/src/api/infrastructure/http/remote-connection.routes.ts new file mode 100644 index 0000000..62d8979 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/remote-connection.routes.ts @@ -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); +} diff --git a/modules/companies/src/api/infrastructure/persistence/remote-connection-migration.ts b/modules/companies/src/api/infrastructure/persistence/remote-connection-migration.ts new file mode 100644 index 0000000..836976d --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/remote-connection-migration.ts @@ -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", + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/remote-connection-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/remote-connection-persistence.mapper.ts new file mode 100644 index 0000000..90e3844 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/remote-connection-persistence.mapper.ts @@ -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"), + }; +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts new file mode 100644 index 0000000..ce8d967 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-remote-connection.repository.ts @@ -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> { + 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( + "SELECT * FROM remote_connections WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const revisions = await this.database.query( + "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( + "SELECT * FROM telework_authorizations WHERE companyId=:companyId", + { replacements: { companyId }, transaction, type: QueryTypes.SELECT } + ); + const authRevisions = await this.database.query( + "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[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 }); + }); + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index 8f9f195..bde3975 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -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[0]) => { + registerRemoteConnectionRoutes(app, remote, authenticate); registerWorkProfileRoutes(app, profiles, authenticate); registerFolderMappingRoutes(app, folders, authenticate); registerPrinterConfigurationRoutes(app, printers, authenticate); diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index f52cb64..4a4626e 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -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"; diff --git a/modules/companies/src/common/remote-connections.ts b/modules/companies/src/common/remote-connections.ts new file mode 100644 index 0000000..4d2c09c --- /dev/null +++ b/modules/companies/src/common/remote-connections.ts @@ -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; diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index df045c3..9349063 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -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 ; case "telework": return ; case "people": diff --git a/modules/companies/src/web/components/person-form.tsx b/modules/companies/src/web/components/person-form.tsx index 048686b..3cd6846 100644 --- a/modules/companies/src/web/components/person-form.tsx +++ b/modules/companies/src/web/components/person-form.tsx @@ -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 diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index a9613e7..4c3b0d9 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -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, diff --git a/modules/companies/src/web/profiles/work-environment-page.tsx b/modules/companies/src/web/profiles/work-environment-page.tsx index bde73fd..aac8d4b 100644 --- a/modules/companies/src/web/profiles/work-environment-page.tsx +++ b/modules/companies/src/web/profiles/work-environment-page.tsx @@ -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({ 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({ /> + + +
+

{t("remote.title")}

+ +
+ {!remoteConnections.length && ( +

{t("remote.empty")}

+ )} +
    + {remoteConnections.map((item) => ( +
  • + +
  • + ))} +
+
+
{(["configured", "pending", "removal", "review"] as const).map((key) => ( diff --git a/modules/companies/src/web/profiles/work-environment.test.tsx b/modules/companies/src/web/profiles/work-environment.test.tsx index c5f0dee..66b2735 100644 --- a/modules/companies/src/web/profiles/work-environment.test.tsx +++ b/modules/companies/src/web/profiles/work-environment.test.tsx @@ -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") + ) + ); +}); diff --git a/modules/companies/src/web/profiles/work-profiles-page.tsx b/modules/companies/src/web/profiles/work-profiles-page.tsx index f8098c8..802c2c4 100644 --- a/modules/companies/src/web/profiles/work-profiles-page.tsx +++ b/modules/companies/src/web/profiles/work-profiles-page.tsx @@ -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" && ( + + )} {selection.kind === "telework" && ( ["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)); + }, +}; diff --git a/modules/companies/src/web/remote/remote-connection-form.tsx b/modules/companies/src/web/remote/remote-connection-form.tsx new file mode 100644 index 0000000..70a0c42 --- /dev/null +++ b/modules/companies/src/web/remote/remote-connection-form.tsx @@ -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; + +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(); + 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({ + 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 ( + <> +
mutation.mutate(values))}> +
+
+ + 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} + /> +
+
+ +
+ + + + {["VPN", "RDP", "AnyDesk", "TeamViewer", "Supremo"].map((method) => ( + +
+
+ + { + 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} + /> +
+
+
+ form.setValue("requiresVpn", checked === true)} + /> + +
+
+ +