@@ -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.
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ Identity
+
+
+ Inicio de sesión
+
+
+ Nuevo acceso unificado para el frontend ERP.
+
+
+
+
+
+
+ );
+}
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,