Identity -> pantalla de login

This commit is contained in:
David Arranz 2026-07-05 13:43:13 +02:00
parent 494c675483
commit faf7b2251c
54 changed files with 1881 additions and 92 deletions

View File

@ -34,6 +34,7 @@
"@erp/catalogs": "workspace:*", "@erp/catalogs": "workspace:*",
"@erp/customer-invoices": "workspace:*", "@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*", "@erp/customers": "workspace:*",
"@erp/identity": "workspace:*",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7", "@fontsource-variable/geist-mono": "^5.2.7",
"@repo/i18next": "workspace:*", "@repo/i18next": "workspace:*",
@ -58,4 +59,4 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vite-plugin-html": "^3.2.2" "vite-plugin-html": "^3.2.2"
} }
} }

View File

@ -1,6 +1,6 @@
import { AuthProvider, createAuthService } from "@erp/auth/client";
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client"; import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import { DataSourceProvider } from "@erp/core/hooks"; import { DataSourceProvider } from "@erp/core/hooks";
import { IdentityAuthSessionProvider, authTokenStorage } from "@erp/identity/client";
import { i18n } from "@repo/i18next"; import { i18n } from "@repo/i18next";
import { LoadingOverlay } from "@repo/rdx-ui/components"; import { LoadingOverlay } from "@repo/rdx-ui/components";
import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components"; import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components";
@ -11,7 +11,6 @@ import { Suspense } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
import { getAppRouter } from "./routes"; import { getAppRouter } from "./routes";
export const App = () => { export const App = () => {
@ -30,11 +29,13 @@ export const App = () => {
const axiosInstance = createAxiosInstance({ const axiosInstance = createAxiosInstance({
baseURL: import.meta.env.VITE_API_SERVER_URL, baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken: () => null, //getAccessToken, getAccessToken: () => authTokenStorage.getAccessToken(),
onAuthError: () => { onAuthError: () => {
//console.error("APP, Error de autenticación"); // TODO(identity-auth): siguiente incremento
//clearAccessToken(); // 1. detectar 401 recuperable
//window.location.href = "/login"; // o usar navegación programática // 2. llamar refresh()
// 3. reintentar la request original
// 4. limpiar sesión si refresh falla
}, },
}); });
@ -45,14 +46,7 @@ export const App = () => {
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<DataSourceProvider dataSource={dataSource}> <DataSourceProvider dataSource={dataSource}>
<AuthProvider <IdentityAuthSessionProvider>
params={{
getAccessToken,
setAccessToken,
clearAccessToken,
authService: createAuthService(),
}}
>
<TooltipProvider> <TooltipProvider>
{/* Fallback Route */} {/* Fallback Route */}
<Suspense fallback={<LoadingOverlay />}> <Suspense fallback={<LoadingOverlay />}>
@ -61,7 +55,7 @@ export const App = () => {
</TooltipProvider> </TooltipProvider>
<Toaster expand={true} position="top-center" richColors /> <Toaster expand={true} position="top-center" richColors />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AuthProvider> </IdentityAuthSessionProvider>
</DataSourceProvider> </DataSourceProvider>
</QueryClientProvider> </QueryClientProvider>
</I18nextProvider> </I18nextProvider>

View File

@ -1,3 +1,4 @@
import { useIdentityAuthSession } from "@erp/identity/client";
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@ -12,8 +13,26 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { BellIcon, ChevronDownIcon, MessageSquareIcon, SearchIcon } from "lucide-react"; import { BellIcon, ChevronDownIcon, MessageSquareIcon, SearchIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
export const AppTopbar = () => { 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 ( return (
<header className="flex h-14 shrink-0 items-center border-b border-border bg-background"> <header className="flex h-14 shrink-0 items-center border-b border-border bg-background">
<div className="flex h-full min-w-0 flex-1 items-center gap-3 px-4"> <div className="flex h-full min-w-0 flex-1 items-center gap-3 px-4">
@ -78,14 +97,14 @@ export const AppTopbar = () => {
variant="ghost" variant="ghost"
> >
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-sm font-semibold text-amber-800"> <span className="flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-sm font-semibold text-amber-800">
AM {initials}
</span> </span>
<span className="hidden min-w-0 text-left md:block"> <span className="hidden min-w-0 text-left md:block">
<span className="block truncate text-sm font-semibold leading-5"> <span className="block truncate text-sm font-semibold leading-5">
Ana Martínez {account?.name ?? "Sesión activa"}
</span> </span>
<span className="block truncate text-xs font-normal text-muted-foreground"> <span className="block truncate text-xs font-normal text-muted-foreground">
Administrador {account?.email ?? "Sin perfil cargado"}
</span> </span>
</span> </span>
<ChevronDownIcon className="hidden size-4 text-muted-foreground md:block" /> <ChevronDownIcon className="hidden size-4 text-muted-foreground md:block" />
@ -99,7 +118,9 @@ export const AppTopbar = () => {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem>Perfil</DropdownMenuItem> <DropdownMenuItem>Perfil</DropdownMenuItem>
<DropdownMenuItem>Preferencias</DropdownMenuItem> <DropdownMenuItem>Preferencias</DropdownMenuItem>
<DropdownMenuItem>Cerrar sesión</DropdownMenuItem> <DropdownMenuItem onClick={() => void handleLogout()}>
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -1,41 +1,16 @@
import secureLocalStorageImport from "react-secure-storage"; import { authTokenStorage } from "@erp/identity/client";
/** export const getAccessToken = (): string | null => authTokenStorage.getAccessToken();
* 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.
*/
// biome-ignore lint/suspicious/noExplicitAny: <Es para que funcione>
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 => { 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 => { export const clearAccessToken = (): void => {
secureLocalStorage?.removeItem?.(TOKEN_STORAGE_KEY); authTokenStorage.clear();
}; };

View File

@ -1,11 +1,11 @@
import { AuthModuleManifest } from "@erp/auth/client";
import { CatalogsModuleManifest } from "@erp/catalogs/client"; import { CatalogsModuleManifest } from "@erp/catalogs/client";
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client"; import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client"; import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
import { CustomersModuleManifest } from "@erp/customers/client"; import { CustomersModuleManifest } from "@erp/customers/client";
import { IdentityModuleManifest } from "@erp/identity/client";
export const modules: IModuleClient[] = [ export const modules: IModuleClient[] = [
AuthModuleManifest, IdentityModuleManifest,
CoreModuleManifest, CoreModuleManifest,
CatalogsModuleManifest, CatalogsModuleManifest,
CustomersModuleManifest, CustomersModuleManifest,

View File

@ -43,14 +43,21 @@ export const getAppRouter = () => {
}, },
{ {
path: "auth",
element: <AuthLayout />, element: <AuthLayout />,
children: authRoutes,
},
{
path: "auth",
children: [ children: [
{ {
index: true, index: true,
element: <Navigate replace to="login" />, element: <Navigate replace to="/login" />,
},
{
path: "*",
element: <Navigate replace to="/login" />,
}, },
...authRoutes,
], ],
}, },

View File

@ -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 type { ReactNode } from "react";
import { Navigate } from "react-router-dom"; import { Navigate, useSearchParams } from "react-router-dom";
interface PublicOnlyRouteProps { interface PublicOnlyRouteProps {
children: ReactNode; children: ReactNode;
} }
export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => { export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => {
// TODO: sustituir por tu estado real de auth. const { isAuthenticated, isLoading } = useIdentityAuthSession();
const isAuthenticated = false; const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo");
if (isLoading) {
return <LoadingOverlay />;
}
if (isAuthenticated) { if (isAuthenticated) {
return <Navigate replace to="/proformas" />; return <Navigate replace to={returnTo?.startsWith("/") ? returnTo : "/proformas"} />;
} }
return <>{children}</>; return <>{children}</>;

View File

@ -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 type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
@ -8,12 +9,16 @@ interface RequireAuthRouteProps {
export const RequireAuthRouteGuard = ({ children }: RequireAuthRouteProps) => { export const RequireAuthRouteGuard = ({ children }: RequireAuthRouteProps) => {
const location = useLocation(); const location = useLocation();
const { isAuthenticated, isLoading } = useIdentityAuthSession();
// TODO: sustituir por tu estado real de auth. if (isLoading) {
const isAuthenticated = true; return <LoadingOverlay />;
}
if (!isAuthenticated) { if (!isAuthenticated) {
return <Navigate replace state={{ from: location }} to="/auth/login" />; const returnTo = `${location.pathname}${location.search}`;
return <Navigate replace to={`/login?returnTo=${encodeURIComponent(returnTo)}`} />;
} }
return <>{children}</>; return <>{children}</>;

View File

@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
@ -6,10 +7,10 @@ import { defineConfig } from "vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
build: { build: {
sourcemap: true sourcemap: true,
}, },
define: { define: {
"process.env": {} "process.env": {},
}, },
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {

150
docs/architecture/README.md Normal file
View File

@ -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)

View File

@ -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 <access_token>
```
### 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 <access_token>
```
### 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 <access_token>
X-Company-Id: <uuid-company>
```
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.

View File

@ -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
<module>-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`.

View File

@ -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.

View File

@ -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
```

View File

@ -1,8 +1,8 @@
import { createContext, useContext } from "react"; import { createContext } from "react";
import type { IDataSource } from "../../lib/data-source/datasource.interface"; import type { IDataSource } from "../../lib/data-source/datasource.interface";
const DataSourceContext = createContext<IDataSource | null>(null); export const DataSourceContext = createContext<IDataSource | null>(null);
export const DataSourceProvider = ({ export const DataSourceProvider = ({
dataSource, dataSource,
@ -11,11 +11,3 @@ export const DataSourceProvider = ({
dataSource: IDataSource; dataSource: IDataSource;
children: React.ReactNode; children: React.ReactNode;
}) => <DataSourceContext.Provider value={dataSource}>{children}</DataSourceContext.Provider>; }) => <DataSourceContext.Provider value={dataSource}>{children}</DataSourceContext.Provider>;
export const useDataSource = (): IDataSource => {
const context = useContext(DataSourceContext);
if (!context) {
throw new Error("useDataSource debe usarse dentro de <DataSourceProvider>");
}
return context;
};

View File

@ -1 +1,2 @@
export * from "./datasource-context"; export * from "./datasource-context";
export * from "./use-datasource";

View File

@ -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 <DataSourceProvider>");
}
return context;
};

View File

@ -14,7 +14,12 @@
"exports": { "exports": {
".": "./src/common/index.ts", ".": "./src/common/index.ts",
"./common": "./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": { "dependencies": {
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
@ -28,8 +33,10 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"express": "^4.22.1", "express": "^4.22.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"react-hook-form": "^7.72.1",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"react-secure-storage": "^1.3.2",
"sequelize": "^6.37.8", "sequelize": "^6.37.8",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@ -45,4 +52,4 @@
"rimraf": "^6.1.3", "rimraf": "^6.1.3",
"typescript": "^6.0.2" "typescript": "^6.0.2"
} }
} }

View File

@ -6,7 +6,7 @@ import {
type URLAddress, type URLAddress,
URLAddress as URLAddressValue, URLAddress as URLAddressValue,
UniqueID, UniqueID,
UtcDate, UtcDateTime,
ValidationErrorCollection, ValidationErrorCollection,
type ValidationErrorDetail, type ValidationErrorDetail,
extractOrPushError, extractOrPushError,
@ -17,8 +17,8 @@ import { Result } from "@repo/rdx-utils";
import { import {
Account, Account,
AccountStatus,
type AccountInternalProps, type AccountInternalProps,
AccountStatus,
PasswordHash, PasswordHash,
} from "../../../../domain"; } from "../../../../domain";
import type { AccountCreationAttributes, AccountModel } from "../models"; import type { AccountCreationAttributes, AccountModel } from "../models";
@ -32,10 +32,7 @@ export class SequelizeAccountDomainMapper extends SequelizeDomainMapper<
AccountCreationAttributes, AccountCreationAttributes,
Account Account
> { > {
public mapToDomain( public mapToDomain(source: AccountModel, _params?: MapperParamsType): Result<Account, Error> {
source: AccountModel,
_params?: MapperParamsType
): Result<Account, Error> {
try { try {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
@ -59,17 +56,18 @@ export class SequelizeAccountDomainMapper extends SequelizeDomainMapper<
); );
const status = extractOrPushError(AccountStatus.create(source.status), "status", errors); const status = extractOrPushError(AccountStatus.create(source.status), "status", errors);
const createdAt = extractOrPushError( const createdAt = extractOrPushError(
UtcDate.createFromISO(toUtcIsoString(source.created_at)), UtcDateTime.createFromISO(toUtcIsoString(source.created_at)),
"created_at", "created_at",
errors errors
); );
const updatedAt = extractOrPushError( const updatedAt = extractOrPushError(
UtcDate.createFromISO(toUtcIsoString(source.updated_at)), UtcDateTime.createFromISO(toUtcIsoString(source.updated_at)),
"updated_at", "updated_at",
errors errors
); );
if (errors.length > 0) { if (errors.length > 0) {
console.error("Account props mapping failed", errors);
return Result.fail(new ValidationErrorCollection("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(), email: source.email.toPrimitive(),
password_hash: source.passwordHash.toPrimitive(), password_hash: source.passwordHash.toPrimitive(),
name: source.name.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(), language_code: source.languageCode.toPrimitive(),
status: source.status.toPrimitive(), status: source.status.toPrimitive(),
created_at: source.createdAt.toISOString(), created_at: source.createdAt.toISOString(),

View File

@ -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: <LoginPage />,
},
];
}

View File

@ -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<AuthSession>;
logout: () => Promise<void>;
restoreSession: () => Promise<AuthSession | null>;
refreshSession: () => Promise<AuthSession | null>;
clearSession: () => void;
}
const IdentityAuthSessionContext = createContext<IdentityAuthSessionContextValue | undefined>(
undefined
);
export function IdentityAuthSessionProvider({ children }: { children: React.ReactNode }) {
const dataSource = useDataSource();
const loginMutation = useLoginMutation();
const logoutMutation = useLogoutMutation();
const refreshSessionMutation = useRefreshSessionMutation();
const [session, setSession] = useState<AuthSession | null>(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<IdentityAuthSessionContextValue>(
() => ({
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 (
<IdentityAuthSessionContext.Provider value={value}>
{children}
</IdentityAuthSessionContext.Provider>
);
}
export function useIdentityAuthSession() {
const context = useContext(IdentityAuthSessionContext);
if (!context) {
throw new Error("useIdentityAuthSession must be used within a IdentityAuthSessionProvider");
}
return context;
}

View File

@ -0,0 +1,2 @@
export * from "./identity-auth-session.context";

View File

@ -0,0 +1,2 @@
export * from "../context";
export * from "../shared/hooks";

View File

@ -0,0 +1,5 @@
export * from "./context";
export * from "./hooks";
export * from "./manifest";
export * from "./shared";

View File

@ -0,0 +1,3 @@
export * from "./use-login-controller";
export * from "./use-login-page-controller";

View File

@ -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<LoginForm>({
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,
};
}

View File

@ -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,
};
}

View File

@ -0,0 +1,2 @@
export * from "./login-form.entity";

View File

@ -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<typeof LoginFormSchema>;

View File

@ -0,0 +1,4 @@
export * from "./controllers";
export * from "./entities";
export * from "./ui";

View File

@ -0,0 +1,3 @@
export * from "./login-card";
export * from "./login-page";

View File

@ -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<LoginForm>;
onSubmit: () => void;
}
export function LoginCard({ form, onSubmit }: LoginCardProps) {
const rootError = form.formState.errors.root?.message;
return (
<Card className="border-border/70 bg-card/95 shadow-lg backdrop-blur-sm">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl font-semibold tracking-tight">Accede al ERP</CardTitle>
<CardDescription>Introduce tu email y contraseña para continuar.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form className="flex flex-col gap-5" onSubmit={onSubmit}>
<TextField<LoginForm>
autoComplete="email"
label="Email"
name="email"
placeholder="tu@email.com"
required
typePreset="email"
/>
<TextField<LoginForm>
autoComplete="current-password"
label="Contraseña"
name="password"
placeholder="••••••••"
required
typePreset="password"
/>
{rootError ? (
<p aria-live="polite" className="text-sm text-destructive" role="alert">
{rootError}
</p>
) : null}
<Button className="mt-1 w-full" disabled={form.formState.isSubmitting} type="submit">
{form.formState.isSubmitting ? (
<>
<Spinner className="size-4" />
Iniciando sesión...
</>
) : (
"Entrar"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@ -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 <Navigate replace to={redirectTo} />;
}
return (
<div className="relative overflow-hidden rounded-[2rem] border border-border/60 bg-gradient-to-br from-background via-background to-muted/50 p-2 shadow-2xl">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-8 top-0 h-40 rounded-full bg-primary/8 blur-3xl"
/>
<div className="grid gap-6 rounded-[1.6rem] bg-background/80 p-4 sm:p-6">
<div className="space-y-2 px-1 pt-1">
<p className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">
Identity
</p>
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Inicio de sesión
</h1>
<p className="max-w-sm text-sm text-muted-foreground">
Nuevo acceso unificado para el frontend ERP.
</p>
</div>
<LoginCard form={loginController.form} onSubmit={loginController.handleSubmit} />
</div>
</div>
);
}

View File

@ -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;

View File

@ -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),
};
}

View File

@ -0,0 +1,2 @@
export * from "./auth-session.adapter";

View File

@ -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<CurrentSessionResponseDTO> {
return dataSource.custom<Record<string, never>, CurrentSessionResponseDTO>({
path: "identity/auth/session",
method: "get",
headers: {
Authorization: `Bearer ${accessToken}`,
},
data: {},
});
}

View File

@ -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<LoginWithPasswordResponseDTO>;
refresh(params: RefreshSessionRequestDTO): Promise<RefreshSessionResponseDTO>;
logout(params: LogoutRequestDTO, accessToken: string): Promise<LogoutResponseDTO>;
getCurrentSession(accessToken: string): Promise<CurrentSessionResponseDTO>;
}
export function createIdentityAuthClient(dataSource: IDataSource): IdentityAuthClient {
return {
login: (params) => login(dataSource, params),
refresh: (params) => refreshSession(dataSource, params),
logout: (params, accessToken) =>
dataSource.custom<LogoutRequestDTO, LogoutResponseDTO>({
path: "identity/auth/logout",
method: "post",
headers: {
Authorization: `Bearer ${accessToken}`,
},
data: params,
}),
getCurrentSession: (accessToken) => getCurrentSession(dataSource, accessToken),
};
}

View File

@ -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";

View File

@ -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<LoginWithPasswordResponseDTO> {
return dataSource.custom<LoginWithPasswordRequestDTO, LoginWithPasswordResponseDTO>({
path: "identity/auth/login",
method: "post",
data: params,
});
}

View File

@ -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<LogoutResponseDTO> {
return dataSource.custom<LogoutRequestDTO, LogoutResponseDTO>({
path: "identity/auth/logout",
method: "post",
headers: {
Authorization: `Bearer ${params.accessToken}`,
},
data: {
refresh_token: params.refreshToken ?? undefined,
},
});
}

View File

@ -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<RefreshSessionResponseDTO> {
return dataSource.custom<RefreshSessionRequestDTO, RefreshSessionResponseDTO>({
path: "identity/auth/refresh",
method: "post",
data: params,
});
}

View File

@ -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;
}

View File

@ -0,0 +1,10 @@
export class IdentityAuthError extends Error {
status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "IdentityAuthError";
this.status = status;
}
}

View File

@ -0,0 +1,3 @@
export * from "./auth-session.entity";
export * from "./identity-auth-error.entity";

View File

@ -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";

View File

@ -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<AuthSession>({
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 ?? "",
});
},
});
}

View File

@ -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<AuthSession, Error, LoginWithPasswordRequestDTO>({
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);
},
});
}

View File

@ -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<void, Error, LogoutParams>({
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");
}
},
});
}

View File

@ -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<AuthTokens, Error, { refreshToken: string }>({
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,
};
},
});
}

View File

@ -0,0 +1,6 @@
export * from "./adapters";
export * from "./api";
export * from "./entities";
export * from "./hooks";
export * from "./storage";

View File

@ -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());
},
};

View File

@ -0,0 +1,2 @@
export * from "./auth-token-storage";

View File

@ -7,14 +7,19 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,