diff --git a/TODO.md b/TODO.md index efd85e0..2f1ebc6 100644 --- a/TODO.md +++ b/TODO.md @@ -438,3 +438,13 @@ Quiero que hagas esta reestructuración pero antes pregunta todo lo que necesite - Quiero que en el navegador web del cliente se muestren rutas para seccion. Por ejemplo: http://192.168.0.2:5173/dashboard, http://192.168.0.2:5173/tickets, http://192.168.0.2:5173/companies/, http://192.168.0.2:5173/companies/xxxx, etc. Analiza qué estructura de ruta es la más adecuada las pantallas de la aplicación y muestrala antes de realizar ningún cambio. Intenta encontar ambiguedades o errores también. Como criterio, guíate por la lógica o por lo más común que se hace en este caso. +- quiero que el estado plegado/desplegado de la barra lateral izquierda se almacene en el almacenamiento local del navegador para que se conserve entre sesiones. + +- revisa el codigo para localizar todos los ficheros en el backend que se refieran a value objetcs de DDD. Debes renombrarlos de esta manera: + +email-address.ts => email-address.vo.ts +unique-id.ts => unique-id.vo.ts + + + + diff --git a/apps/web/package.json b/apps/web/package.json index eae1e60..8275baf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite --host --clearScreen false", "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx src/i18n.test.ts", + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx src/i18n.test.ts src/navigation/app-redirect.test.ts", "build": "vite build", "preview": "vite preview --host 127.0.0.1" }, diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx index 95d3efa..4670389 100644 --- a/apps/web/src/app.test.tsx +++ b/apps/web/src/app.test.tsx @@ -143,9 +143,9 @@ test("solo el administrador inicial ve la navegación de gestión de usuarios", assert.ok(normal.includes("Tu espacio de soporte")); assert.ok(normal.includes("Mi contraseña")); assert.ok(normal.includes("Salir")); - assert.equal(normal.includes(">Usuarios"), false); + assert.equal(normal.includes('href="/settings/users"'), false); const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } }); - assert.ok(admin.includes(">Usuarios")); + assert.ok(admin.includes('href="/settings/users"')); }); test("el espacio abre Inicio tras el login y mantiene acceso a las compañías sin formularios visibles", async () => { diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 5798f78..3e90348 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -3,6 +3,7 @@ import { IdentityWorkspace, identityApi, identityKeys } from "@metis/identity/cl import { useQuery } from "@tanstack/react-query"; import { LanguageSwitcher } from "./language-switcher"; +import { useAppNavigation } from "./navigation/use-app-navigation"; export function App() { const session = useQuery({ @@ -10,6 +11,7 @@ export function App() { queryFn: identityApi.session, retry: false, }); + useAppNavigation(session.data); return (
{!session.data?.user && ( diff --git a/apps/web/src/navigation/app-redirect.test.ts b/apps/web/src/navigation/app-redirect.test.ts new file mode 100644 index 0000000..c1c533c --- /dev/null +++ b/apps/web/src/navigation/app-redirect.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { SessionResponseDTO } from "@metis/identity/common"; + +import { appRedirect } from "./app-redirect"; + +const ANONYMOUS: SessionResponseDTO = { + user: null, + csrfToken: "a".repeat(64), + setupRequired: false, +}; +const AUTHENTICATED: SessionResponseDTO = { + ...ANONYMOUS, + user: { + id: "019b8838-44af-776d-a406-af74452b6f26", + name: "Test", + email: "test@example.test", + active: true, + isInitialAdmin: false, + }, +}; + +test("el acceso restaura el destino solicitado, sin bucles ni redirecciones externas", () => { + const destination = "/companies/019b8838-44af-776d-a406-af74452b6f26/people"; + const login = `/login?${new URLSearchParams({ returnTo: destination })}`; + assert.equal(appRedirect(destination, ANONYMOUS), login); + assert.equal(appRedirect(login, ANONYMOUS), undefined); + assert.equal(appRedirect(login, AUTHENTICATED), destination); + for (const returnTo of [ + "//example.test", + "https://example.test", + "/\\example.test", + "/login", + "/setup?returnTo=/login", + ]) { + assert.equal( + appRedirect(`/login?${new URLSearchParams({ returnTo })}`, AUTHENTICATED), + "/dashboard" + ); + } +}); + +test("setup depende del servidor y las rutas canónicas no conservan barras finales", () => { + assert.equal(appRedirect("/login", { ...ANONYMOUS, setupRequired: true }), "/setup"); + assert.equal(appRedirect("/setup", ANONYMOUS), "/login"); + assert.equal(appRedirect("/", AUTHENTICATED), "/dashboard"); + assert.equal( + appRedirect("/companies/?activeOnly=true", AUTHENTICATED), + "/companies?activeOnly=true" + ); + assert.equal(appRedirect("/catalogs", AUTHENTICATED), undefined); +}); diff --git a/apps/web/src/navigation/app-redirect.ts b/apps/web/src/navigation/app-redirect.ts new file mode 100644 index 0000000..7616bc4 --- /dev/null +++ b/apps/web/src/navigation/app-redirect.ts @@ -0,0 +1,34 @@ +import { isInternalPath } from "@metis/core/client"; +import type { SessionResponseDTO } from "@metis/identity/common"; + +function safeReturnTo(value: string | null | undefined): string | undefined { + return value && isInternalPath(value) && !/^\/(login|setup)(?:[/?]|$)/u.test(value) + ? value + : undefined; +} + +function accessHref(target: string, requested: string | null) { + const returnTo = safeReturnTo(requested); + return `${target}${returnTo && returnTo !== "/" ? `?${new URLSearchParams({ returnTo })}` : ""}`; +} + +export function appRedirect(location: string, session: SessionResponseDTO): string | undefined { + const url = new URL(location, "http://metis.invalid"); + const pathname = url.pathname.replace(/\/+$/u, "") || "/"; + const accessPage = pathname === "/login" || pathname === "/setup"; + if (!session.user) { + const target = session.setupRequired ? "/setup" : "/login"; + const requested = accessPage ? url.searchParams.get("returnTo") : location; + const destination = accessHref(target, requested); + return destination === location ? undefined : destination; + } + if (accessPage) { + const requested = url.searchParams.get("returnTo"); + return safeReturnTo(requested) ?? "/dashboard"; + } + if (pathname === "/") { + return `/dashboard${url.search}`; + } + const canonical = `${pathname}${url.search}`; + return canonical === location ? undefined : canonical; +} diff --git a/apps/web/src/navigation/use-app-navigation.ts b/apps/web/src/navigation/use-app-navigation.ts new file mode 100644 index 0000000..4068e74 --- /dev/null +++ b/apps/web/src/navigation/use-app-navigation.ts @@ -0,0 +1,18 @@ +import { navigateTo, useBrowserLocation } from "@metis/core/client"; +import type { SessionResponseDTO } from "@metis/identity/common"; +import { useEffect } from "react"; + +import { appRedirect } from "./app-redirect"; + +export function useAppNavigation(session: SessionResponseDTO | undefined) { + const location = useBrowserLocation(); + useEffect(() => { + if (!session) { + return; + } + const destination = appRedirect(location, session); + if (destination) { + navigateTo(destination, { replace: true }); + } + }, [location, session]); +} diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index b4cdca5..864be3c 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -17,6 +17,9 @@ El código se genera desde el registro oficial y queda versionado en este reposi ## Convención de navegación +Los destinos de secciones y fichas existentes siguen [Rutas del cliente](rutas-del-cliente.md). +Usar enlaces reales para esos destinos y botones para acciones o diálogos locales. + Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios se abren en modal como norma general, con anchura adaptable y desplazamiento si el contenido lo requiere. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas, los departamentos y las asignaciones se crean y editan en modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui. TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos. @@ -33,6 +36,14 @@ Usar tokens semánticos como bg-background, text-foreground y border-border en v ## Añadir componentes +`SectionNavigation`, en `modules/companies/src/web/shared/ui`, compone Card de la +biblioteca y BrowserLink de core para índices de secciones. Recibe una etiqueta +de navegación y elementos con `href`, título, descripción y valor numérico +opcionales. No conoce DTO, consultas ni endpoints; sus consumidores aportan los +textos traducidos y únicamente valores conocidos. Se reutiliza en el resumen de +compañía y en los índices de catálogos globales y de compañía, con foco visible y +enlaces que pueden abrirse en otra pestaña. No se modifica el paquete vendor. + Para componentes propios de un módulo, consultar primero la [estructura web por entidades y operaciones](estructura-web-por-entidades-y-operaciones.md) y los componentes equivalentes existentes. Ubicar cada componente en la entidad y operación diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md index a03fc5f..493a2dd 100644 --- a/docs/patron-de-listados-de-entidades.md +++ b/docs/patron-de-listados-de-entidades.md @@ -50,7 +50,7 @@ Carga inicial y errores se presentan dentro del contenido sin afirmar que no hay ## Inventario de listados y ubicaciones actuales -La aplicación navega por espacios y secciones en memoria, no mediante rutas URL independientes. Los componentes siguientes se alcanzan desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace. +La aplicación navega por espacios y secciones mediante rutas URL independientes, según [Rutas del cliente](rutas-del-cliente.md). Los componentes siguientes se componen desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace; altas, ediciones e historiales conservan sus modales. | Listado / ámbito | Componente y ubicación actual | Aplicación | | --- | --- | --- | @@ -102,7 +102,7 @@ La validación debe incluir tipos, estilo, pruebas y compilación, además de in ### Migración realizada -Se han adaptado las 25 familias de listados del inventario y el sublistado de asignaciones departamentales. Los catálogos y accesos reutilizan sus componentes para las distintas secciones y ámbitos; no son nuevas rutas ni nuevas entidades. El inventario conserva la ficha lateral y su modal móvil. Las fichas, historiales y bloques contextuales de las excepciones mantienen su función. +Se han adaptado las 25 familias de listados del inventario y el sublistado de asignaciones departamentales. Los catálogos y accesos reutilizan sus componentes para las distintas secciones y ámbitos; las rutas distinguen el ámbito sin crear nuevas entidades. El inventario conserva la ficha lateral y su modal móvil. Las fichas, historiales y bloques contextuales de las excepciones mantienen su función. Componentes compartidos: EntityListHeader y EntityTable; EntityTableConfiguration para columnas y orden; EntityTableContent para tabla semántica; EntityListPagination, EntityRowActions, EntityAction y EntityStatus para controles. useEntityTable concentra filtros locales, ordenación estable, columnas visibles y paginación. entity-list.ts contiene textos es/en y cálculo de rangos. No se añade selección masiva sin una operación que la consuma. diff --git a/docs/puesta-en-marcha.md b/docs/puesta-en-marcha.md index debbb3f..0ddb423 100644 --- a/docs/puesta-en-marcha.md +++ b/docs/puesta-en-marcha.md @@ -17,6 +17,11 @@ pnpm db:up genera .env con secretos locales si no existe e inicia MariaDB en un Abrir http://localhost:5173. La primera apertura permite crear el administrador inicial; las siguientes muestran el acceso. La API escucha en 127.0.0.1:3001 y la base de datos en 127.0.0.1:3307. +Una vez autenticado, Inicio utiliza `/dashboard` y cada sección tiene URL propia. +Se pueden abrir o recargar directamente `/tickets`, `/catalogs` o +`/companies/:companyId`. Consultar [Rutas del cliente](rutas-del-cliente.md) para +las fichas, los filtros y el requisito de fallback de la SPA en producción. + Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inicial y protección de cuentas. No borrar el volumen para actualizar o reiniciar: contiene usuarios, compañías, contactos y sedes. Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env. diff --git a/docs/rutas-del-cliente.md b/docs/rutas-del-cliente.md new file mode 100644 index 0000000..59e3865 --- /dev/null +++ b/docs/rutas-del-cliente.md @@ -0,0 +1,157 @@ +# Rutas del cliente + +La URL del navegador identifica el espacio, la sección y, cuando existe una ficha, +el registro seleccionado. No cambia los endpoints HTTP de `/api`, los permisos ni +la persistencia. Los identificadores de compañía y de registros son UUID. + +## Destinos generales + +| Ruta | Pantalla | +| --- | --- | +| `/dashboard` | Inicio y resumen global; admite `companyId` para limitar el ámbito. | +| `/companies` | Directorio de compañías. | +| `/companies/:companyId` | Estado y resumen operativo de la compañía. | +| `/tickets` | Tickets de todas las compañías, con filtros. | +| `/interventions` | Intervenciones de todas las compañías, con filtros. | +| `/catalogs` | Índice explicativo de catálogos globales. | +| `/settings/users` | Gestión de usuarios, reservada al administrador inicial. | +| `/settings/password` | Cambio de contraseña del usuario conectado. | +| `/login` | Acceso cuando no hay sesión. | +| `/setup` | Alta del administrador inicial únicamente cuando el servidor la requiere. | + +`/` se normaliza a `/dashboard`. Las barras finales se eliminan conservando la +consulta. El acceso sin sesión conserva el destino interno en `returnTo`; tras +autenticar se recupera ese destino. No se aceptan redirecciones a otros orígenes. + +## Espacio de compañía + +Todas las secciones siguientes cuelgan de `/companies/:companyId`: + +```text +/companies/:companyId → resumen, no inventario +├── assets +├── resources +├── workstations +├── work-profiles +├── telework-authorizations +├── remote-connections +├── processes +├── intervention-templates +├── interventions +├── tickets +├── people +├── departments +├── sites +├── access +├── folder-mappings +├── certificate-configurations +├── application-configurations +├── printer-configurations +├── mail-configurations +└── catalogs → índice de catálogos de la compañía + ├── assets + └── resources +``` + +`assets` contiene equipos concretos. `workstations` contiene puestos que agrupan +activos exclusivos o compartidos; no es un alias de activos. `work-profiles` +representa el entorno de una persona en un dispositivo. Sus rutas independientes +respetan las responsabilidades existentes, sin introducir entidades nuevas. + +Las fichas existentes de activos, recursos, personas, perfiles, procesos, tickets +e intervenciones utilizan `/
/:entityId`. Las de tickets e intervenciones +se abren en su compañía incluso cuando proceden del listado global. El inventario +conserva el panel lateral en escritorio y el diálogo de ficha en anchuras menores +de 1100 px, ambos asociados al UUID de la URL. + +Las configuraciones de un perfil tienen rutas contextuales: + +```text +/companies/:companyId/work-profiles/:profileId +├── folder-mappings +├── mail-configurations +├── certificate-configurations +├── application-configurations +└── printer-configurations +``` + +Los accesos remotos y el teletrabajo de una persona se consultan en sus secciones +propias con `personId`. Las vistas generales de configuraciones admiten `profileId`. +No se crean fichas URL para entidades que actualmente solo tienen editor o historial. + +## Catálogos globales + +```text +/catalogs +├── access +├── assets +├── resources +├── brands +├── intervention-templates +└── process-templates +``` + +El índice ofrece enlaces y explica el ámbito; no inventa estadísticas. Los catálogos +de compañía representan las variantes de activos y recursos disponibles en ese +ámbito, no duplicados de las rutas de inventario. + +## Filtros y estado temporal + +La consulta conserva los filtros que ya están conectados a la URL: + +- Dashboard y directorio: `companyId`; directorio también `activeOnly` y `q`. +- Tickets: `companyId`, `pendingOnly`, `q`, `state`, `priority` y `assignee`. +- Intervenciones: `companyId`, `pendingOnly`, `q`, `state`, `assignee`, `dateFrom` y `dateTo`. +- Activos y recursos: `q`. +- Perfiles y procesos: `q`, con `archived` o `closed`, respectivamente. +- Catálogos de activos y recursos: `q` y `tab`; `relations` solo en activos. +- Cuentas y accesos: `q` y `kind`, respetando los tipos disponibles en cada ámbito. + +Los booleanos se serializan como `true` o `false`; las fechas como ISO `YYYY-MM-DD`. +`assignee` admite UUID o `unassigned`. Los estados y prioridades se validan con los +schemas compartidos del dominio; los UUID, las pestañas y los parámetros repetidos +o desconocidos se rechazan antes de seleccionar una pantalla. La búsqueda admite +hasta 200 caracteres. Un `companyId` de consulta no puede contradecir la compañía +del pathname. Esta validación del navegador no sustituye la autorización del servidor. + +Cambiar filtros reemplaza la entrada actual del historial; abrir otra sección o +ficha añade una entrada. Atrás y Adelante recuperan los destinos y filtros. Cerrar +una ficha vuelve al destino interno anterior; si se abrió directamente, sustituye +la entrada por su listado o por el contexto del perfil correspondiente. + +Altas, ediciones, confirmaciones, historiales y detalles auxiliares del dashboard +siguen siendo diálogos locales. No se añaden rutas `/new`, `/edit` ni parámetros +`view` sin una necesidad concreta. El alta rápida que cambia de sección lleva una +señal temporal en `history.state`, consumida cuando monta la pantalla que abre el +formulario, sin introducirla en la URL compartida. Los restantes filtros locales, +la paginación, ordenación y preferencias de columnas mantienen su estado existente. + +## Implementación y futuros destinos + +- `@metis/core/client` centraliza la History API, suscripciones con cleanup, + enlaces internos, búsqueda y selección de fichas. No se añade una dependencia + de routing externa. +- `workspace/shared/helpers/workspace-routes.ts` es la fuente de rutas de compañía, + construcción de destinos y validación del espacio. No repartir condiciones de + pathname entre componentes de presentación. +- `apps/web/src/navigation` coordina los destinos según la sesión. El servidor + mantiene su autenticación, autorización y validación HTTP existentes. +- `BrowserLink` produce enlaces reales y conserva abrir en otra pestaña, clic + modificado y navegación por teclado. Las secciones activas indican `aria-current`. +- Un UUID válido ausente en el ámbito muestra ausencia explícita: no selecciona + otro registro silenciosamente. Rutas mal formadas y destinos desconocidos tienen + estados diferenciados y acceso de regreso a Inicio. + +Para añadir una sección, actualizar los contratos y el mapa del espacio, revisar sus +consumidores y probar construcción, acceso directo, recarga, historial y casos +inválidos. No cambiar el modelo de dominio únicamente para reproducir una jerarquía URL. +La organización de ficheros sigue el [estándar web](estructura-web-por-entidades-y-operaciones.md). + +## Servicio de la SPA + +Vite en desarrollo y en preview entrega la SPA para estas rutas. Un servidor de +producción debe servir `index.html` para los destinos del cliente y mantener el +tratamiento propio de `/api` y de los archivos estáticos. Sin ese fallback, abrir +o recargar una ficha dará un 404 del alojamiento aunque la navegación interna funcione. +La API compilada todavía no sirve el frontend estático: no se ha cambiado su +despliegue ni añadido un fallback a Express en esta adaptación. diff --git a/modules/assets/package.json b/modules/assets/package.json index 30af365..4bf87d7 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -13,6 +13,7 @@ "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx" }, "dependencies": { + "@metis/core": "workspace:*", "@repo/rdx-ddd": "workspace:*", "@repo/rdx-utils": "workspace:*", "@repo/shadcn-ui": "workspace:*", diff --git a/modules/assets/src/web/assets/list/controllers/use-compact-inventory.ts b/modules/assets/src/web/assets/list/controllers/use-compact-inventory.ts new file mode 100644 index 0000000..e561019 --- /dev/null +++ b/modules/assets/src/web/assets/list/controllers/use-compact-inventory.ts @@ -0,0 +1,15 @@ +import { useSyncExternalStore } from "react"; + +const COMPACT_INVENTORY = "(max-width: 1099px)"; +function subscribe(listener: () => void) { + const media = window.matchMedia(COMPACT_INVENTORY); + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); +} +export function useCompactInventory() { + return useSyncExternalStore( + subscribe, + () => window.matchMedia(COMPACT_INVENTORY).matches, + () => false + ); +} diff --git a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts index 98c3f8b..d2c2ed8 100644 --- a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts +++ b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts @@ -1,3 +1,4 @@ +import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,6 +8,8 @@ import { inventoryKeys } from "../../../shared/queries"; import { getAssetInventory } from "../api"; import type { AssetInventoryPageProps } from "../types"; +import { useCompactInventory } from "./use-compact-inventory"; + export function useAssetInventoryPageController({ companyId, lookups, @@ -22,13 +25,30 @@ export function useAssetInventoryPageController({ queryKey: catalogKeys.scope(companyId), queryFn: () => catalogApi.get(companyId), }); - const [search, setSearch] = useState(""); + const { searchParams, setSearchParams } = useBrowserSearchParams(); + const search = searchParams.get("q") ?? ""; + const setSearch = (value: string) => + setSearchParams((params) => { + if (value) { + params.set("q", value); + } else { + params.delete("q"); + } + }); const [editor, setEditor] = useState(initialCreate ? "new" : undefined); - const [viewId, setViewId] = useState(); - const [selectedId, setSelectedId] = useState(initialId); + const compact = useCompactInventory(); + const [localViewId, setLocalViewId] = useState(); + const { + selectedId, + selectEntity: setSelectedId, + entityHref, + routed, + } = useEntityNavigation(`/companies/${companyId}/assets`, initialId); + const viewId = compact && routed ? selectedId : localViewId; + const setViewId = compact && routed ? setSelectedId : setLocalViewId; const [detailOpen, setDetailOpen] = useState(true); const assets = query.data?.assets ?? []; - const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0]; + const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0]; const edited = assets.find((asset) => asset.id === editor); const viewed = assets.find((asset) => asset.id === viewId); const filtered = assets.filter((asset) => { @@ -44,6 +64,8 @@ export function useAssetInventoryPageController({ }); return { query, + entityHref, + missingView: Boolean(selectedId && query.isSuccess && !selected), catalog, search, setSearch, diff --git a/modules/assets/src/web/assets/list/ui/inventory-page.tsx b/modules/assets/src/web/assets/list/ui/inventory-page.tsx index 7dbfe1b..76734b3 100644 --- a/modules/assets/src/web/assets/list/ui/inventory-page.tsx +++ b/modules/assets/src/web/assets/list/ui/inventory-page.tsx @@ -1,3 +1,4 @@ +import { BrowserLink, EntityNotFound } from "@metis/core/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { EntityAction, @@ -37,8 +38,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { edited, viewed, filtered, + missingView, + entityHref, } = useAssetInventoryPageController(props); const { t, i18n } = useTranslation("assets"); + if (missingView) { + return ; + } return (
asset.values.name, hideable: false, render: (asset) => ( - + ), }, { diff --git a/modules/assets/src/web/catalog/list/controllers/use-catalog-page-controller.ts b/modules/assets/src/web/catalog/list/controllers/use-catalog-page-controller.ts index 918d5ef..d0d6238 100644 --- a/modules/assets/src/web/catalog/list/controllers/use-catalog-page-controller.ts +++ b/modules/assets/src/web/catalog/list/controllers/use-catalog-page-controller.ts @@ -1,3 +1,4 @@ +import { useBrowserSearchParams } from "@metis/core/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; @@ -8,8 +9,27 @@ import type { AssetCatalogPageProps } from "../types"; export function useAssetCatalogPageController({ scope, csrfToken }: AssetCatalogPageProps) { const client = useQueryClient(); - const [tab, setTab] = useState<"field" | "template" | "relation">("template"); - const [search, setSearch] = useState(""); + const { searchParams, setSearchParams } = useBrowserSearchParams(); + const requestedTab = searchParams.get("tab"); + const tab: "field" | "template" | "relation" = + requestedTab === "fields" ? "field" : requestedTab === "relations" ? "relation" : "template"; + const setTab = (value: "field" | "template" | "relation") => + setSearchParams((params) => { + if (value === "template") { + params.delete("tab"); + } else { + params.set("tab", value === "field" ? "fields" : "relations"); + } + }); + const search = searchParams.get("q") ?? ""; + const setSearch = (value: string) => + setSearchParams((params) => { + if (value) { + params.set("q", value); + } else { + params.delete("q"); + } + }); const [viewId, setViewId] = useState(); const [editor, setEditor] = useState(); const query = useQuery({ diff --git a/modules/companies/package.json b/modules/companies/package.json index 99e1424..f805e50 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,9 +10,10 @@ }, "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/companies/shared/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/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts" + "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/companies/shared/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/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts" }, "dependencies": { + "@metis/core": "workspace:*", "@hookform/resolvers": "5.2.2", "@metis/assets": "workspace:*", "@metis/resources": "workspace:*", diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 7d9de01..7edfea1 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -1,5 +1,47 @@ { "companies": { + "navigation": { + "invalidRoute": "Invalid address", + "notFound": "Page not found", + "routeHelp": "Check the address or return to the dashboard." + }, + "companyOverview": { + "title": "Company overview", + "description": "Status, pending work and alerts for {{name}}.", + "directory": "Company information" + }, + "catalogOverview": { + "globalTitle": "Global catalogs", + "companyTitle": "Company catalogs", + "globalDescription": "Shared definitions for recording and managing data across companies.", + "companyDescription": "Browse available global definitions and this company's own variants.", + "items": { + "access": { + "title": "Access", + "description": "Systems, resource types, permissions and access templates." + }, + "assets": { + "title": "Assets", + "description": "Fields, templates and relationship types for the inventory." + }, + "resources": { + "title": "Resources", + "description": "Fields and templates for recording resources." + }, + "brands": { + "title": "Brands", + "description": "Shared brands for the asset inventory." + }, + "intervention-templates": { + "title": "Intervention templates", + "description": "Reusable procedures for technical interventions." + }, + "process-templates": { + "title": "Process templates", + "description": "Checklists for onboarding, changes and offboarding." + } + } + }, "interventionTemplates": { "title": "Intervention templates", "new": "New intervention template", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 682adbb..1749d1c 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -1,5 +1,47 @@ { "companies": { + "navigation": { + "invalidRoute": "La dirección no es válida", + "notFound": "No se ha encontrado esta página", + "routeHelp": "Comprueba la dirección o vuelve al inicio." + }, + "companyOverview": { + "title": "Resumen de la compañía", + "description": "Estado, trabajo pendiente y avisos de {{name}}.", + "directory": "Información de la compañía" + }, + "catalogOverview": { + "globalTitle": "Catálogos globales", + "companyTitle": "Catálogos de la compañía", + "globalDescription": "Definiciones compartidas para registrar y gestionar datos de todas las compañías.", + "companyDescription": "Consulta las definiciones globales disponibles y las variantes propias de esta compañía.", + "items": { + "access": { + "title": "Accesos", + "description": "Sistemas, tipos de recursos, permisos y plantillas de acceso." + }, + "assets": { + "title": "Activos", + "description": "Campos, plantillas y tipos de relaciones para el inventario." + }, + "resources": { + "title": "Recursos", + "description": "Campos y plantillas para registrar recursos." + }, + "brands": { + "title": "Marcas", + "description": "Marcas compartidas del inventario de activos." + }, + "intervention-templates": { + "title": "Plantillas de intervenciones", + "description": "Procedimientos reutilizables para las intervenciones técnicas." + }, + "process-templates": { + "title": "Plantillas de procesos", + "description": "Listas de comprobación para altas, cambios y salidas de personas." + } + } + }, "interventionTemplates": { "title": "Plantillas de intervención", "new": "Nueva plantilla de intervención", diff --git a/modules/companies/src/web/access/list/controllers/use-access-workspace-controller.ts b/modules/companies/src/web/access/list/controllers/use-access-workspace-controller.ts index aa9f6f7..bea8dce 100644 --- a/modules/companies/src/web/access/list/controllers/use-access-workspace-controller.ts +++ b/modules/companies/src/web/access/list/controllers/use-access-workspace-controller.ts @@ -1,8 +1,10 @@ +import { useBrowserSearchParams } from "@metis/core/client"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import type { AccessKind } from "../../../../common"; import { accessFields, accessLabel } from "../../shared/helpers"; +import { accessKinds } from "../../shared/helpers/access-kinds"; import { accessKeys } from "../../shared/queries"; import { getAccess } from "../api"; import type { AccessWorkspacePageProps } from "../types"; @@ -12,30 +14,30 @@ export function useAccessWorkspacePageController({ scope, people = [] }: AccessW queryKey: accessKeys.scope(scope), queryFn: () => getAccess(scope), }); - const [kind, setKind] = useState(scope === "global" ? "system" : "account"); + const { searchParams, setSearchParams } = useBrowserSearchParams(); const [editor, setEditor] = useState(); const [history, setHistory] = useState(); const [helpOpen, setHelpOpen] = useState(false); - const [search, setSearch] = useState(""); + const search = searchParams.get("q") ?? ""; + const setSearch = (value: string) => + setSearchParams((params) => { + if (value) { + params.set("q", value); + } else { + params.delete("q"); + } + }); + const setKind = (value: AccessKind) => + setSearchParams((params) => { + params.set("kind", value); + }); const data = query.data; const selected = data?.items.find((item) => item.id === editor); const historical = data?.items.find((item) => item.id === history); - const kinds: readonly AccessKind[] = - scope === "global" - ? ["system", "resource_type", "permission", "template"] - : [ - "account", - "account_person", - "group", - "membership", - "grant", - "need", - "coverage", - "environment", - "system", - "resource_type", - "permission", - ]; + const kinds = accessKinds(scope); + const kind = + kinds.find((value) => value === searchParams.get("kind")) ?? + (scope === "global" ? "system" : "account"); const visible = data?.items.filter( (item) => diff --git a/modules/companies/src/web/access/shared/helpers/access-kinds.ts b/modules/companies/src/web/access/shared/helpers/access-kinds.ts new file mode 100644 index 0000000..7b93eaf --- /dev/null +++ b/modules/companies/src/web/access/shared/helpers/access-kinds.ts @@ -0,0 +1,19 @@ +import type { AccessKind } from "../../../../common"; + +export function accessKinds(scope: string): readonly AccessKind[] { + return scope === "global" + ? ["system", "resource_type", "permission", "template"] + : [ + "account", + "account_person", + "group", + "membership", + "grant", + "need", + "coverage", + "environment", + "system", + "resource_type", + "permission", + ]; +} diff --git a/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts b/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts index 326f6a1..a1ecc68 100644 --- a/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts +++ b/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts @@ -8,12 +8,15 @@ import type { CompanyDetailProps } from "../types"; export function useCompanyDetailController({ id, - embedded = false, initialSection, + section, + onSectionChange, }: CompanyDetailProps) { const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => getCompany(id) }); const [editOpen, setEditOpen] = useState(false); - const [tab, setTab] = useState(initialSection ?? (embedded ? "inventory" : "people")); + const [localTab, setLocalTab] = useState(initialSection ?? "overview"); + const tab = section ?? localTab; + const setTab = onSectionChange ?? setLocalTab; const company = query.data?.company; return { query, editOpen, setEditOpen, tab, setTab, company }; diff --git a/modules/companies/src/web/companies/detail/types/company-detail-props.ts b/modules/companies/src/web/companies/detail/types/company-detail-props.ts index ff28788..c4ab1df 100644 --- a/modules/companies/src/web/companies/detail/types/company-detail-props.ts +++ b/modules/companies/src/web/companies/detail/types/company-detail-props.ts @@ -9,6 +9,15 @@ export type CompanyDetailProps = { embedded?: boolean; accountNavigation?: ReactNode; initialSection?: CompanyTab; + section?: CompanyTab; + onSectionChange?: (section: CompanyTab) => void; + initialConfiguration?: + | "folders" + | "mail" + | "certificates" + | "applications" + | "printers" + | undefined; initialEntityId?: string | undefined; initialCreate?: boolean | undefined; }; diff --git a/modules/companies/src/web/companies/detail/ui/company-detail.tsx b/modules/companies/src/web/companies/detail/ui/company-detail.tsx index 4d80454..7f4b805 100644 --- a/modules/companies/src/web/companies/detail/ui/company-detail.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-detail.tsx @@ -1,7 +1,10 @@ +import { BrowserLink } from "@metis/core/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { BoxIcon, BriefcaseIcon, + DatabaseIcon, + HomeIcon, KeyRoundIcon, LayersIcon, LayoutTemplateIcon, @@ -11,6 +14,7 @@ import { } from "@repo/shadcn-ui/lib/icons"; import { useTranslation } from "react-i18next"; +import { companySectionHref } from "../../../workspace/shared/helpers"; import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared"; import { useCompanyDetailController } from "../controllers"; import type { CompanyDetailProps } from "../types"; @@ -26,6 +30,7 @@ export function CompanyDetail(props: CompanyDetailProps) { initialSection, initialEntityId, initialCreate, + initialConfiguration, } = props; const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props); const { t } = useTranslation("companies"); @@ -111,6 +116,7 @@ export function CompanyDetail(props: CompanyDetailProps) { )} @@ -167,8 +170,8 @@ export function WorkspaceSidebar({ onSelectCompany(company.id)} title={company.name} > { + this.entries.splice(this.index + 1); + this.entries.push({ href, state }); + this.index += 1; + this.restore(); + }, + replaceState: (state: unknown, _title: string, href: string) => { + this.entries[this.index] = { href, state }; + this.restore(); + }, + back: () => { + this.index = Math.max(0, this.index - 1); + this.restore(); + this.dispatchEvent(new Event("popstate")); + }, + forward: () => { + this.index = Math.min(this.entries.length - 1, this.index + 1); + this.restore(); + this.dispatchEvent(new Event("popstate")); + }, + }; + private restore() { + const entry = this.entries[this.index]; + assert.ok(entry); + this.location = new URL(entry.href, "http://localhost"); + this.history.state = entry.state; + } +} + +test("History mantiene URL, filtros, Atrás/Adelante, cierre y suscripciones sin entradas duplicadas", () => { + const browser = new BrowserFixture(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { value: browser, configurable: true }); + try { + let updates = 0; + const unsubscribe = subscribeToNavigation(() => { + updates += 1; + }); + navigateTo("/tickets?pendingOnly=true"); + assert.equal(getBrowserLocation(), "/tickets?pendingOnly=true"); + navigateTo("/tickets?pendingOnly=true"); + assert.equal(updates, 1); + navigateTo("/companies/company/tickets/ticket"); + closeRoute("/dashboard"); + assert.equal(getBrowserLocation(), "/tickets?pendingOnly=true"); + browser.history.forward(); + assert.equal(getBrowserLocation(), "/companies/company/tickets/ticket"); + navigateTo("/catalogs", { replace: true }); + browser.history.back(); + assert.equal(getBrowserLocation(), "/tickets?pendingOnly=true"); + unsubscribe(); + const observed = updates; + navigateTo("/companies", { create: true }); + assert.equal(isCreateNavigation(), true); + clearCreateNavigation("/dashboard"); + assert.equal(isCreateNavigation(), true); + clearCreateNavigation("/companies"); + assert.equal(isCreateNavigation(), false); + browser.history.back(); + browser.history.forward(); + assert.equal(isCreateNavigation(), false); + assert.equal(updates, observed); + browser.history.replaceState(null, "", "/companies/company/people/person"); + closeRoute("/companies/company/people"); + assert.equal(getBrowserLocation(), "/companies/company/people"); + assert.throws(() => navigateTo("//example.test")); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "window", descriptor); + } else { + Reflect.deleteProperty(globalThis, "window"); + } + } +}); + +test("las redirecciones internas rechazan destinos externos, barras invertidas y caracteres de control", () => { + for (const href of [ + "https://example.test", + "//example.test", + "/\\example.test", + "/\nexample.test", + "tickets", + "", + ]) { + assert.equal(isInternalPath(href), false); + } + assert.equal(isInternalPath("/companies/019?tab=fields"), true); +}); diff --git a/modules/core/src/web/navigation/shared/helpers/browser-navigation.ts b/modules/core/src/web/navigation/shared/helpers/browser-navigation.ts new file mode 100644 index 0000000..4460958 --- /dev/null +++ b/modules/core/src/web/navigation/shared/helpers/browser-navigation.ts @@ -0,0 +1,79 @@ +const NAVIGATION_EVENT = "metis:navigation"; + +export function isInternalPath(value: string): boolean { + return ( + value.startsWith("/") && + !value.startsWith("//") && + !value.includes("\\") && + Array.from(value).every((character) => character.charCodeAt(0) > 32) + ); +} + +export function getBrowserLocation(): string { + return typeof window === "undefined" + ? "/dashboard" + : `${window.location.pathname}${window.location.search}`; +} + +export function subscribeToNavigation(listener: () => void): () => void { + window.addEventListener("popstate", listener); + window.addEventListener(NAVIGATION_EVENT, listener); + return () => { + window.removeEventListener("popstate", listener); + window.removeEventListener(NAVIGATION_EVENT, listener); + }; +} + +export function navigateTo(href: string, options: { replace?: boolean; create?: boolean } = {}) { + if (!isInternalPath(href)) { + throw new Error("Invalid internal navigation destination"); + } + if (typeof window === "undefined") { + return; + } + const previous = getBrowserLocation(); + if (previous === href && !options.create) { + return; + } + if (options.replace) { + window.history.replaceState(window.history.state, "", href); + } else { + window.history.pushState({ previous, create: options.create === true }, "", href); + } + window.dispatchEvent(new Event(NAVIGATION_EVENT)); +} + +export function closeRoute(fallback: string) { + if (typeof window !== "undefined") { + const state: unknown = window.history.state; + if ( + typeof state === "object" && + state !== null && + "previous" in state && + typeof state.previous === "string" && + isInternalPath(state.previous) + ) { + window.history.back(); + return; + } + } + navigateTo(fallback, { replace: true }); +} + +export function isCreateNavigation(): boolean { + if (typeof window === "undefined") { + return false; + } + const state: unknown = window.history.state; + return typeof state === "object" && state !== null && "create" in state && state.create === true; +} + +export function clearCreateNavigation(expectedLocation: string) { + if (typeof window === "undefined" || getBrowserLocation() !== expectedLocation) { + return; + } + const state: unknown = window.history.state; + if (typeof state === "object" && state !== null && "create" in state && state.create === true) { + window.history.replaceState({ ...state, create: false }, "", getBrowserLocation()); + } +} diff --git a/modules/core/src/web/navigation/shared/hooks/use-browser-location.ts b/modules/core/src/web/navigation/shared/hooks/use-browser-location.ts new file mode 100644 index 0000000..97eb6a7 --- /dev/null +++ b/modules/core/src/web/navigation/shared/hooks/use-browser-location.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; + +import { + getBrowserLocation, + navigateTo, + subscribeToNavigation, +} from "../helpers/browser-navigation"; + +export function useBrowserLocation() { + return useSyncExternalStore(subscribeToNavigation, getBrowserLocation, getBrowserLocation); +} + +export function useBrowserSearchParams() { + const location = useBrowserLocation(); + const searchParams = new URL(location, "http://metis.invalid").searchParams; + const setSearchParams = (update: (params: URLSearchParams) => void) => { + const params = new URL(getBrowserLocation(), "http://metis.invalid").searchParams; + update(params); + const search = params.toString(); + const pathname = getBrowserLocation().split("?")[0] ?? "/dashboard"; + navigateTo(`${pathname}${search ? `?${search}` : ""}`, { replace: true }); + }; + return { searchParams, setSearchParams }; +} diff --git a/modules/core/src/web/navigation/shared/hooks/use-create-navigation.ts b/modules/core/src/web/navigation/shared/hooks/use-create-navigation.ts new file mode 100644 index 0000000..94581ab --- /dev/null +++ b/modules/core/src/web/navigation/shared/hooks/use-create-navigation.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +import { clearCreateNavigation, isCreateNavigation } from "../helpers/browser-navigation"; + +import { useBrowserLocation } from "./use-browser-location"; + +/** Consumir el alta cuando se monta su pantalla, no mientras todavía carga la compañía. */ +export function useCreateNavigation(): boolean { + const location = useBrowserLocation(); + const create = isCreateNavigation(); + useEffect(() => { + clearCreateNavigation(location); + }, [location]); + return create; +} diff --git a/modules/core/src/web/navigation/shared/hooks/use-entity-navigation.ts b/modules/core/src/web/navigation/shared/hooks/use-entity-navigation.ts new file mode 100644 index 0000000..887efe2 --- /dev/null +++ b/modules/core/src/web/navigation/shared/hooks/use-entity-navigation.ts @@ -0,0 +1,27 @@ +import { useState } from "react"; + +import { closeRoute, navigateTo } from "../helpers/browser-navigation"; + +import { useBrowserLocation } from "./use-browser-location"; + +/** La selección es URL cuando la lista está en una ruta; los consumidores embebidos conservan selección local. */ +export function useEntityNavigation(basePath: string, initialId?: string) { + const location = useBrowserLocation(); + const pathname = location.split("?")[0] ?? ""; + const routed = pathname === basePath || pathname.startsWith(`${basePath}/`); + const [localId, setLocalId] = useState(initialId); + const selectedId = routed + ? pathname.slice(basePath.length + 1).split("/")[0] || undefined + : localId; + const entityHref = (id: string) => `${basePath}/${encodeURIComponent(id)}`; + const selectEntity = (id: string | undefined) => { + if (!routed) { + setLocalId(id); + } else if (id) { + navigateTo(entityHref(id)); + } else { + closeRoute(basePath); + } + }; + return { selectedId, selectEntity, entityHref, routed }; +} diff --git a/modules/core/src/web/navigation/shared/ui/browser-link.tsx b/modules/core/src/web/navigation/shared/ui/browser-link.tsx new file mode 100644 index 0000000..dd518e7 --- /dev/null +++ b/modules/core/src/web/navigation/shared/ui/browser-link.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps } from "react"; + +import { isInternalPath, navigateTo } from "../helpers/browser-navigation"; + +export function BrowserLink({ href, onClick, ...props }: ComponentProps<"a"> & { href: string }) { + return ( + { + onClick?.(event); + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + props.download !== undefined || + (props.target && props.target !== "_self") || + !isInternalPath(href) + ) { + return; + } + event.preventDefault(); + navigateTo(href); + }} + /> + ); +} diff --git a/modules/core/src/web/navigation/shared/ui/entity-not-found.tsx b/modules/core/src/web/navigation/shared/ui/entity-not-found.tsx new file mode 100644 index 0000000..4d68012 --- /dev/null +++ b/modules/core/src/web/navigation/shared/ui/entity-not-found.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from "react-i18next"; + +export function EntityNotFound() { + const { t } = useTranslation("core"); + return ( +

+ {t("navigation.recordNotFound")} +

+ ); +} diff --git a/modules/identity/package.json b/modules/identity/package.json index 9331492..f786e0e 100644 --- a/modules/identity/package.json +++ b/modules/identity/package.json @@ -13,6 +13,7 @@ "test": "node --import tsx --test src/api/domain/user.aggregate.test.ts src/api/infrastructure/persistence/user-persistence.mapper.test.ts src/api/infrastructure/security/scrypt-password-hasher.test.ts src/web/shared/api/identity-api.test.ts" }, "dependencies": { + "@metis/core": "workspace:*", "@repo/rdx-utils": "workspace:*", "@repo/shadcn-ui": "workspace:*", "zod": "4.3.6", diff --git a/modules/identity/src/web/workspace/view/controllers/use-authenticated-workspace-controller.ts b/modules/identity/src/web/workspace/view/controllers/use-authenticated-workspace-controller.ts index a2add99..9f5f376 100644 --- a/modules/identity/src/web/workspace/view/controllers/use-authenticated-workspace-controller.ts +++ b/modules/identity/src/web/workspace/view/controllers/use-authenticated-workspace-controller.ts @@ -1,5 +1,5 @@ +import { closeRoute, navigateTo, useBrowserLocation } from "@metis/core/client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { logout as logoutSession } from "../../../sessions/shared/api"; import { identityKeys } from "../../../shared/queries"; @@ -7,15 +7,29 @@ import type { AuthenticatedWorkspaceProps } from "../types"; export function useAuthenticatedWorkspaceController({ snapshot }: AuthenticatedWorkspaceProps) { const client = useQueryClient(); - const [page, setPage] = useState("home"); + const location = useBrowserLocation(); + const pathname = location.split("?")[0]; + const page = + pathname === "/settings/users" + ? "users" + : pathname === "/settings/password" + ? "password" + : "home"; + const setPage = (value: string) => { + if (value === "home") { + closeRoute("/dashboard"); + } else if (value === "users" || value === "password") { + navigateTo(`/settings/${value}`); + } + }; const logout = useMutation({ mutationFn: () => logoutSession(snapshot.csrfToken), onSuccess: async () => { - setPage("home"); client.removeQueries({ predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session", }); await client.invalidateQueries({ queryKey: identityKeys.session }); + navigateTo("/login", { replace: true }); }, }); return { page, setPage, logout }; diff --git a/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx b/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx index f5717f8..47fe438 100644 --- a/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx +++ b/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx @@ -1,3 +1,4 @@ +import { BrowserLink } from "@metis/core/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; @@ -28,6 +29,16 @@ export function CustomWorkspace({ if (!snapshot.user) { return null; } + if (page === "users" && !snapshot.user.isInitialAdmin) { + return ( +
+

{t("errors.FORBIDDEN")}

+ + {t("workspace")} + +
+ ); + } return ( <> {renderWorkspace( @@ -36,11 +47,21 @@ export function CustomWorkspace({

{snapshot.user.name}

{snapshot.user.isInitialAdmin && ( - )} - + ), }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9890b0..2144a1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: '@hookform/resolvers': specifier: 5.2.2 version: 5.2.2(react-hook-form@7.72.1(react@19.2.5)) + '@metis/core': + specifier: workspace:* + version: link:../core '@repo/rdx-ddd': specifier: workspace:* version: link:../../packages/rdx-ddd @@ -203,6 +206,9 @@ importers: '@metis/assets': specifier: workspace:* version: link:../assets + '@metis/core': + specifier: workspace:* + version: link:../core '@metis/resources': specifier: workspace:* version: link:../resources @@ -310,6 +316,9 @@ importers: '@hookform/resolvers': specifier: 5.2.2 version: 5.2.2(react-hook-form@7.72.1(react@19.2.5)) + '@metis/core': + specifier: workspace:* + version: link:../core '@repo/rdx-ddd': specifier: workspace:* version: link:../../packages/rdx-ddd @@ -377,6 +386,9 @@ importers: '@hookform/resolvers': specifier: 5.2.2 version: 5.2.2(react-hook-form@7.72.1(react@19.2.5)) + '@metis/core': + specifier: workspace:* + version: link:../core '@repo/rdx-ddd': specifier: workspace:* version: link:../../packages/rdx-ddd