diff --git a/docs/README.md b/docs/README.md index 74e069eb..e1a09292 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,84 +1,36 @@ -# Turborepo starter +# ERP Docs -This Turborepo starter is maintained by the Turborepo core team. +Este directorio reúne la documentación operativa y arquitectónica del ERP. -## Using this example +## Estructura actual -Run the following command: - -```sh -npx create-turbo@latest +```txt +docs/ + README.md + architecture/ + frontend/ + dev/ ``` -## What's inside? +## Documentos clave -This Turborepo includes the following packages/apps: +- [Arquitectura de identity y companies](./architecture/identity-and-companies.md) +- [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md) +- [API de autenticación identity](./architecture/identity-auth-api.md) +- [Estado de migración a identity](./architecture/identity-migration-status.md) +- [Flujo frontend de auth y selección de empresa](./frontend/auth-and-company-selection.md) +- [Seed local de admin](./dev/seed-local-admin.sql) -### Apps and Packages +## Estado actual resumido -- `docs`: a [Next.js](https://nextjs.org/) app -- `web`: another [Next.js](https://nextjs.org/) app -- `@repo/shadcn-ui`: a stub React component library shared by both `web` and `docs` applications -- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`) -- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo +- El backend nuevo usa `modules/identity` para autenticación, sesión y control de acceso a companies. +- `modules/companies` es la fuente de verdad de la ficha operativa de empresa. +- El frontend ERP ya usa `@erp/identity/client` como runtime de autenticación. +- El flujo actual resuelve sesión, companies disponibles, auto-selección de empresa única y refresh automático sobre `401`. -Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). +## Reglas de lectura rápida -### Utilities - -This Turborepo has some additional tools already setup for you: - -- [TypeScript](https://www.typescriptlang.org/) for static type checking -- [ESLint](https://eslint.org/) for code linting -- [Prettier](https://prettier.io) for code formatting - -### Build - -To build all apps and packages, run the following command: - -``` -cd my-turborepo -pnpm build -``` - -### Develop - -To develop all apps and packages, run the following command: - -``` -cd my-turborepo -pnpm dev -``` - -### Remote Caching - -> [!TIP] -> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache). - -Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines. - -By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands: - -``` -cd my-turborepo -npx turbo login -``` - -This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). - -Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo: - -``` -npx turbo link -``` - -## Useful Links - -Learn more about the power of Turborepo: - -- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks) -- [Caching](https://turbo.build/repo/docs/core-concepts/caching) -- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) -- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering) -- [Configuration Options](https://turbo.build/repo/docs/reference/configuration) -- [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference) +- `modules/auth` debe considerarse legacy/deprecated para backend nuevo. +- `GET /companies/available` es el endpoint vigente para cargar companies accesibles. +- `GET /identity/companies` no es un endpoint válido del diseño actual. +- Las rutas tenant-scoped deben usar `Authorization` + `X-Company-Id`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0e6ff40b..91add18f 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,150 +1,18 @@ -# ERP Backend · Identity/Auth Migration Notes +# ERP Architecture Docs -## Propósito +## Documentos vigentes -Este documento consolida las decisiones de arquitectura y el estado de migración del backend ERP desde el módulo legacy `auth` hacia el nuevo módulo `identity`. +- [Identity and companies](./identity-and-companies.md) +- [Tenant context](./tenant-context.md) +- [Identity auth API](./identity-auth-api.md) +- [Identity backend architecture](./identity-backend-architecture.md) +- [Identity migration status](./identity-migration-status.md) +- [Identity roadmap](./identity-roadmap.md) -Debe usarse como referencia para futuros incrementos relacionados con: +## Resumen actual -- autenticación -- sesión -- contexto tenant/company -- registro de usuarios -- roles y permisos -- autorización -- login frontend -- retirada progresiva de `modules/auth` - -## Estado actual resumido - -El backend ya dispone de un módulo `identity` registrado en servidor y con endpoints de autenticación funcionales: - -```http -POST /identity/auth/login -POST /identity/auth/refresh -POST /identity/auth/logout -GET /identity/auth/session -``` - -El módulo `identity` expone builders genéricos desde `@erp/identity/api`: - -```ts -requireIdentityAuthenticated(params: StartParams): RequestHandler[]; -requireIdentityTenant(params: StartParams): RequestHandler[]; -``` - -Uso esperado en routers tenant-scoped: - -```ts -import { requireIdentityTenant } from "@erp/identity/api"; - -router.use(...requireIdentityTenant(params)); -``` - -Uso esperado en routers que solo requieren usuario autenticado: - -```ts -import { requireIdentityAuthenticated } from "@erp/identity/api"; - -router.use(...requireIdentityAuthenticated(params)); -``` - -## Módulos backend migrados a `identity` - -Ya no usan `@erp/auth/api` ni `mockUser`: - -- `modules/catalogs` -- `modules/customers` -- `modules/customer-invoices` -- `modules/factuges` - -## Consumidores legacy pendientes - -Backend pendiente explícitamente: - -- `modules/supplier/src/api/infrastructure/express/suppliers.routes.ts` - -Frontend todavía usa cliente legacy: - -- `apps/web/src/register-modules.tsx` -- `apps/web/src/app.tsx` - -Por tanto, `modules/auth` no puede retirarse todavía. - -## Regla de arquitectura vigente - -Los módulos funcionales no deben importar ni recomponer internals de `identity`. - -No usar en módulos consumidores: - -```ts -IdentityInternalDeps; -authenticateUserDependencies; -getInternal("identity"); -``` - -No crear helpers de auth por módulo como patrón final: - -```txt -catalogs-auth-middlewares.ts -customers-auth-middlewares.ts -... -``` - -El middleware genérico pertenece a `identity`. - -## Separación de responsabilidades - -### Authentication - -Responde a: - -- quién es el usuario -- si el token es válido -- si la cuenta puede autenticarse - -### Tenant context - -Responde a: - -- qué empresa activa se está usando en la request -- actualmente se resuelve por header `X-Company-Id` - -### Authorization - -Responderá a: - -- qué permisos efectivos tiene el usuario dentro de la empresa - -Todavía no está implementado en modo completo. - -### Document context - -Datos como `companySlug`, templates, certificados o metadatos de generación documental no pertenecen al middleware de autenticación. - -No añadir a `identity` auth: - -- `companySlug` -- `X-Company-Slug` -- `companySlug` en access token - -Si un flujo documental necesita `companySlug`, debe resolverse mediante un servicio específico de empresa/documentos. - -## Roadmap corto recomendado - -1. Crear pantalla de login en Frontend ERP contra `/identity/auth/login`. -2. Mantener `supplier` temporalmente en legacy si así se decide. -3. Implementar refresh automático en frontend. -4. Implementar selección de empresa y uso de `X-Company-Id`. -5. Implementar persistencia y validación real de `CompanyMembership`. -6. Implementar resolución de permisos efectivos. -7. Migrar `supplier` cuando se decida. -8. Retirar backend legacy de `@erp/auth/api`. -9. Migrar o retirar `@erp/auth/client`. - -## Documentos relacionados - -- [`identity-auth-api.md`](./identity-auth-api.md) -- [`identity-backend-architecture.md`](./identity-backend-architecture.md) -- [`identity-migration-status.md`](./identity-migration-status.md) -- [`identity-roadmap.md`](./identity-roadmap.md) +- `identity` se encarga de autenticación, sesión, refresh y memberships. +- `companies` se encarga de la ficha operativa de empresa. +- `GET /companies/available` es la vía actual para listar companies accesibles. +- El contexto tenant se resuelve con `X-Company-Id` y `requireIdentityTenant(params)`. +- El frontend ERP ya usa el runtime nuevo de `identity` con auto-selección de empresa y refresh automático. diff --git a/docs/architecture/identity-and-companies.md b/docs/architecture/identity-and-companies.md new file mode 100644 index 00000000..cae868be --- /dev/null +++ b/docs/architecture/identity-and-companies.md @@ -0,0 +1,175 @@ +# Identity And Companies + +## Objetivo + +Documentar la separación actual entre `modules/identity` y `modules/companies`, evitando mezclar autenticación, memberships y ficha operativa de empresa. + +## Responsabilidades por módulo + +### `modules/identity` + +Gestiona: + +- `Account` +- login +- refresh +- logout +- session +- `RefreshToken` +- `CompanyMembership` +- roles/permisos futuros +- validación de acceso `accountId + companyId` + +Endpoints activos: + +```http +POST /identity/auth/login +POST /identity/auth/refresh +POST /identity/auth/logout +GET /identity/auth/session +``` + +El access token contiene solo: + +```ts +{ + accountId: string; + email: string; +} +``` + +No debe contener: + +- `companyId` +- `companySlug` +- roles +- permisos + +### `modules/companies` + +Es la fuente de verdad de la ficha operativa de empresa. + +`Company` funcional vive en `modules/companies`. + +Campos actuales de `companies`: + +```txt +id +legal_name +trade_name +tin +slug +email +phone +website +status +created_at +updated_at +``` + +Índices actuales: + +```txt +UNIQUE(slug) +INDEX(status) +INDEX(tin) +``` + +`slug` pertenece a `companies`, no a auth ni al token. + +## Relación entre ambos módulos + +- `CompanyMembership` vive en `identity`. +- `Company` vive en `companies`. +- No mover memberships a `companies`. +- No evolucionar `identity.Company` como ficha operativa ERP. + +## Servicios públicos + +### `identity:general` + +Expone autenticación y acceso por membership. + +Uso conceptual: + +```ts +companyAccess.canAccessCompany(...) +companyAccess.findAccessibleCompanyIds(...) +``` + +`identity` devuelve IDs accesibles por membership, no fichas completas de empresa. + +### `companies:general` + +Expone consulta de empresas y validación de empresa activa. + +Uso conceptual: + +```ts +ICompanyPublicServices { + finder: ICompanyPublicFinder; +} +``` + +## Por qué no existe `GET /identity/companies` + +No se documenta `GET /identity/companies` como endpoint válido porque produciría acoplamiento circular: + +```txt +identity -> companies -> identity +``` + +Si `identity` devolviera fichas completas de company, tendría que depender de `companies`. + +Como `companies` ya depende de `identity` para autenticación, el diseño correcto es: + +- `identity` resuelve membership y accesibilidad por ID +- `companies` resuelve la ficha completa de empresa + +El endpoint vigente para el frontend es: + +```http +GET /companies/available +Authorization: Bearer +``` + +## Dependencias permitidas + +Para módulos tenant-scoped que usan `requireIdentityTenant(params)`, la dependencia explícita debe ser hacia: + +- `identity` +- `companies` + +Porque el middleware tenant compone: + +```txt +identity:general.companyAccess +companies:general.finder +``` + +## Dependencias prohibidas + +No introducir: + +- `identity -> companies` +- recomposición manual de internals de `identity` +- `IdentityInternalDeps` +- `getInternal("identity")` desde módulos consumidores +- helpers locales de auth por módulo como patrón estable + +## `GET /companies/available` + +Semántica actual: + +- requiere `Authorization` +- no requiere `X-Company-Id` +- usa `requireIdentityAuthenticated(params)` +- devuelve companies activas accesibles para la cuenta autenticada + +La disponibilidad se calcula con: + +```txt +membership active en identity ++ +company active en companies +``` diff --git a/docs/architecture/identity-auth-api.md b/docs/architecture/identity-auth-api.md index 37725c4d..fa06ba15 100644 --- a/docs/architecture/identity-auth-api.md +++ b/docs/architecture/identity-auth-api.md @@ -1,12 +1,13 @@ # Identity Auth API -## Endpoints disponibles +## Endpoints activos ```http POST /identity/auth/login POST /identity/auth/refresh POST /identity/auth/logout GET /identity/auth/session +GET /companies/available ``` ## POST `/identity/auth/login` @@ -42,11 +43,11 @@ export type AuthenticatedAccountDTO = { ### Notas -- No requiere `Authorization`. -- No requiere `X-Company-Id`. -- No devuelve empresas accesibles todavía. -- No devuelve roles ni permisos. -- El access token no contiene `companyId`. +- no requiere `Authorization` +- no requiere `X-Company-Id` +- no devuelve companies accesibles +- no devuelve roles ni permisos +- el access token no contiene `companyId` ## POST `/identity/auth/refresh` @@ -69,9 +70,9 @@ export type RefreshSessionResponseDTO = { ### Notas -- El refresh token se rota. -- El cliente debe reemplazar ambos tokens por los nuevos. -- El refresh token anterior no debe reutilizarse tras una renovación correcta. +- el refresh token se rota +- el cliente debe reemplazar ambos tokens por los nuevos +- no debe llevar `X-Company-Id` ## POST `/identity/auth/logout` @@ -100,9 +101,9 @@ export type LogoutResponseDTO = { ### Reglas -- Para logout normal, enviar el `refresh_token` actual. -- Para cerrar todas las sesiones, enviar `all_sessions: true`. -- Si logout falla por expiración de token, el frontend debe limpiar sesión igualmente. +- no debe llevar `X-Company-Id` +- para logout normal, enviar el `refresh_token` actual +- si logout falla por expiración de token, el frontend debe limpiar sesión igualmente ## GET `/identity/auth/session` @@ -120,24 +121,50 @@ export type CurrentSessionResponseDTO = { }; ``` -## Errores esperados +### Notas -```txt -400 -> request inválida o header mal formado -401 -> access token ausente/inválido/expirado o credenciales inválidas -403 -> contexto de empresa requerido en rutas tenant-scoped -500 -> error inesperado -``` +- no debe llevar `X-Company-Id` -## Rutas tenant-scoped +## GET `/companies/available` -Las rutas de negocio tenant-scoped deben enviar: +### Headers ```http Authorization: Bearer -X-Company-Id: ``` -Actualmente `X-Company-Id` se valida sintácticamente como UUID y se publica como `req.user.companyId`. +### Response -La validación real de membership queda pendiente. +```ts +export type AvailableCompaniesResponseDTO = { + companies: AvailableCompanyDTO[]; +}; + +export type AvailableCompanyDTO = { + id: string; + legal_name: string; + trade_name: string | null; + tin: string | null; + slug: string; + email: string | null; + phone: string | null; + website: string | null; + status: "active"; +}; +``` + +### Notas + +- usa autenticación, no tenant context +- no requiere `X-Company-Id` +- usa `requireIdentityAuthenticated(params)` +- devuelve solo companies activas accesibles para la cuenta autenticada + +## Errores esperados + +```txt +400 -> request inválida o UUID de X-Company-Id mal formado +401 -> access token ausente/inválido/expirado o credenciales inválidas +403 -> contexto tenant inválido, company no accesible o company no activa +500 -> error inesperado +``` diff --git a/docs/architecture/identity-backend-architecture.md b/docs/architecture/identity-backend-architecture.md index e67ed35c..ef6aba50 100644 --- a/docs/architecture/identity-backend-architecture.md +++ b/docs/architecture/identity-backend-architecture.md @@ -4,7 +4,7 @@ `identity` sustituye progresivamente al módulo legacy `auth`. -Responsabilidades previstas: +Responsabilidades actuales: - cuentas autenticables - login/password @@ -12,48 +12,22 @@ Responsabilidades previstas: - refresh token rotado - logout - sesión actual -- empresas accesibles - memberships -- roles -- permisos -- autorización futura +- validación de acceso account-company +- base para roles/permisos futuros -## Dominio mínimo creado +## Dominio actual -Agregados principales: +Agregados y conceptos relevantes: ```txt Account -Company +RefreshToken CompanyMembership Role -RefreshToken ``` -## Value Objects reutilizados - -Desde `@repo/rdx-ddd`: - -```txt -UniqueID -LanguageCode -Name -TextValue -UtcDate -EmailAddress -URLAddress -``` - -VOs específicos de `identity`: - -```txt -PasswordHash -RefreshTokenHash -RoleCode -AccountStatus -CompanyStatus -CompanyMembershipStatus -``` +`Company` funcional no pertenece a `identity`; vive en `modules/companies`. ## Seguridad @@ -67,81 +41,40 @@ IRefreshTokenGenerator IRefreshTokenHasher ``` -Implementaciones actuales: +Decisiones vigentes: -```txt -BcryptPasswordHasher -JsonWebTokenAccessTokenIssuer -JsonWebTokenAccessTokenVerifier -CryptoRefreshTokenGenerator -Sha256RefreshTokenHasher -``` +- password hashing con `bcrypt` +- access token JWT con `accountId` y `email` +- refresh token aleatorio +- refresh token persistido como hash +- no incluir `companyId`, `companySlug`, roles ni permisos en access token -Decisiones: +## Builders públicos -- password hashing con `bcrypt` en V1. -- access token JWT con `accountId` y `email`. -- refresh token aleatorio con `crypto.randomBytes`. -- refresh token persistido solo como hash SHA-256. -- no incluir `companyId`, roles ni permisos en access token. - -## Persistencia mínima - -Tablas/modelos creados: - -```txt -identity_accounts -identity_refresh_tokens -``` - -Repositorios: - -```txt -IAccountRepository -IRefreshTokenRepository -SequelizeAccountRepository -SequelizeRefreshTokenRepository -``` - -## Servicios públicos del módulo - -`identity` registra un servicio público: - -```txt -identity:general -``` - -Incluye: - -```ts -auth.authenticatedOnly(): RequestHandler[]; -auth.tenantRequired(): RequestHandler[]; -``` - -Pero los consumidores no deberían usar directamente `identity:general` en routers. - -Deben usar los builders públicos: +`identity` expone desde `@erp/identity/api`: ```ts requireIdentityAuthenticated(params: StartParams): RequestHandler[]; requireIdentityTenant(params: StartParams): RequestHandler[]; ``` -## Uso correcto en routers +Uso correcto: ```ts import { requireIdentityTenant } from "@erp/identity/api"; -export function buildSomeRoutes(params: StartParams) { - const router = Router(); - - router.use(...requireIdentityTenant(params)); - - return router; -} +router.use(...requireIdentityTenant(params)); ``` -## Shape actual de `req.user` +```ts +import { requireIdentityAuthenticated } from "@erp/identity/api"; + +router.use(...requireIdentityAuthenticated(params)); +``` + +## `req.user` + +Shape mínimo esperado: ```ts { @@ -152,11 +85,35 @@ export function buildSomeRoutes(params: StartParams) { } ``` -Compatibilidad provisional: +`req.user.companyId` solo se debe poblar cuando el middleware tenant valida correctamente la request. -- `roles` puede ser `[]`. -- `companyId` viene de `X-Company-Id`. -- `companySlug` no debe depender de auth. +No añadir: + +- `companySlug` +- `company` +- `permissions` enriquecidos +- `companyStatus` + +## Tenant validation + +`requireIdentityTenant(params)` ya no es solo validación sintáctica. + +Valida: + +1. token válido +2. account autenticable +3. `X-Company-Id` presente +4. UUID válido +5. membership `active` +6. company `active` + +## Relación con `companies` + +- `identity` resuelve membership y accesibilidad +- `companies` resuelve la ficha operativa de empresa +- no introducir dependencia `identity -> companies` + +Si un consumidor necesita `slug`, debe resolverlo desde `companies`, no desde auth. ## Prohibiciones de diseño @@ -168,54 +125,11 @@ authenticateUserDependencies; getInternal("identity"); ``` -No crear: +No crear helpers locales de auth por módulo como patrón estable. -```txt --auth-middlewares.ts -``` +No añadir al access token: -como patrón estable. - -No añadir a access token: - -```txt -companyId -roles -permissions -companySlug -``` - -## Permisos - -Cada módulo define sus propios permisos. - -`core` contiene contratos transversales mínimos: - -```ts -export type PermissionCode = string; - -export type PermissionDefinition = { - code: PermissionCode; - module: string; - description: string; -}; -``` - -`identity` define solo sus permisos propios: - -```txt -identity:companies:update -identity:roles:read -identity:roles:create -identity:roles:update -identity:roles:delete -identity:company-users:read -identity:company-users:update-roles -identity:company-users:disable -identity:company-users:enable -identity:permissions:read -``` - -`Role.permissionCodes` usa `PermissionCode[]`, no `IdentityPermissionCode[]`. - -La existencia real de permisos se valida en Application con `IPermissionCatalog`. +- `companyId` +- `companySlug` +- roles +- permisos diff --git a/docs/architecture/identity-migration-status.md b/docs/architecture/identity-migration-status.md index f5aeaf7a..f6280f77 100644 --- a/docs/architecture/identity-migration-status.md +++ b/docs/architecture/identity-migration-status.md @@ -20,130 +20,74 @@ router.use(...requireIdentityAuthenticated(params)); ## Migrado a `identity` -### `modules/catalogs` +### Backend tenant-scoped -Routers migrados: +Módulos migrados: -```txt -payment-methods.routes.ts -payment-terms.routes.ts -tax-regimes.routes.ts -tax-definitions.routes.ts -``` +- `modules/catalogs` +- `modules/customers` +- `modules/customer-invoices` +- `modules/factuges` Estado: -```txt -sin @erp/auth/api -sin mockUser -sin helpers locales de auth -usa requireIdentityTenant(params) +- sin `@erp/auth/api` +- sin `mockUser` +- sin helpers locales de auth +- usan `requireIdentityTenant(params)` + +### Backend autenticado sin tenant + +`modules/companies` usa: + +```ts +import { requireIdentityAuthenticated } from "@erp/identity/api"; + +router.use(...requireIdentityAuthenticated(params)); ``` -### `modules/customers` +Esto aplica a: -Router migrado: - -```txt -customers.routes.ts +```http +POST /companies +GET /companies/available +GET /companies/:company_id +PATCH /companies/:company_id +POST /companies/:company_id/enable +POST /companies/:company_id/disable ``` -Estado: +## Frontend actual + +El runtime nuevo ya usa: + +- `@erp/identity/client` +- `IdentityAuthSessionProvider` +- `DataSourceProvider` +- Axios compartido con refresh automático + +Flujo implementado: ```txt -sin @erp/auth/api -sin mockUser -sin helpers locales de auth -usa requireIdentityTenant(params) +login +-> GET /identity/auth/session +-> GET /companies/available +-> resolver empresa activa +-> auto-selección si hay una sola empresa +-> selector si hay varias +-> /no-companies si no hay empresas +-> X-Company-Id en rutas tenant-scoped ``` -### `modules/customer-invoices` +## Legacy pendiente -Routers migrados: +`modules/auth` queda legacy/deprecated y no debe usarse en backend nuevo. -```txt -proformas.routes.ts -issued-invoices.routes.ts -``` - -Estado: - -```txt -sin @erp/auth/api -sin mockUser -sin helpers locales de auth -usa requireIdentityTenant(params) -``` - -Notas: - -- `companySlug` ya no debe venir de `req.user`. -- La deuda documental de `companySlug: "rodax"` queda fuera del diseño de auth. - -### `modules/factuges` - -Router migrado: - -```txt -factuges.routes.ts -``` - -Estado: - -```txt -sin @erp/auth/api -sin mockUser -usa requireIdentityTenant(params) -``` - -Nota: - -- La carpeta sigue llamándose `infraestructure`; no se ha corregido en esta migración. - -## Pendiente backend +Sigue pendiente revisar o retirar consumidores legacy residuales antes de eliminarlo por completo. ### `modules/supplier` -Sigue usando: - -```ts -import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api"; -``` - -Archivo: - -```txt -modules/supplier/src/api/infrastructure/express/suppliers.routes.ts -``` - -Decisión actual: - -```txt -No migrar supplier por ahora. -``` - -## Legacy documentado - -`modules/auth` queda documentado como legacy. - -Uso backend nuevo recomendado: - -```ts -import { requireIdentityTenant, requireIdentityAuthenticated } from "@erp/identity/api"; -``` - -`@erp/auth/api` se mantiene temporalmente solo por `supplier`. - -## Frontend legacy pendiente - -Todavía existen imports de `@erp/auth/client` en: - -```txt -apps/web/src/register-modules.tsx -apps/web/src/app.tsx -``` - -Esto impide retirar completamente `modules/auth` aunque el backend acabe limpio. +Queda fuera de esta migración por ahora. ## Búsquedas útiles de control @@ -151,13 +95,7 @@ Esto impide retirar completamente `modules/auth` aunque el backend acabe limpio. rg -n -F "@erp/auth/api" modules apps packages rg -n -F "mockUser" modules apps packages rg -n -F "IdentityInternalDeps" modules apps packages -rg -n -F 'getInternal("identity")' modules apps packages -rg -n -F "getInternal('identity')" modules apps packages +rg -n -F 'getInternal(\"identity\")' modules apps packages +rg -n -F "requireIdentityTenant(params)" modules apps packages +rg -n -F "requireIdentityAuthenticated(params)" modules apps packages ``` - -Resultado esperado actual: - -- `@erp/auth/api`: solo `supplier` y documentación/exports legacy. -- `mockUser`: solo `supplier` y `modules/auth`. -- `IdentityInternalDeps`: no debe aparecer en módulos consumidores. -- `getInternal("identity")`: no debe aparecer en módulos consumidores. diff --git a/docs/architecture/identity-roadmap.md b/docs/architecture/identity-roadmap.md index 16d9a405..a414ded2 100644 --- a/docs/architecture/identity-roadmap.md +++ b/docs/architecture/identity-roadmap.md @@ -2,216 +2,57 @@ ## Objetivo general -Completar la transición desde el módulo legacy `auth` hacia `identity`, sin mezclar autenticación con autorización, tenant context, datos documentales o frontend legacy. +Completar la transición desde el módulo legacy `auth` hacia `identity`, manteniendo separadas autenticación, tenant context, companies y autorización. -## Fase 1 · Backend auth runtime +## Estado consolidado -Estado: prácticamente completado. - -Incluye: +Ya está implementado: ```txt login refresh rotado logout session -access token verifier -refresh token persistence -middlewares genéricos +GET /companies/available +selección de empresa en frontend +auto-selección con una sola empresa +refresh automático sobre 401 en frontend +validación real de membership active + company active en tenant middleware ``` -Pendiente técnico recomendado: +## Trabajo todavía pendiente -```txt -- sanear typings de bcrypt/jsonwebtoken en identity -- revisar errores reales de node tsc -``` - -## Fase 2 · Migración backend desde `@erp/auth/api` - -Estado: - -```txt -catalogs migrado -customers migrado -customer-invoices migrado -factuges migrado -supplier pendiente intencionado -``` - -Siguiente decisión: - -```txt -migrar supplier o mantenerlo legacy temporalmente -``` - -## Fase 3 · Frontend login - -Objetivo: - -Crear pantalla `/login` en Frontend ERP contra: - -```http -POST /identity/auth/login -``` - -Debe implementar: - -```txt -email/password -persistencia temporal de tokens -carga de sesión actual -logout básico -preparación para refresh automático -``` - -No incluir todavía: - -```txt -register -forgot password -MFA -OAuth -roles/permisos -selección avanzada de empresa -``` - -## Fase 4 · Selección de empresa - -Backend ya usa `X-Company-Id` para rutas tenant-scoped. +### Roles y permisos Pendiente: -```txt -GET /identity/companies -GET /identity/companies/current -PATCH /identity/companies/current -``` +- resolución de permisos efectivos +- middleware de autorización fina +- endpoints funcionales de roles/permisos si se activan -Frontend deberá: - -```txt -listar empresas accesibles -permitir seleccionar empresa activa -enviar X-Company-Id en rutas tenant-scoped -``` - -## Fase 5 · Membership real - -Actualmente `X-Company-Id` se valida sintácticamente pero no contra membership real. +### Migración legacy restante Pendiente: -```txt -persistencia Company -persistencia CompanyMembership -validación accountId + companyId -bloqueo si membership disabled/invited -``` +- revisar consumidores legacy que sigan atados a `modules/auth` +- migrar o retirar `supplier` cuando se decida +- retirar `@erp/auth/client` si aún queda compatibilidad heredada -No validar membership en access token. - -La validación debe ocurrir en backend por request o mediante contexto cacheado controlado. - -## Fase 6 · Roles y permisos - -Ya existe base conceptual: - -```txt -Role -PermissionCode -PermissionDefinition -IPermissionCatalog -RolePermissionValidator -``` - -Pendiente: - -```txt -RoleCreator -RoleUpdater -PermissionResolver -AuthorizationService -middleware authorize(permission) -endpoints /identity/roles -endpoints /identity/permissions -``` - -Reglas: - -```txt -cada módulo declara sus propios permisos -identity no define permisos de otros módulos -Role.permissionCodes usa PermissionCode[] transversal -``` - -## Fase 7 · Registro y onboarding - -No implementar registro público sin decisión previa. - -Opciones futuras: - -```txt -registro público -creación de primera empresa -invitaciones -alta interna por admin -``` - -Posible secuencia segura: - -```txt -1. RegisterAccountUseCase -2. CreateInitialCompanyUseCase -3. CreateOwnerMembershipUseCase -4. Seed owner role -5. Emitir sesión -``` - -Debe evitarse: - -```txt -crear cuentas sin empresa cuando el producto exige tenant -crear empresas sin owner -crear roles sin permisos válidos -``` - -## Fase 8 · Retirada de `modules/auth` - -Solo posible cuando: - -```txt -- supplier deje de usar @erp/auth/api -- frontend deje de usar @erp/auth/client -- no queden imports a @erp/auth en runtime -``` - -Antes de retirar: - -```powershell -rg -n -F "@erp/auth" modules apps packages -rg -n -F "mockUser" modules apps packages -``` - -## Decisiones explícitas +## Decisiones que se mantienen No hacer: -```txt -- meter companyId en access token +- meter `companyId` en access token - meter roles/permisos en access token -- meter companySlug en access token -- poblar companySlug desde auth middleware -- crear helpers auth por módulo -- usar getInternal("identity") desde módulos consumidores -- usar IdentityInternalDeps desde módulos consumidores -``` +- meter `companySlug` en access token +- documentar `GET /identity/companies` como endpoint vigente +- poblar `companySlug` desde auth middleware +- usar `getInternal("identity")` desde módulos consumidores +- usar `IdentityInternalDeps` desde módulos consumidores Sí hacer: -```txt -- usar requireIdentityTenant(params) en rutas tenant-scoped -- usar requireIdentityAuthenticated(params) en rutas solo autenticadas -- resolver datos documentales desde servicios específicos -- validar membership en backend cuando exista persistencia real -``` +- usar `requireIdentityTenant(params)` en rutas tenant-scoped +- usar `requireIdentityAuthenticated(params)` en rutas solo autenticadas +- usar `GET /companies/available` para companies accesibles en frontend +- resolver datos documentales desde `companies` u otros servicios específicos diff --git a/docs/architecture/tenant-context.md b/docs/architecture/tenant-context.md new file mode 100644 index 00000000..276e98c7 --- /dev/null +++ b/docs/architecture/tenant-context.md @@ -0,0 +1,115 @@ +# Tenant Context + +## Objetivo + +Documentar cómo se resuelve el contexto tenant actual del ERP mediante `X-Company-Id` y `requireIdentityTenant(params)`. + +## Header tenant + +Las rutas tenant-scoped deben recibir: + +```http +Authorization: Bearer +X-Company-Id: +``` + +`X-Company-Id` es el identificador de la company activa en la request. + +No usar: + +- `X-Company-Slug` +- `companySlug` en token +- `req.user.companySlug` + +## Middleware público + +Para rutas tenant-scoped: + +```ts +import { requireIdentityTenant } from "@erp/identity/api"; + +router.use(...requireIdentityTenant(params)); +``` + +Para rutas solo autenticadas: + +```ts +import { requireIdentityAuthenticated } from "@erp/identity/api"; + +router.use(...requireIdentityAuthenticated(params)); +``` + +## Validación real de `requireIdentityTenant(params)` + +Actualmente ya valida: + +1. access token válido +2. account autenticable +3. `X-Company-Id` presente +4. `X-Company-Id` con formato UUID válido +5. membership `active` en `identity_company_memberships` +6. company `active` en `companies` +7. solo entonces rellena `req.user.companyId` + +## Semántica esperada de errores + +```txt +sin Authorization -> 401 +token inválido -> 401 +cuenta inexistente/no autenticable -> 401 +sin X-Company-Id -> 403 +X-Company-Id inválido -> 400 +company inexistente -> 403 +company disabled -> 403 +membership inexistente -> 403 +membership disabled/invited -> 403 +membership active + company active -> entra al controller +``` + +## Shape mínimo de `req.user` + +```ts +{ + userId: UniqueID; + email?: EmailAddress; + companyId?: UniqueID; + roles?: string[]; +} +``` + +No añadir: + +- `companySlug` +- `company` +- `permissions` enriquecidos +- `companyStatus` + +## Módulos tenant-scoped migrados + +Actualmente usan `requireIdentityTenant(params)`: + +- `modules/catalogs` +- `modules/customers` +- `modules/customer-invoices` +- `modules/factuges` + +`modules/supplier` queda fuera de esta migración por ahora. + +## Qué no debe ir en el token + +El access token no debe incluir: + +- `companyId` +- `companySlug` +- roles +- permisos + +El contexto tenant se resuelve por request con `X-Company-Id`. + +## Document context + +Si un flujo documental necesita `companySlug`, debe resolverlo desde `companies`, por ejemplo: + +```txt +companyId -> companies:general.finder.findById(...) -> slug +``` diff --git a/docs/dev/seed-local-admin.sql b/docs/dev/seed-local-admin.sql index 746c8cca..8ce4802e 100644 --- a/docs/dev/seed-local-admin.sql +++ b/docs/dev/seed-local-admin.sql @@ -12,59 +12,28 @@ -- -- Credentials: -- email: admin@local.test --- password: Passw0rd123 --- --- Important: --- Replace before running this script. +-- password: Admin123! -- -- Generate bcrypt hash from the workspace: -- -- node -e "const bcrypt=require('bcrypt'); bcrypt.hash('Admin123!', 10).then(console.log)" -- +-- Replace the hash below if you want to regenerate it. -- This script is idempotent: -- - It does not duplicate the admin account if the email exists. -- - It does not duplicate the company if the slug exists. -- - It does not duplicate the membership if it already exists. --- --- If you need to force password reset for the existing admin, --- uncomment the UPDATE block near the end. -- ============================================================ SET @admin_id = UUID(); +SET @company_id = UUID(); SET @admin_email = 'admin@local.test'; +SET @admin_password_hash = '$2b$10$6m6Nh2OpDy9MlQF18KOueOzLSCHybcg8yu1JyG0XxjgxB5Qx2dMdG'; -SET - @admin_password_hash = '$2a$10$qgmmm34tJug4HydlKwcZxOVA5u5zoDTLE5lkH//sp55tl5au2wNQm'; - -SET - @company_id1 = "5e4dc5b3-96b9-4968-9490-14bd032fec5f" -- UUID(); -SET - @company_slug1 = 'rodax'; - -SET @company_legal_name1 = 'Rodax Software S.L.'; - -SET @company_trade_name1 = 'Rodax'; - -SET @company_id2 = UUID(); - -SET @company_slug2 = 'company2'; - -SET @company_legal_name2 = 'Empresa 2 S.L.'; - -SET @company_trade_name2 = 'Empresa 2'; - -SET @company_id3 = UUID(); - -SET @company_slug3 = 'company3'; - -SET @company_legal_name3 = 'Empresa 2 S.L.'; - -SET @company_trade_name3 = 'Empresa 3'; - --- ------------------------------------------------------------ --- Account admin --- ------------------------------------------------------------ +SET @company_slug = 'rodax'; +SET @company_legal_name = 'Rodax Software S.L.'; +SET @company_trade_name = 'Rodax'; INSERT INTO identity_accounts ( @@ -96,17 +65,12 @@ WHERE email = @admin_email ); --- Recover the real account id if the account already existed. SELECT id INTO @admin_id FROM identity_accounts WHERE email = @admin_email LIMIT 1; --- ------------------------------------------------------------ --- Company --- ------------------------------------------------------------ - INSERT INTO companies ( id, @@ -141,17 +105,12 @@ WHERE slug = @company_slug ); --- Recover the real company id if the company already existed. SELECT id INTO @company_id FROM companies WHERE slug = @company_slug LIMIT 1; --- ------------------------------------------------------------ --- Account-company membership --- ------------------------------------------------------------ - INSERT INTO identity_company_memberships ( id, @@ -171,21 +130,6 @@ WHERE AND company_id = @company_id ); --- ------------------------------------------------------------ --- Optional: force admin password/status update in local/dev --- ------------------------------------------------------------ --- --- UPDATE identity_accounts --- SET --- password_hash = @admin_password_hash, --- status = 'active', --- updated_at = CURRENT_TIMESTAMP --- WHERE email = @admin_email; - --- ------------------------------------------------------------ --- Final check --- ------------------------------------------------------------ - SELECT a.id AS account_id, a.email, @@ -202,4 +146,4 @@ FROM JOIN companies c ON c.id = m.company_id WHERE a.email = @admin_email - AND c.slug = @company_slug; \ No newline at end of file + AND c.slug = @company_slug; diff --git a/docs/frontend/auth-and-company-selection.md b/docs/frontend/auth-and-company-selection.md new file mode 100644 index 00000000..a0d6346d --- /dev/null +++ b/docs/frontend/auth-and-company-selection.md @@ -0,0 +1,174 @@ +# Frontend Auth And Company Selection + +## Runtime actual + +El frontend ERP usa el runtime nuevo: + +```txt +@erp/identity/client +IdentityAuthSessionProvider +DataSourceProvider +Axios compartido +``` + +No debe usarse `@erp/auth/client` como runtime nuevo. + +## Flujo actual + +```txt +login +-> GET /identity/auth/session +-> GET /companies/available +-> resolver empresa activa +-> auto-selección si hay una sola empresa +-> selector si hay varias +-> /no-companies si no hay empresas +-> X-Company-Id en rutas tenant-scoped +``` + +## Login + +El login usa: + +```http +POST /identity/auth/login +``` + +Tras login correcto: + +- se guardan `accessToken` y `refreshToken` +- se limpia cualquier `activeCompanyId` previo +- se carga `GET /companies/available` +- se resuelve el redirect autenticado según companies disponibles + +## Session restore + +Si existe `accessToken` persistido: + +1. se llama `GET /identity/auth/session` +2. se hidrata la sesión autenticada +3. se llama `GET /companies/available` +4. se resuelve `activeCompanyId` persistido o auto-selección + +Si la restauración falla: + +- se limpia la sesión +- se limpia `activeCompanyId` + +## Selección de empresa + +### Cero empresas + +Si `companies.length === 0`: + +- se muestra `/no-companies` +- no existe `activeCompanyId` +- no se envía `X-Company-Id` +- sigue disponible el logout + +### Una empresa + +Si `companies.length === 1`: + +- se selecciona automáticamente +- se guarda `activeCompanyId` +- no se muestra `/company-selection` +- se continúa al área privada + +### Varias empresas + +Si `companies.length > 1`: + +- se muestra `/company-selection` +- no existe `activeCompanyId` hasta elegir +- el usuario selecciona company +- se guarda `activeCompanyId` +- se continúa al área privada + +### `activeCompanyId` persistido ya no disponible + +Si el `activeCompanyId` persistido ya no aparece en `GET /companies/available`: + +- con `0` companies -> `/no-companies` +- con `1` company -> auto-selección de la disponible +- con varias companies -> `/company-selection` + +## `GET /companies/available` + +Endpoint actual: + +```http +GET /companies/available +Authorization: Bearer +``` + +No requiere: + +- `X-Company-Id` + +No usa: + +- `requireIdentityTenant(params)` + +Devuelve: + +```ts +export type AvailableCompaniesResponseDTO = { + companies: AvailableCompanyDTO[]; +}; +``` + +## Headers + +### No deben llevar `X-Company-Id` + +```txt +POST /identity/auth/login +POST /identity/auth/refresh +POST /identity/auth/logout +GET /identity/auth/session +GET /companies/available +``` + +### Sí deben llevar `X-Company-Id` + +Rutas tenant-scoped como: + +- `/customers` +- `/catalogs/*` +- `/customer-invoices/*` +- `/factuges` + +siempre que exista `activeCompanyId`. + +## Refresh automático sobre `401` + +El frontend implementa refresh automático global sobre Axios. + +Comportamiento: + +1. una request autenticada recibe `401` +2. si existe `refreshToken`, se llama `POST /identity/auth/refresh` +3. se guardan los nuevos tokens +4. se reintenta una única vez la request original +5. si refresh falla, se limpia la sesión y se redirige a `/login` + +## Reglas implementadas + +- retry único por request con `_retry` +- refresh concurrente único con `refreshPromise` compartida +- varias requests `401` esperan el mismo refresh +- login `401` no dispara refresh +- refresh `401` no intenta refrescarse a sí mismo +- `/identity/auth/refresh` no lleva `X-Company-Id` +- la request tenant-scoped reintentada conserva `X-Company-Id` + +## Endpoints excluidos de tenant header y refresh + +```txt +/identity/auth/login +/identity/auth/refresh +/identity/auth/logout +/identity/auth/session +/companies/available +```