From faf7b2251ca06ae12d7ceffbb6a4a580921ea104 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 5 Jul 2026 13:43:13 +0200 Subject: [PATCH] Identity -> pantalla de login --- apps/web/package.json | 3 +- apps/web/src/app.tsx | 24 +- apps/web/src/layout/app-topbar.tsx | 29 ++- apps/web/src/lib/token.ts | 43 +--- apps/web/src/register-modules.tsx | 4 +- apps/web/src/routes/app-routes.tsx | 13 +- apps/web/src/routes/public-only-guard.tsx | 17 +- apps/web/src/routes/require-auth-guard.tsx | 13 +- apps/web/vite.config.mts | 5 +- docs/architecture/README.md | 150 ++++++++++++ docs/architecture/identity-auth-api.md | 143 ++++++++++++ .../identity-backend-architecture.md | 221 ++++++++++++++++++ .../architecture/identity-migration-status.md | 163 +++++++++++++ docs/architecture/identity-roadmap.md | 217 +++++++++++++++++ .../use-datasource/datasource-context.tsx | 12 +- .../src/web/hooks/use-datasource/index.ts | 1 + .../hooks/use-datasource/use-datasource.tsx | 13 ++ modules/identity/package.json | 11 +- .../sequelize-account-domain.mapper.ts | 18 +- modules/identity/src/web/auth-routes.tsx | 18 ++ .../context/identity-auth-session.context.tsx | 187 +++++++++++++++ modules/identity/src/web/context/index.ts | 2 + modules/identity/src/web/hooks/index.ts | 2 + modules/identity/src/web/index.ts | 5 + .../src/web/login/controllers/index.ts | 3 + .../login/controllers/use-login-controller.ts | 49 ++++ .../controllers/use-login-page-controller.ts | 46 ++++ .../identity/src/web/login/entities/index.ts | 2 + .../web/login/entities/login-form.entity.ts | 10 + modules/identity/src/web/login/index.ts | 4 + modules/identity/src/web/login/ui/index.ts | 3 + .../identity/src/web/login/ui/login-card.tsx | 73 ++++++ .../identity/src/web/login/ui/login-page.tsx | 38 +++ modules/identity/src/web/manifest.ts | 17 ++ .../shared/adapters/auth-session.adapter.ts | 41 ++++ .../identity/src/web/shared/adapters/index.ts | 2 + .../web/shared/api/get-current-session.api.ts | 17 ++ .../web/shared/api/identity-auth-client.ts | 38 +++ modules/identity/src/web/shared/api/index.ts | 6 + .../identity/src/web/shared/api/login.api.ts | 14 ++ .../identity/src/web/shared/api/logout.api.ts | 24 ++ .../src/web/shared/api/refresh-session.api.ts | 14 ++ .../shared/entities/auth-session.entity.ts | 17 ++ .../entities/identity-auth-error.entity.ts | 10 + .../identity/src/web/shared/entities/index.ts | 3 + .../identity/src/web/shared/hooks/index.ts | 5 + .../shared/hooks/use-current-session-query.ts | 43 ++++ .../web/shared/hooks/use-login-mutation.ts | 32 +++ .../web/shared/hooks/use-logout-mutation.ts | 22 ++ .../hooks/use-refresh-session-mutation.ts | 35 +++ modules/identity/src/web/shared/index.ts | 6 + .../web/shared/storage/auth-token-storage.ts | 76 ++++++ .../identity/src/web/shared/storage/index.ts | 2 + modules/identity/tsconfig.json | 7 +- 54 files changed, 1881 insertions(+), 92 deletions(-) create mode 100644 docs/architecture/README.md create mode 100644 docs/architecture/identity-auth-api.md create mode 100644 docs/architecture/identity-backend-architecture.md create mode 100644 docs/architecture/identity-migration-status.md create mode 100644 docs/architecture/identity-roadmap.md create mode 100644 modules/core/src/web/hooks/use-datasource/use-datasource.tsx create mode 100644 modules/identity/src/web/auth-routes.tsx create mode 100644 modules/identity/src/web/context/identity-auth-session.context.tsx create mode 100644 modules/identity/src/web/context/index.ts create mode 100644 modules/identity/src/web/hooks/index.ts create mode 100644 modules/identity/src/web/index.ts create mode 100644 modules/identity/src/web/login/controllers/index.ts create mode 100644 modules/identity/src/web/login/controllers/use-login-controller.ts create mode 100644 modules/identity/src/web/login/controllers/use-login-page-controller.ts create mode 100644 modules/identity/src/web/login/entities/index.ts create mode 100644 modules/identity/src/web/login/entities/login-form.entity.ts create mode 100644 modules/identity/src/web/login/index.ts create mode 100644 modules/identity/src/web/login/ui/index.ts create mode 100644 modules/identity/src/web/login/ui/login-card.tsx create mode 100644 modules/identity/src/web/login/ui/login-page.tsx create mode 100644 modules/identity/src/web/manifest.ts create mode 100644 modules/identity/src/web/shared/adapters/auth-session.adapter.ts create mode 100644 modules/identity/src/web/shared/adapters/index.ts create mode 100644 modules/identity/src/web/shared/api/get-current-session.api.ts create mode 100644 modules/identity/src/web/shared/api/identity-auth-client.ts create mode 100644 modules/identity/src/web/shared/api/index.ts create mode 100644 modules/identity/src/web/shared/api/login.api.ts create mode 100644 modules/identity/src/web/shared/api/logout.api.ts create mode 100644 modules/identity/src/web/shared/api/refresh-session.api.ts create mode 100644 modules/identity/src/web/shared/entities/auth-session.entity.ts create mode 100644 modules/identity/src/web/shared/entities/identity-auth-error.entity.ts create mode 100644 modules/identity/src/web/shared/entities/index.ts create mode 100644 modules/identity/src/web/shared/hooks/index.ts create mode 100644 modules/identity/src/web/shared/hooks/use-current-session-query.ts create mode 100644 modules/identity/src/web/shared/hooks/use-login-mutation.ts create mode 100644 modules/identity/src/web/shared/hooks/use-logout-mutation.ts create mode 100644 modules/identity/src/web/shared/hooks/use-refresh-session-mutation.ts create mode 100644 modules/identity/src/web/shared/index.ts create mode 100644 modules/identity/src/web/shared/storage/auth-token-storage.ts create mode 100644 modules/identity/src/web/shared/storage/index.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5ce104e5..fa16ce7b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@erp/catalogs": "workspace:*", "@erp/customer-invoices": "workspace:*", "@erp/customers": "workspace:*", + "@erp/identity": "workspace:*", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", "@repo/i18next": "workspace:*", @@ -58,4 +59,4 @@ "tw-animate-css": "^1.4.0", "vite-plugin-html": "^3.2.2" } -} \ No newline at end of file +} diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 2417d0a5..027774f8 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,6 +1,6 @@ -import { AuthProvider, createAuthService } from "@erp/auth/client"; import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client"; import { DataSourceProvider } from "@erp/core/hooks"; +import { IdentityAuthSessionProvider, authTokenStorage } from "@erp/identity/client"; import { i18n } from "@repo/i18next"; import { LoadingOverlay } from "@repo/rdx-ui/components"; import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components"; @@ -11,7 +11,6 @@ import { Suspense } from "react"; import { I18nextProvider } from "react-i18next"; import { RouterProvider } from "react-router-dom"; -import { clearAccessToken, getAccessToken, setAccessToken } from "./lib"; import { getAppRouter } from "./routes"; export const App = () => { @@ -30,11 +29,13 @@ export const App = () => { const axiosInstance = createAxiosInstance({ baseURL: import.meta.env.VITE_API_SERVER_URL, - getAccessToken: () => null, //getAccessToken, + getAccessToken: () => authTokenStorage.getAccessToken(), onAuthError: () => { - //console.error("APP, Error de autenticación"); - //clearAccessToken(); - //window.location.href = "/login"; // o usar navegación programática + // TODO(identity-auth): siguiente incremento + // 1. detectar 401 recuperable + // 2. llamar refresh() + // 3. reintentar la request original + // 4. limpiar sesión si refresh falla }, }); @@ -45,14 +46,7 @@ export const App = () => { - + {/* Fallback Route */} }> @@ -61,7 +55,7 @@ export const App = () => { {import.meta.env.DEV && } - + diff --git a/apps/web/src/layout/app-topbar.tsx b/apps/web/src/layout/app-topbar.tsx index 0c15159b..130177f5 100644 --- a/apps/web/src/layout/app-topbar.tsx +++ b/apps/web/src/layout/app-topbar.tsx @@ -1,3 +1,4 @@ +import { useIdentityAuthSession } from "@erp/identity/client"; import { Button, DropdownMenu, @@ -12,8 +13,26 @@ import { SidebarTrigger, } from "@repo/shadcn-ui/components"; import { BellIcon, ChevronDownIcon, MessageSquareIcon, SearchIcon } from "lucide-react"; +import { useNavigate } from "react-router-dom"; export const AppTopbar = () => { + const navigate = useNavigate(); + const { account, logout } = useIdentityAuthSession(); + + const initials = account?.name + ? account.name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((chunk: string) => chunk[0]?.toUpperCase() ?? "") + .join("") + : "??"; + + const handleLogout = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + return (
@@ -78,14 +97,14 @@ export const AppTopbar = () => { variant="ghost" > - AM + {initials} - Ana Martínez + {account?.name ?? "Sesión activa"} - Administrador + {account?.email ?? "Sin perfil cargado"} @@ -99,7 +118,9 @@ export const AppTopbar = () => { Perfil Preferencias - Cerrar sesión + void handleLogout()}> + Cerrar sesión + diff --git a/apps/web/src/lib/token.ts b/apps/web/src/lib/token.ts index d647e25e..e46b2a06 100644 --- a/apps/web/src/lib/token.ts +++ b/apps/web/src/lib/token.ts @@ -1,41 +1,16 @@ -import secureLocalStorageImport from "react-secure-storage"; +import { authTokenStorage } from "@erp/identity/client"; -/** - * Servicio para manejar la obtención del token JWT desde el almacenamiento local. - * Este archivo puede evolucionar a un AuthService más completo en el futuro. - */ +export const getAccessToken = (): string | null => authTokenStorage.getAccessToken(); -// biome-ignore lint/suspicious/noExplicitAny: -const secureLocalStorage = (secureLocalStorageImport as any)?.default ?? secureLocalStorageImport; - -/** - * Clave utilizada en el almacenamiento local para el token JWT. - */ - -const TOKEN_STORAGE_KEY = "factuges.auth"; - -/** - * Obtiene el token JWT almacenado localmente. - * - * @returns El token como string, o null si no está disponible. - */ -export const getAccessToken = (): string | null => { - const authInfo = secureLocalStorage?.getItem?.(TOKEN_STORAGE_KEY) as { token?: string } | null; - return typeof authInfo?.token === "string" ? authInfo.token : null; -}; - -/** - * Almacena el token JWT localmente. - * - * @params El token como string. - */ export const setAccessToken = (token: string): void => { - secureLocalStorage?.setItem?.(TOKEN_STORAGE_KEY, token); + const currentTokens = authTokenStorage.getTokens(); + + authTokenStorage.setTokens({ + accessToken: token, + refreshToken: currentTokens.refreshToken ?? "", + }); }; -/** - * Limpia el token JWT del almacenamiento local. - */ export const clearAccessToken = (): void => { - secureLocalStorage?.removeItem?.(TOKEN_STORAGE_KEY); + authTokenStorage.clear(); }; diff --git a/apps/web/src/register-modules.tsx b/apps/web/src/register-modules.tsx index bea18ef5..785ee267 100644 --- a/apps/web/src/register-modules.tsx +++ b/apps/web/src/register-modules.tsx @@ -1,11 +1,11 @@ -import { AuthModuleManifest } from "@erp/auth/client"; import { CatalogsModuleManifest } from "@erp/catalogs/client"; import CoreModuleManifest, { type IModuleClient } from "@erp/core/client"; import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client"; import { CustomersModuleManifest } from "@erp/customers/client"; +import { IdentityModuleManifest } from "@erp/identity/client"; export const modules: IModuleClient[] = [ - AuthModuleManifest, + IdentityModuleManifest, CoreModuleManifest, CatalogsModuleManifest, CustomersModuleManifest, diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx index c06902f6..7cf79f78 100644 --- a/apps/web/src/routes/app-routes.tsx +++ b/apps/web/src/routes/app-routes.tsx @@ -43,14 +43,21 @@ export const getAppRouter = () => { }, { - path: "auth", element: , + children: authRoutes, + }, + + { + path: "auth", children: [ { index: true, - element: , + element: , + }, + { + path: "*", + element: , }, - ...authRoutes, ], }, diff --git a/apps/web/src/routes/public-only-guard.tsx b/apps/web/src/routes/public-only-guard.tsx index fc10b991..d6213876 100644 --- a/apps/web/src/routes/public-only-guard.tsx +++ b/apps/web/src/routes/public-only-guard.tsx @@ -1,17 +1,24 @@ -// apps/web/src/routes/guards/public-only-route.tsx +import { useIdentityAuthSession } from "@erp/identity/client"; +import { LoadingOverlay } from "@repo/rdx-ui/components"; import type { ReactNode } from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate, useSearchParams } from "react-router-dom"; interface PublicOnlyRouteProps { children: ReactNode; } export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => { - // TODO: sustituir por tu estado real de auth. - const isAuthenticated = false; + const { isAuthenticated, isLoading } = useIdentityAuthSession(); + const [searchParams] = useSearchParams(); + + const returnTo = searchParams.get("returnTo"); + + if (isLoading) { + return ; + } if (isAuthenticated) { - return ; + return ; } return <>{children}; diff --git a/apps/web/src/routes/require-auth-guard.tsx b/apps/web/src/routes/require-auth-guard.tsx index e02752b3..e2244e81 100644 --- a/apps/web/src/routes/require-auth-guard.tsx +++ b/apps/web/src/routes/require-auth-guard.tsx @@ -1,4 +1,5 @@ -// apps/web/src/routes/guards/require-auth.tsx +import { useIdentityAuthSession } from "@erp/identity/client"; +import { LoadingOverlay } from "@repo/rdx-ui/components"; import type { ReactNode } from "react"; import { Navigate, useLocation } from "react-router-dom"; @@ -8,12 +9,16 @@ interface RequireAuthRouteProps { export const RequireAuthRouteGuard = ({ children }: RequireAuthRouteProps) => { const location = useLocation(); + const { isAuthenticated, isLoading } = useIdentityAuthSession(); - // TODO: sustituir por tu estado real de auth. - const isAuthenticated = true; + if (isLoading) { + return ; + } if (!isAuthenticated) { - return ; + const returnTo = `${location.pathname}${location.search}`; + + return ; } return <>{children}; diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 6bc5096b..598543d3 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -1,4 +1,5 @@ import path from "node:path"; + import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; @@ -6,10 +7,10 @@ import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ build: { - sourcemap: true + sourcemap: true, }, define: { - "process.env": {} + "process.env": {}, }, plugins: [react(), tailwindcss()], resolve: { diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 00000000..0e6ff40b --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,150 @@ +# ERP Backend · Identity/Auth Migration Notes + +## Propósito + +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`. + +Debe usarse como referencia para futuros incrementos relacionados con: + +- 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) diff --git a/docs/architecture/identity-auth-api.md b/docs/architecture/identity-auth-api.md new file mode 100644 index 00000000..37725c4d --- /dev/null +++ b/docs/architecture/identity-auth-api.md @@ -0,0 +1,143 @@ +# Identity Auth API + +## Endpoints disponibles + +```http +POST /identity/auth/login +POST /identity/auth/refresh +POST /identity/auth/logout +GET /identity/auth/session +``` + +## POST `/identity/auth/login` + +### Request + +```ts +export type LoginWithPasswordRequestDTO = { + email: string; + password: string; +}; +``` + +### Response + +```ts +export type LoginWithPasswordResponseDTO = { + access_token: string; + refresh_token: string; + account: AuthenticatedAccountDTO; +}; +``` + +```ts +export type AuthenticatedAccountDTO = { + id: string; + email: string; + name: string; + avatar_url: string | null; + language_code: string; +}; +``` + +### 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`. + +## POST `/identity/auth/refresh` + +### Request + +```ts +export type RefreshSessionRequestDTO = { + refresh_token: string; +}; +``` + +### Response + +```ts +export type RefreshSessionResponseDTO = { + access_token: string; + refresh_token: string; +}; +``` + +### 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. + +## POST `/identity/auth/logout` + +### Headers + +```http +Authorization: Bearer +``` + +### Request + +```ts +export type LogoutRequestDTO = { + refresh_token?: string; + all_sessions?: boolean; +}; +``` + +### Response + +```ts +export type LogoutResponseDTO = { + success: true; +}; +``` + +### 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. + +## GET `/identity/auth/session` + +### Headers + +```http +Authorization: Bearer +``` + +### Response + +```ts +export type CurrentSessionResponseDTO = { + account: AuthenticatedAccountDTO; +}; +``` + +## Errores esperados + +```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 +``` + +## Rutas tenant-scoped + +Las rutas de negocio tenant-scoped deben enviar: + +```http +Authorization: Bearer +X-Company-Id: +``` + +Actualmente `X-Company-Id` se valida sintácticamente como UUID y se publica como `req.user.companyId`. + +La validación real de membership queda pendiente. diff --git a/docs/architecture/identity-backend-architecture.md b/docs/architecture/identity-backend-architecture.md new file mode 100644 index 00000000..e67ed35c --- /dev/null +++ b/docs/architecture/identity-backend-architecture.md @@ -0,0 +1,221 @@ +# Identity Backend Architecture + +## Objetivo del módulo `identity` + +`identity` sustituye progresivamente al módulo legacy `auth`. + +Responsabilidades previstas: + +- cuentas autenticables +- login/password +- access token +- refresh token rotado +- logout +- sesión actual +- empresas accesibles +- memberships +- roles +- permisos +- autorización futura + +## Dominio mínimo creado + +Agregados principales: + +```txt +Account +Company +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 +``` + +## Seguridad + +Servicios definidos: + +```txt +IPasswordHasher +IAccessTokenIssuer +IAccessTokenVerifier +IRefreshTokenGenerator +IRefreshTokenHasher +``` + +Implementaciones actuales: + +```txt +BcryptPasswordHasher +JsonWebTokenAccessTokenIssuer +JsonWebTokenAccessTokenVerifier +CryptoRefreshTokenGenerator +Sha256RefreshTokenHasher +``` + +Decisiones: + +- 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: + +```ts +requireIdentityAuthenticated(params: StartParams): RequestHandler[]; +requireIdentityTenant(params: StartParams): RequestHandler[]; +``` + +## Uso correcto en routers + +```ts +import { requireIdentityTenant } from "@erp/identity/api"; + +export function buildSomeRoutes(params: StartParams) { + const router = Router(); + + router.use(...requireIdentityTenant(params)); + + return router; +} +``` + +## Shape actual de `req.user` + +```ts +{ + userId: UniqueID; + email?: EmailAddress; + companyId?: UniqueID; + roles?: string[]; +} +``` + +Compatibilidad provisional: + +- `roles` puede ser `[]`. +- `companyId` viene de `X-Company-Id`. +- `companySlug` no debe depender de auth. + +## Prohibiciones de diseño + +No usar en módulos consumidores: + +```ts +IdentityInternalDeps; +authenticateUserDependencies; +getInternal("identity"); +``` + +No crear: + +```txt +-auth-middlewares.ts +``` + +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`. diff --git a/docs/architecture/identity-migration-status.md b/docs/architecture/identity-migration-status.md new file mode 100644 index 00000000..f5aeaf7a --- /dev/null +++ b/docs/architecture/identity-migration-status.md @@ -0,0 +1,163 @@ +# Identity Migration Status + +## Patrón final de middleware + +Rutas tenant-scoped: + +```ts +import { requireIdentityTenant } from "@erp/identity/api"; + +router.use(...requireIdentityTenant(params)); +``` + +Rutas autenticadas sin tenant: + +```ts +import { requireIdentityAuthenticated } from "@erp/identity/api"; + +router.use(...requireIdentityAuthenticated(params)); +``` + +## Migrado a `identity` + +### `modules/catalogs` + +Routers migrados: + +```txt +payment-methods.routes.ts +payment-terms.routes.ts +tax-regimes.routes.ts +tax-definitions.routes.ts +``` + +Estado: + +```txt +sin @erp/auth/api +sin mockUser +sin helpers locales de auth +usa requireIdentityTenant(params) +``` + +### `modules/customers` + +Router migrado: + +```txt +customers.routes.ts +``` + +Estado: + +```txt +sin @erp/auth/api +sin mockUser +sin helpers locales de auth +usa requireIdentityTenant(params) +``` + +### `modules/customer-invoices` + +Routers migrados: + +```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 + +### `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. + +## Búsquedas útiles de control + +```powershell +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 +``` + +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 new file mode 100644 index 00000000..16d9a405 --- /dev/null +++ b/docs/architecture/identity-roadmap.md @@ -0,0 +1,217 @@ +# Identity Roadmap + +## 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. + +## Fase 1 · Backend auth runtime + +Estado: prácticamente completado. + +Incluye: + +```txt +login +refresh rotado +logout +session +access token verifier +refresh token persistence +middlewares genéricos +``` + +Pendiente técnico recomendado: + +```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. + +Pendiente: + +```txt +GET /identity/companies +GET /identity/companies/current +PATCH /identity/companies/current +``` + +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. + +Pendiente: + +```txt +persistencia Company +persistencia CompanyMembership +validación accountId + companyId +bloqueo si membership disabled/invited +``` + +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 + +No hacer: + +```txt +- 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 +``` + +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 +``` diff --git a/modules/core/src/web/hooks/use-datasource/datasource-context.tsx b/modules/core/src/web/hooks/use-datasource/datasource-context.tsx index 8b5d999f..dac8f0fc 100644 --- a/modules/core/src/web/hooks/use-datasource/datasource-context.tsx +++ b/modules/core/src/web/hooks/use-datasource/datasource-context.tsx @@ -1,8 +1,8 @@ -import { createContext, useContext } from "react"; +import { createContext } from "react"; import type { IDataSource } from "../../lib/data-source/datasource.interface"; -const DataSourceContext = createContext(null); +export const DataSourceContext = createContext(null); export const DataSourceProvider = ({ dataSource, @@ -11,11 +11,3 @@ export const DataSourceProvider = ({ dataSource: IDataSource; children: React.ReactNode; }) => {children}; - -export const useDataSource = (): IDataSource => { - const context = useContext(DataSourceContext); - if (!context) { - throw new Error("useDataSource debe usarse dentro de "); - } - return context; -}; diff --git a/modules/core/src/web/hooks/use-datasource/index.ts b/modules/core/src/web/hooks/use-datasource/index.ts index 8844e491..774dee84 100644 --- a/modules/core/src/web/hooks/use-datasource/index.ts +++ b/modules/core/src/web/hooks/use-datasource/index.ts @@ -1 +1,2 @@ export * from "./datasource-context"; +export * from "./use-datasource"; diff --git a/modules/core/src/web/hooks/use-datasource/use-datasource.tsx b/modules/core/src/web/hooks/use-datasource/use-datasource.tsx new file mode 100644 index 00000000..f469cece --- /dev/null +++ b/modules/core/src/web/hooks/use-datasource/use-datasource.tsx @@ -0,0 +1,13 @@ +import { useContext } from "react"; + +import type { IDataSource } from "../../lib"; + +import { DataSourceContext } from "./datasource-context"; + +export const useDataSource = (): IDataSource => { + const context = useContext(DataSourceContext); + if (!context) { + throw new Error("useDataSource debe usarse dentro de "); + } + return context; +}; diff --git a/modules/identity/package.json b/modules/identity/package.json index bb41e809..9a0684ee 100644 --- a/modules/identity/package.json +++ b/modules/identity/package.json @@ -14,7 +14,12 @@ "exports": { ".": "./src/common/index.ts", "./common": "./src/common/index.ts", - "./api": "./src/api/index.ts" + "./api": "./src/api/index.ts", + "./client": "./src/web/index.ts" + }, + "peerDependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" }, "dependencies": { "@erp/core": "workspace:*", @@ -28,8 +33,10 @@ "bcrypt": "^6.0.0", "express": "^4.22.1", "jsonwebtoken": "^9.0.3", + "react-hook-form": "^7.72.1", "react-i18next": "^17.0.2", "react-router-dom": "^7.14.0", + "react-secure-storage": "^1.3.2", "sequelize": "^6.37.8", "zod": "^4.3.6" }, @@ -45,4 +52,4 @@ "rimraf": "^6.1.3", "typescript": "^6.0.2" } -} \ No newline at end of file +} diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-account-domain.mapper.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-account-domain.mapper.ts index 3a81437d..2d3c0aa2 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-account-domain.mapper.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-account-domain.mapper.ts @@ -6,7 +6,7 @@ import { type URLAddress, URLAddress as URLAddressValue, UniqueID, - UtcDate, + UtcDateTime, ValidationErrorCollection, type ValidationErrorDetail, extractOrPushError, @@ -17,8 +17,8 @@ import { Result } from "@repo/rdx-utils"; import { Account, - AccountStatus, type AccountInternalProps, + AccountStatus, PasswordHash, } from "../../../../domain"; import type { AccountCreationAttributes, AccountModel } from "../models"; @@ -32,10 +32,7 @@ export class SequelizeAccountDomainMapper extends SequelizeDomainMapper< AccountCreationAttributes, Account > { - public mapToDomain( - source: AccountModel, - _params?: MapperParamsType - ): Result { + public mapToDomain(source: AccountModel, _params?: MapperParamsType): Result { try { const errors: ValidationErrorDetail[] = []; @@ -59,17 +56,18 @@ export class SequelizeAccountDomainMapper extends SequelizeDomainMapper< ); const status = extractOrPushError(AccountStatus.create(source.status), "status", errors); const createdAt = extractOrPushError( - UtcDate.createFromISO(toUtcIsoString(source.created_at)), + UtcDateTime.createFromISO(toUtcIsoString(source.created_at)), "created_at", errors ); const updatedAt = extractOrPushError( - UtcDate.createFromISO(toUtcIsoString(source.updated_at)), + UtcDateTime.createFromISO(toUtcIsoString(source.updated_at)), "updated_at", errors ); if (errors.length > 0) { + console.error("Account props mapping failed", errors); return Result.fail(new ValidationErrorCollection("Account props mapping failed", errors)); } @@ -99,7 +97,9 @@ export class SequelizeAccountDomainMapper extends SequelizeDomainMapper< email: source.email.toPrimitive(), password_hash: source.passwordHash.toPrimitive(), name: source.name.toPrimitive(), - avatar_url: maybeToNullable(source.avatarUrl, (avatarUrl: URLAddress) => avatarUrl.toPrimitive()), + avatar_url: maybeToNullable(source.avatarUrl, (avatarUrl: URLAddress) => + avatarUrl.toPrimitive() + ), language_code: source.languageCode.toPrimitive(), status: source.status.toPrimitive(), created_at: source.createdAt.toISOString(), diff --git a/modules/identity/src/web/auth-routes.tsx b/modules/identity/src/web/auth-routes.tsx new file mode 100644 index 00000000..71eaad12 --- /dev/null +++ b/modules/identity/src/web/auth-routes.tsx @@ -0,0 +1,18 @@ +import type { ModuleClientParams } from "@erp/core/client"; +import type { RouteObject } from "react-router-dom"; + +import { LoginPage } from "./login"; + +export function IdentityAuthRoutes(_params: ModuleClientParams): RouteObject[] { + return [ + { + path: "login", + handle: { + layout: "auth", + protected: false, + }, + element: , + }, + ]; +} + diff --git a/modules/identity/src/web/context/identity-auth-session.context.tsx b/modules/identity/src/web/context/identity-auth-session.context.tsx new file mode 100644 index 00000000..efecb6e2 --- /dev/null +++ b/modules/identity/src/web/context/identity-auth-session.context.tsx @@ -0,0 +1,187 @@ +import { useDataSource } from "@erp/core/hooks"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; + +import type { LoginForm } from "../login/entities"; +import { + authTokenStorage, + mapCurrentSessionToAuthSession, + useLoginMutation, + useLogoutMutation, + useRefreshSessionMutation, +} from "../shared"; +import { getCurrentSession } from "../shared/api"; +import type { AuthSession } from "../shared/entities"; + +export interface IdentityAuthSessionContextValue { + session: AuthSession | null; + account: AuthSession["account"] | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (params: LoginForm) => Promise; + logout: () => Promise; + restoreSession: () => Promise; + refreshSession: () => Promise; + clearSession: () => void; +} + +const IdentityAuthSessionContext = createContext( + undefined +); + +export function IdentityAuthSessionProvider({ children }: { children: React.ReactNode }) { + const dataSource = useDataSource(); + const loginMutation = useLoginMutation(); + const logoutMutation = useLogoutMutation(); + const refreshSessionMutation = useRefreshSessionMutation(); + + const [session, setSession] = useState(null); + const [isRestoringSession, setIsRestoringSession] = useState(authTokenStorage.hasAccessToken()); + + const clearSession = useCallback(() => { + authTokenStorage.clear(); + setSession(null); + }, []); + + const restoreSession = useCallback(async () => { + const tokens = authTokenStorage.getTokens(); + + if (!tokens.accessToken) { + clearSession(); + setIsRestoringSession(false); + return null; + } + + setIsRestoringSession(true); + + try { + const response = await getCurrentSession(dataSource, tokens.accessToken); + const restoredSession = mapCurrentSessionToAuthSession(response, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken ?? "", + }); + + setSession(restoredSession); + + return restoredSession; + } catch { + clearSession(); + return null; + } finally { + setIsRestoringSession(false); + } + }, [clearSession, dataSource]); + + const login = useCallback( + async (params: LoginForm) => { + const nextSession = await loginMutation.mutateAsync(params); + + authTokenStorage.setTokens({ + accessToken: nextSession.accessToken, + refreshToken: nextSession.refreshToken, + }); + + setSession(nextSession); + + return nextSession; + }, + [loginMutation] + ); + + const logout = useCallback(async () => { + const tokens = authTokenStorage.getTokens(); + + try { + if (tokens.accessToken) { + await logoutMutation.mutateAsync({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }); + } + } finally { + clearSession(); + } + }, [clearSession, logoutMutation]); + + const refreshSession = useCallback(async () => { + const tokens = authTokenStorage.getTokens(); + + if (!tokens.refreshToken) { + clearSession(); + return null; + } + + try { + const refreshedTokens = await refreshSessionMutation.mutateAsync({ + refreshToken: tokens.refreshToken, + }); + + authTokenStorage.setTokens(refreshedTokens); + + const currentAccessToken = refreshedTokens.accessToken; + const currentRefreshToken = refreshedTokens.refreshToken; + const currentSessionResponse = await getCurrentSession(dataSource, currentAccessToken); + + const refreshedSession = mapCurrentSessionToAuthSession(currentSessionResponse, { + accessToken: currentAccessToken, + refreshToken: currentRefreshToken, + }); + + setSession(refreshedSession); + + return refreshedSession; + } catch { + clearSession(); + return null; + } + }, [clearSession, dataSource, refreshSessionMutation]); + + useEffect(() => { + void restoreSession(); + }, [restoreSession]); + + const value = useMemo( + () => ({ + session, + account: session?.account ?? null, + isAuthenticated: Boolean(session), + isLoading: + isRestoringSession || + loginMutation.isPending || + logoutMutation.isPending || + refreshSessionMutation.isPending, + login, + logout, + restoreSession, + refreshSession, + clearSession, + }), + [ + clearSession, + isRestoringSession, + login, + loginMutation.isPending, + logout, + logoutMutation.isPending, + refreshSession, + refreshSessionMutation.isPending, + restoreSession, + session, + ] + ); + + return ( + + {children} + + ); +} + +export function useIdentityAuthSession() { + const context = useContext(IdentityAuthSessionContext); + + if (!context) { + throw new Error("useIdentityAuthSession must be used within a IdentityAuthSessionProvider"); + } + + return context; +} diff --git a/modules/identity/src/web/context/index.ts b/modules/identity/src/web/context/index.ts new file mode 100644 index 00000000..7b9bb6a9 --- /dev/null +++ b/modules/identity/src/web/context/index.ts @@ -0,0 +1,2 @@ +export * from "./identity-auth-session.context"; + diff --git a/modules/identity/src/web/hooks/index.ts b/modules/identity/src/web/hooks/index.ts new file mode 100644 index 00000000..2ea48f55 --- /dev/null +++ b/modules/identity/src/web/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "../context"; +export * from "../shared/hooks"; diff --git a/modules/identity/src/web/index.ts b/modules/identity/src/web/index.ts new file mode 100644 index 00000000..c9df9425 --- /dev/null +++ b/modules/identity/src/web/index.ts @@ -0,0 +1,5 @@ +export * from "./context"; +export * from "./hooks"; +export * from "./manifest"; +export * from "./shared"; + diff --git a/modules/identity/src/web/login/controllers/index.ts b/modules/identity/src/web/login/controllers/index.ts new file mode 100644 index 00000000..4394cd2b --- /dev/null +++ b/modules/identity/src/web/login/controllers/index.ts @@ -0,0 +1,3 @@ +export * from "./use-login-controller"; +export * from "./use-login-page-controller"; + diff --git a/modules/identity/src/web/login/controllers/use-login-controller.ts b/modules/identity/src/web/login/controllers/use-login-controller.ts new file mode 100644 index 00000000..2f66fd31 --- /dev/null +++ b/modules/identity/src/web/login/controllers/use-login-controller.ts @@ -0,0 +1,49 @@ +import { useHookForm } from "@erp/core/hooks"; + +import { useIdentityAuthSession } from "../../context"; +import type { IdentityAuthError } from "../../shared/entities"; +import { type LoginForm, LoginFormSchema } from "../entities"; + +function getLoginErrorMessage(error: unknown) { + const status = (error as IdentityAuthError | undefined)?.status; + + if (status === 401) { + return "Email o contraseña no válidos."; + } + + if (status === 400) { + return "Revisa los datos introducidos."; + } + + return "No se ha podido iniciar sesión. Inténtalo de nuevo."; +} + +export function useLoginController({ onSuccess }: { onSuccess?: () => void }) { + const { login } = useIdentityAuthSession(); + + const form = useHookForm({ + resolverSchema: LoginFormSchema, + initialValues: { + email: "", + password: "", + }, + }); + + const handleSubmit = form.handleSubmit(async (values) => { + form.clearErrors("root"); + + try { + await login(values); + onSuccess?.(); + } catch (error) { + form.setError("root", { + message: getLoginErrorMessage(error), + }); + } + }); + + return { + form, + handleSubmit, + }; +} diff --git a/modules/identity/src/web/login/controllers/use-login-page-controller.ts b/modules/identity/src/web/login/controllers/use-login-page-controller.ts new file mode 100644 index 00000000..04adbf9c --- /dev/null +++ b/modules/identity/src/web/login/controllers/use-login-page-controller.ts @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import { useIdentityAuthSession } from "../../context"; + +import { useLoginController } from "./use-login-controller"; + +function resolveSafeReturnTo(returnTo: string | null, fallbackPath: string) { + if (!returnTo) { + return fallbackPath; + } + + if (!returnTo.startsWith("/")) { + return fallbackPath; + } + + if (returnTo.startsWith("//")) { + return fallbackPath; + } + + return returnTo; +} + +export function useLoginPageController() { + const navigate = useNavigate(); + const { isAuthenticated, isLoading } = useIdentityAuthSession(); + const [searchParams] = useSearchParams(); + + const redirectTo = useMemo( + () => resolveSafeReturnTo(searchParams.get("returnTo"), "/proformas"), + [searchParams] + ); + + const loginController = useLoginController({ + onSuccess: () => { + navigate(redirectTo, { replace: true }); + }, + }); + + return { + loginController, + redirectTo, + isAuthenticated, + isLoading, + }; +} diff --git a/modules/identity/src/web/login/entities/index.ts b/modules/identity/src/web/login/entities/index.ts new file mode 100644 index 00000000..af2cce15 --- /dev/null +++ b/modules/identity/src/web/login/entities/index.ts @@ -0,0 +1,2 @@ +export * from "./login-form.entity"; + diff --git a/modules/identity/src/web/login/entities/login-form.entity.ts b/modules/identity/src/web/login/entities/login-form.entity.ts new file mode 100644 index 00000000..0d0b25a2 --- /dev/null +++ b/modules/identity/src/web/login/entities/login-form.entity.ts @@ -0,0 +1,10 @@ +import { LoginWithPasswordRequestSchema } from "@erp/identity/common"; +import { z } from "zod/v4"; + +export const LoginFormSchema = LoginWithPasswordRequestSchema.extend({ + email: z.email("Introduce un email válido."), + password: z.string().min(1, "La contraseña es obligatoria."), +}); + +export type LoginForm = z.infer; + diff --git a/modules/identity/src/web/login/index.ts b/modules/identity/src/web/login/index.ts new file mode 100644 index 00000000..3bab01b3 --- /dev/null +++ b/modules/identity/src/web/login/index.ts @@ -0,0 +1,4 @@ +export * from "./controllers"; +export * from "./entities"; +export * from "./ui"; + diff --git a/modules/identity/src/web/login/ui/index.ts b/modules/identity/src/web/login/ui/index.ts new file mode 100644 index 00000000..3f46e22e --- /dev/null +++ b/modules/identity/src/web/login/ui/index.ts @@ -0,0 +1,3 @@ +export * from "./login-card"; +export * from "./login-page"; + diff --git a/modules/identity/src/web/login/ui/login-card.tsx b/modules/identity/src/web/login/ui/login-card.tsx new file mode 100644 index 00000000..f3c20555 --- /dev/null +++ b/modules/identity/src/web/login/ui/login-card.tsx @@ -0,0 +1,73 @@ +import { TextField } from "@repo/rdx-ui/components"; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Form, + Spinner, +} from "@repo/shadcn-ui/components"; +import type { UseFormReturn } from "react-hook-form"; + +import type { LoginForm } from "../entities"; + +interface LoginCardProps { + form: UseFormReturn; + onSubmit: () => void; +} + +export function LoginCard({ form, onSubmit }: LoginCardProps) { + const rootError = form.formState.errors.root?.message; + + return ( + + + Accede al ERP + Introduce tu email y contraseña para continuar. + + + +
+ + + autoComplete="email" + label="Email" + name="email" + placeholder="tu@email.com" + required + typePreset="email" + /> + + + autoComplete="current-password" + label="Contraseña" + name="password" + placeholder="••••••••" + required + typePreset="password" + /> + + {rootError ? ( +

+ {rootError} +

+ ) : null} + + + + +
+
+ ); +} diff --git a/modules/identity/src/web/login/ui/login-page.tsx b/modules/identity/src/web/login/ui/login-page.tsx new file mode 100644 index 00000000..2981e8e0 --- /dev/null +++ b/modules/identity/src/web/login/ui/login-page.tsx @@ -0,0 +1,38 @@ +import { Navigate } from "react-router-dom"; + +import { useLoginPageController } from "../controllers"; + +import { LoginCard } from "./login-card"; + +export function LoginPage() { + const { loginController, redirectTo, isAuthenticated, isLoading } = useLoginPageController(); + + if (!isLoading && isAuthenticated) { + return ; + } + + return ( +
+ + ); +} diff --git a/modules/identity/src/web/manifest.ts b/modules/identity/src/web/manifest.ts new file mode 100644 index 00000000..abe5ebd3 --- /dev/null +++ b/modules/identity/src/web/manifest.ts @@ -0,0 +1,17 @@ +import type { IModuleClient } from "@erp/core/client"; + +import { IdentityAuthRoutes } from "./auth-routes"; + +const MODULE_NAME = "identity"; +const MODULE_VERSION = "1.0.0"; + +export const IdentityModuleManifest: IModuleClient = { + name: MODULE_NAME, + version: MODULE_VERSION, + dependencies: ["Core"], + protected: false, + layout: "auth", + routes: (params) => IdentityAuthRoutes(params), +}; + +export default IdentityModuleManifest; diff --git a/modules/identity/src/web/shared/adapters/auth-session.adapter.ts b/modules/identity/src/web/shared/adapters/auth-session.adapter.ts new file mode 100644 index 00000000..aa8e8be8 --- /dev/null +++ b/modules/identity/src/web/shared/adapters/auth-session.adapter.ts @@ -0,0 +1,41 @@ +import type { + AuthenticatedAccountDTO, + CurrentSessionResponseDTO, + LoginWithPasswordResponseDTO, +} from "@erp/identity/common"; + +import type { AuthSession, AuthTokens } from "../entities"; + +export function mapAuthenticatedAccount( + dto: AuthenticatedAccountDTO +): AuthSession["account"] { + return { + id: dto.id, + email: dto.email, + name: dto.name, + avatarUrl: dto.avatar_url, + languageCode: dto.language_code, + }; +} + +export function mapLoginResponseToAuthSession( + dto: LoginWithPasswordResponseDTO +): AuthSession { + return { + accessToken: dto.access_token, + refreshToken: dto.refresh_token, + account: mapAuthenticatedAccount(dto.account), + }; +} + +export function mapCurrentSessionToAuthSession( + dto: CurrentSessionResponseDTO, + tokens: AuthTokens +): AuthSession { + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + account: mapAuthenticatedAccount(dto.account), + }; +} + diff --git a/modules/identity/src/web/shared/adapters/index.ts b/modules/identity/src/web/shared/adapters/index.ts new file mode 100644 index 00000000..e9a067d0 --- /dev/null +++ b/modules/identity/src/web/shared/adapters/index.ts @@ -0,0 +1,2 @@ +export * from "./auth-session.adapter"; + diff --git a/modules/identity/src/web/shared/api/get-current-session.api.ts b/modules/identity/src/web/shared/api/get-current-session.api.ts new file mode 100644 index 00000000..e506f9d4 --- /dev/null +++ b/modules/identity/src/web/shared/api/get-current-session.api.ts @@ -0,0 +1,17 @@ +import type { IDataSource } from "@erp/core/client"; +import type { CurrentSessionResponseDTO } from "@erp/identity/common"; + +export function getCurrentSession( + dataSource: IDataSource, + accessToken: string +): Promise { + return dataSource.custom, CurrentSessionResponseDTO>({ + path: "identity/auth/session", + method: "get", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + data: {}, + }); +} + diff --git a/modules/identity/src/web/shared/api/identity-auth-client.ts b/modules/identity/src/web/shared/api/identity-auth-client.ts new file mode 100644 index 00000000..31f19e6b --- /dev/null +++ b/modules/identity/src/web/shared/api/identity-auth-client.ts @@ -0,0 +1,38 @@ +import type { IDataSource } from "@erp/core/client"; +import type { + CurrentSessionResponseDTO, + LoginWithPasswordRequestDTO, + LoginWithPasswordResponseDTO, + LogoutRequestDTO, + LogoutResponseDTO, + RefreshSessionRequestDTO, + RefreshSessionResponseDTO, +} from "@erp/identity/common"; + +import { getCurrentSession } from "./get-current-session.api"; +import { login } from "./login.api"; +import { refreshSession } from "./refresh-session.api"; + +export interface IdentityAuthClient { + login(params: LoginWithPasswordRequestDTO): Promise; + refresh(params: RefreshSessionRequestDTO): Promise; + logout(params: LogoutRequestDTO, accessToken: string): Promise; + getCurrentSession(accessToken: string): Promise; +} + +export function createIdentityAuthClient(dataSource: IDataSource): IdentityAuthClient { + return { + login: (params) => login(dataSource, params), + refresh: (params) => refreshSession(dataSource, params), + logout: (params, accessToken) => + dataSource.custom({ + path: "identity/auth/logout", + method: "post", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + data: params, + }), + getCurrentSession: (accessToken) => getCurrentSession(dataSource, accessToken), + }; +} diff --git a/modules/identity/src/web/shared/api/index.ts b/modules/identity/src/web/shared/api/index.ts new file mode 100644 index 00000000..6b22afe5 --- /dev/null +++ b/modules/identity/src/web/shared/api/index.ts @@ -0,0 +1,6 @@ +export * from "./get-current-session.api"; +export * from "./identity-auth-client"; +export * from "./login.api"; +export * from "./logout.api"; +export * from "./refresh-session.api"; + diff --git a/modules/identity/src/web/shared/api/login.api.ts b/modules/identity/src/web/shared/api/login.api.ts new file mode 100644 index 00000000..d0d898be --- /dev/null +++ b/modules/identity/src/web/shared/api/login.api.ts @@ -0,0 +1,14 @@ +import type { IDataSource } from "@erp/core/client"; +import type { LoginWithPasswordRequestDTO, LoginWithPasswordResponseDTO } from "@erp/identity/common"; + +export function login( + dataSource: IDataSource, + params: LoginWithPasswordRequestDTO +): Promise { + return dataSource.custom({ + path: "identity/auth/login", + method: "post", + data: params, + }); +} + diff --git a/modules/identity/src/web/shared/api/logout.api.ts b/modules/identity/src/web/shared/api/logout.api.ts new file mode 100644 index 00000000..f32e33e4 --- /dev/null +++ b/modules/identity/src/web/shared/api/logout.api.ts @@ -0,0 +1,24 @@ +import type { IDataSource } from "@erp/core/client"; +import type { LogoutRequestDTO, LogoutResponseDTO } from "@erp/identity/common"; + +export interface LogoutParams { + accessToken: string; + refreshToken: string | null; +} + +export function logout( + dataSource: IDataSource, + params: LogoutParams +): Promise { + return dataSource.custom({ + path: "identity/auth/logout", + method: "post", + headers: { + Authorization: `Bearer ${params.accessToken}`, + }, + data: { + refresh_token: params.refreshToken ?? undefined, + }, + }); +} + diff --git a/modules/identity/src/web/shared/api/refresh-session.api.ts b/modules/identity/src/web/shared/api/refresh-session.api.ts new file mode 100644 index 00000000..f9d599e2 --- /dev/null +++ b/modules/identity/src/web/shared/api/refresh-session.api.ts @@ -0,0 +1,14 @@ +import type { IDataSource } from "@erp/core/client"; +import type { RefreshSessionRequestDTO, RefreshSessionResponseDTO } from "@erp/identity/common"; + +export function refreshSession( + dataSource: IDataSource, + params: RefreshSessionRequestDTO +): Promise { + return dataSource.custom({ + path: "identity/auth/refresh", + method: "post", + data: params, + }); +} + diff --git a/modules/identity/src/web/shared/entities/auth-session.entity.ts b/modules/identity/src/web/shared/entities/auth-session.entity.ts new file mode 100644 index 00000000..e0ea3340 --- /dev/null +++ b/modules/identity/src/web/shared/entities/auth-session.entity.ts @@ -0,0 +1,17 @@ +export interface AuthSession { + accessToken: string; + refreshToken: string; + account: { + id: string; + email: string; + name: string; + avatarUrl: string | null; + languageCode: string; + }; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; +} + diff --git a/modules/identity/src/web/shared/entities/identity-auth-error.entity.ts b/modules/identity/src/web/shared/entities/identity-auth-error.entity.ts new file mode 100644 index 00000000..ac0edec0 --- /dev/null +++ b/modules/identity/src/web/shared/entities/identity-auth-error.entity.ts @@ -0,0 +1,10 @@ +export class IdentityAuthError extends Error { + status: number; + + constructor(message: string, status = 0) { + super(message); + this.name = "IdentityAuthError"; + this.status = status; + } +} + diff --git a/modules/identity/src/web/shared/entities/index.ts b/modules/identity/src/web/shared/entities/index.ts new file mode 100644 index 00000000..2aae0af4 --- /dev/null +++ b/modules/identity/src/web/shared/entities/index.ts @@ -0,0 +1,3 @@ +export * from "./auth-session.entity"; +export * from "./identity-auth-error.entity"; + diff --git a/modules/identity/src/web/shared/hooks/index.ts b/modules/identity/src/web/shared/hooks/index.ts new file mode 100644 index 00000000..32eb05d3 --- /dev/null +++ b/modules/identity/src/web/shared/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./use-current-session-query"; +export * from "./use-login-mutation"; +export * from "./use-logout-mutation"; +export * from "./use-refresh-session-mutation"; + diff --git a/modules/identity/src/web/shared/hooks/use-current-session-query.ts b/modules/identity/src/web/shared/hooks/use-current-session-query.ts new file mode 100644 index 00000000..364e8c1e --- /dev/null +++ b/modules/identity/src/web/shared/hooks/use-current-session-query.ts @@ -0,0 +1,43 @@ +import { useDataSource } from "@erp/core/hooks"; +import { CurrentSessionResponseSchema } from "@erp/identity/common"; +import { useQuery } from "@tanstack/react-query"; + +import { mapCurrentSessionToAuthSession } from "../adapters"; +import { getCurrentSession } from "../api"; +import { type AuthSession, IdentityAuthError } from "../entities"; + +interface UseCurrentSessionQueryParams { + accessToken: string | null; + refreshToken: string | null; + enabled?: boolean; +} + +export function useCurrentSessionQuery({ + accessToken, + refreshToken, + enabled = true, +}: UseCurrentSessionQueryParams) { + const dataSource = useDataSource(); + + return useQuery({ + queryKey: ["identity", "auth", "session", accessToken], + enabled: enabled && Boolean(accessToken), + queryFn: async () => { + if (!accessToken) { + throw new IdentityAuthError("Missing access token"); + } + + const response = await getCurrentSession(dataSource, accessToken); + const parsedResponse = CurrentSessionResponseSchema.safeParse(response); + + if (!parsedResponse.success) { + throw new IdentityAuthError("Invalid current session response"); + } + + return mapCurrentSessionToAuthSession(parsedResponse.data, { + accessToken, + refreshToken: refreshToken ?? "", + }); + }, + }); +} diff --git a/modules/identity/src/web/shared/hooks/use-login-mutation.ts b/modules/identity/src/web/shared/hooks/use-login-mutation.ts new file mode 100644 index 00000000..efaed48c --- /dev/null +++ b/modules/identity/src/web/shared/hooks/use-login-mutation.ts @@ -0,0 +1,32 @@ +import { useDataSource } from "@erp/core/hooks"; +import { + LoginWithPasswordRequestSchema, + LoginWithPasswordResponseSchema, + type LoginWithPasswordRequestDTO, +} from "@erp/identity/common"; +import { useMutation } from "@tanstack/react-query"; + +import { mapLoginResponseToAuthSession } from "../adapters"; +import { IdentityAuthError, type AuthSession } from "../entities"; + +import { login } from "../api"; + +export function useLoginMutation() { + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: ["identity", "auth", "login"], + mutationFn: async (params) => { + const request = LoginWithPasswordRequestSchema.parse(params); + const response = await login(dataSource, request); + const parsedResponse = LoginWithPasswordResponseSchema.safeParse(response); + + if (!parsedResponse.success) { + throw new IdentityAuthError("Invalid login response"); + } + + return mapLoginResponseToAuthSession(parsedResponse.data); + }, + }); +} + diff --git a/modules/identity/src/web/shared/hooks/use-logout-mutation.ts b/modules/identity/src/web/shared/hooks/use-logout-mutation.ts new file mode 100644 index 00000000..10f62fc2 --- /dev/null +++ b/modules/identity/src/web/shared/hooks/use-logout-mutation.ts @@ -0,0 +1,22 @@ +import { useDataSource } from "@erp/core/hooks"; +import { LogoutResponseSchema } from "@erp/identity/common"; +import { useMutation } from "@tanstack/react-query"; + +import { type LogoutParams, logout } from "../api"; +import { IdentityAuthError } from "../entities"; + +export function useLogoutMutation() { + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: ["identity", "auth", "logout"], + mutationFn: async (params) => { + const response = await logout(dataSource, params); + const parsedResponse = LogoutResponseSchema.safeParse(response); + + if (!parsedResponse.success) { + throw new IdentityAuthError("Invalid logout response"); + } + }, + }); +} diff --git a/modules/identity/src/web/shared/hooks/use-refresh-session-mutation.ts b/modules/identity/src/web/shared/hooks/use-refresh-session-mutation.ts new file mode 100644 index 00000000..1fad8e2d --- /dev/null +++ b/modules/identity/src/web/shared/hooks/use-refresh-session-mutation.ts @@ -0,0 +1,35 @@ +import { useDataSource } from "@erp/core/hooks"; +import { + type RefreshSessionRequestDTO, + RefreshSessionRequestSchema, + RefreshSessionResponseSchema, +} from "@erp/identity/common"; +import { useMutation } from "@tanstack/react-query"; + +import { refreshSession } from "../api"; +import { type AuthTokens, IdentityAuthError } from "../entities"; + +export function useRefreshSessionMutation() { + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: ["identity", "auth", "refresh"], + mutationFn: async ({ refreshToken }) => { + const request: RefreshSessionRequestDTO = RefreshSessionRequestSchema.parse({ + refresh_token: refreshToken, + }); + + const response = await refreshSession(dataSource, request); + const parsedResponse = RefreshSessionResponseSchema.safeParse(response); + + if (!parsedResponse.success) { + throw new IdentityAuthError("Invalid refresh response"); + } + + return { + accessToken: parsedResponse.data.access_token, + refreshToken: parsedResponse.data.refresh_token, + }; + }, + }); +} diff --git a/modules/identity/src/web/shared/index.ts b/modules/identity/src/web/shared/index.ts new file mode 100644 index 00000000..813baa2f --- /dev/null +++ b/modules/identity/src/web/shared/index.ts @@ -0,0 +1,6 @@ +export * from "./adapters"; +export * from "./api"; +export * from "./entities"; +export * from "./hooks"; +export * from "./storage"; + diff --git a/modules/identity/src/web/shared/storage/auth-token-storage.ts b/modules/identity/src/web/shared/storage/auth-token-storage.ts new file mode 100644 index 00000000..cf370833 --- /dev/null +++ b/modules/identity/src/web/shared/storage/auth-token-storage.ts @@ -0,0 +1,76 @@ +import secureLocalStorageImport from "react-secure-storage"; + +import type { AuthTokens } from "../entities"; + +interface StoredAuthState { + accessToken?: string; + refreshToken?: string; + token?: string; +} + +const secureLocalStorage = + (secureLocalStorageImport as { default?: typeof secureLocalStorageImport })?.default ?? + secureLocalStorageImport; + +const TOKEN_STORAGE_KEY = "factuges.auth"; + +const EMPTY_TOKENS: { accessToken: null; refreshToken: null } = { + accessToken: null, + refreshToken: null, +}; + +function normalizeStoredAuthState(value: unknown): StoredAuthState | null { + if (typeof value === "string") { + return { + accessToken: value, + }; + } + + if (value && typeof value === "object") { + return value as StoredAuthState; + } + + return null; +} + +export const authTokenStorage = { + getTokens(): { accessToken: string | null; refreshToken: string | null } { + const storedValue = normalizeStoredAuthState(secureLocalStorage?.getItem?.(TOKEN_STORAGE_KEY)); + + if (!storedValue) { + return EMPTY_TOKENS; + } + + return { + accessToken: + typeof storedValue.accessToken === "string" + ? storedValue.accessToken + : typeof storedValue.token === "string" + ? storedValue.token + : null, + refreshToken: + typeof storedValue.refreshToken === "string" ? storedValue.refreshToken : null, + }; + }, + + getAccessToken(): string | null { + return this.getTokens().accessToken; + }, + + setTokens(tokens: AuthTokens): void { + secureLocalStorage?.setItem?.(TOKEN_STORAGE_KEY, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + token: tokens.accessToken, + }); + }, + + clear(): void { + secureLocalStorage?.removeItem?.(TOKEN_STORAGE_KEY); + }, + + hasAccessToken(): boolean { + return Boolean(this.getAccessToken()); + }, +}; + diff --git a/modules/identity/src/web/shared/storage/index.ts b/modules/identity/src/web/shared/storage/index.ts new file mode 100644 index 00000000..7b42d97b --- /dev/null +++ b/modules/identity/src/web/shared/storage/index.ts @@ -0,0 +1,2 @@ +export * from "./auth-token-storage"; + diff --git a/modules/identity/tsconfig.json b/modules/identity/tsconfig.json index 4a12a119..bafadf9b 100644 --- a/modules/identity/tsconfig.json +++ b/modules/identity/tsconfig.json @@ -7,14 +7,19 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + + /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true,