Identity -> pantalla de login
This commit is contained in:
parent
494c675483
commit
faf7b2251c
@ -34,6 +34,7 @@
|
||||
"@erp/catalogs": "workspace:*",
|
||||
"@erp/customer-invoices": "workspace:*",
|
||||
"@erp/customers": "workspace:*",
|
||||
"@erp/identity": "workspace:*",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||
"@repo/i18next": "workspace:*",
|
||||
@ -58,4 +59,4 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { AuthProvider, createAuthService } from "@erp/auth/client";
|
||||
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
|
||||
import { DataSourceProvider } from "@erp/core/hooks";
|
||||
import { IdentityAuthSessionProvider, authTokenStorage } from "@erp/identity/client";
|
||||
import { i18n } from "@repo/i18next";
|
||||
import { LoadingOverlay } from "@repo/rdx-ui/components";
|
||||
import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components";
|
||||
@ -11,7 +11,6 @@ import { Suspense } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
|
||||
import { getAppRouter } from "./routes";
|
||||
|
||||
export const App = () => {
|
||||
@ -30,11 +29,13 @@ export const App = () => {
|
||||
|
||||
const axiosInstance = createAxiosInstance({
|
||||
baseURL: import.meta.env.VITE_API_SERVER_URL,
|
||||
getAccessToken: () => null, //getAccessToken,
|
||||
getAccessToken: () => authTokenStorage.getAccessToken(),
|
||||
onAuthError: () => {
|
||||
//console.error("APP, Error de autenticación");
|
||||
//clearAccessToken();
|
||||
//window.location.href = "/login"; // o usar navegación programática
|
||||
// TODO(identity-auth): siguiente incremento
|
||||
// 1. detectar 401 recuperable
|
||||
// 2. llamar refresh()
|
||||
// 3. reintentar la request original
|
||||
// 4. limpiar sesión si refresh falla
|
||||
},
|
||||
});
|
||||
|
||||
@ -45,14 +46,7 @@ export const App = () => {
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DataSourceProvider dataSource={dataSource}>
|
||||
<AuthProvider
|
||||
params={{
|
||||
getAccessToken,
|
||||
setAccessToken,
|
||||
clearAccessToken,
|
||||
authService: createAuthService(),
|
||||
}}
|
||||
>
|
||||
<IdentityAuthSessionProvider>
|
||||
<TooltipProvider>
|
||||
{/* Fallback Route */}
|
||||
<Suspense fallback={<LoadingOverlay />}>
|
||||
@ -61,7 +55,7 @@ export const App = () => {
|
||||
</TooltipProvider>
|
||||
<Toaster expand={true} position="top-center" richColors />
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</AuthProvider>
|
||||
</IdentityAuthSessionProvider>
|
||||
</DataSourceProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useIdentityAuthSession } from "@erp/identity/client";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@ -12,8 +13,26 @@ import {
|
||||
SidebarTrigger,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { BellIcon, ChevronDownIcon, MessageSquareIcon, SearchIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const AppTopbar = () => {
|
||||
const navigate = useNavigate();
|
||||
const { account, logout } = useIdentityAuthSession();
|
||||
|
||||
const initials = account?.name
|
||||
? account.name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((chunk: string) => chunk[0]?.toUpperCase() ?? "")
|
||||
.join("")
|
||||
: "??";
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -78,14 +97,14 @@ export const AppTopbar = () => {
|
||||
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">
|
||||
AM
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden min-w-0 text-left md:block">
|
||||
<span className="block truncate text-sm font-semibold leading-5">
|
||||
Ana Martínez
|
||||
{account?.name ?? "Sesión activa"}
|
||||
</span>
|
||||
<span className="block truncate text-xs font-normal text-muted-foreground">
|
||||
Administrador
|
||||
{account?.email ?? "Sin perfil cargado"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="hidden size-4 text-muted-foreground md:block" />
|
||||
@ -99,7 +118,9 @@ export const AppTopbar = () => {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Perfil</DropdownMenuItem>
|
||||
<DropdownMenuItem>Preferencias</DropdownMenuItem>
|
||||
<DropdownMenuItem>Cerrar sesión</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void handleLogout()}>
|
||||
Cerrar sesión
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -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: <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 => {
|
||||
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();
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -43,14 +43,21 @@ export const getAppRouter = () => {
|
||||
},
|
||||
|
||||
{
|
||||
path: "auth",
|
||||
element: <AuthLayout />,
|
||||
children: authRoutes,
|
||||
},
|
||||
|
||||
{
|
||||
path: "auth",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate replace to="login" />,
|
||||
element: <Navigate replace to="/login" />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <Navigate replace to="/login" />,
|
||||
},
|
||||
...authRoutes,
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@ -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 <LoadingOverlay />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate replace to="/proformas" />;
|
||||
return <Navigate replace to={returnTo?.startsWith("/") ? returnTo : "/proformas"} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@ -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 <LoadingOverlay />;
|
||||
}
|
||||
|
||||
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}</>;
|
||||
|
||||
@ -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: {
|
||||
|
||||
150
docs/architecture/README.md
Normal file
150
docs/architecture/README.md
Normal 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)
|
||||
143
docs/architecture/identity-auth-api.md
Normal file
143
docs/architecture/identity-auth-api.md
Normal 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.
|
||||
221
docs/architecture/identity-backend-architecture.md
Normal file
221
docs/architecture/identity-backend-architecture.md
Normal 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`.
|
||||
163
docs/architecture/identity-migration-status.md
Normal file
163
docs/architecture/identity-migration-status.md
Normal 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.
|
||||
217
docs/architecture/identity-roadmap.md
Normal file
217
docs/architecture/identity-roadmap.md
Normal 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
|
||||
```
|
||||
@ -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<IDataSource | null>(null);
|
||||
export const DataSourceContext = createContext<IDataSource | null>(null);
|
||||
|
||||
export const DataSourceProvider = ({
|
||||
dataSource,
|
||||
@ -11,11 +11,3 @@ export const DataSourceProvider = ({
|
||||
dataSource: IDataSource;
|
||||
children: React.ReactNode;
|
||||
}) => <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;
|
||||
};
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./datasource-context";
|
||||
export * from "./use-datasource";
|
||||
|
||||
13
modules/core/src/web/hooks/use-datasource/use-datasource.tsx
Normal file
13
modules/core/src/web/hooks/use-datasource/use-datasource.tsx
Normal 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;
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Account, Error> {
|
||||
public mapToDomain(source: AccountModel, _params?: MapperParamsType): Result<Account, Error> {
|
||||
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(),
|
||||
|
||||
18
modules/identity/src/web/auth-routes.tsx
Normal file
18
modules/identity/src/web/auth-routes.tsx
Normal 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 />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
2
modules/identity/src/web/context/index.ts
Normal file
2
modules/identity/src/web/context/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./identity-auth-session.context";
|
||||
|
||||
2
modules/identity/src/web/hooks/index.ts
Normal file
2
modules/identity/src/web/hooks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "../context";
|
||||
export * from "../shared/hooks";
|
||||
5
modules/identity/src/web/index.ts
Normal file
5
modules/identity/src/web/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./context";
|
||||
export * from "./hooks";
|
||||
export * from "./manifest";
|
||||
export * from "./shared";
|
||||
|
||||
3
modules/identity/src/web/login/controllers/index.ts
Normal file
3
modules/identity/src/web/login/controllers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./use-login-controller";
|
||||
export * from "./use-login-page-controller";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
2
modules/identity/src/web/login/entities/index.ts
Normal file
2
modules/identity/src/web/login/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./login-form.entity";
|
||||
|
||||
10
modules/identity/src/web/login/entities/login-form.entity.ts
Normal file
10
modules/identity/src/web/login/entities/login-form.entity.ts
Normal 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>;
|
||||
|
||||
4
modules/identity/src/web/login/index.ts
Normal file
4
modules/identity/src/web/login/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./controllers";
|
||||
export * from "./entities";
|
||||
export * from "./ui";
|
||||
|
||||
3
modules/identity/src/web/login/ui/index.ts
Normal file
3
modules/identity/src/web/login/ui/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./login-card";
|
||||
export * from "./login-page";
|
||||
|
||||
73
modules/identity/src/web/login/ui/login-card.tsx
Normal file
73
modules/identity/src/web/login/ui/login-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
modules/identity/src/web/login/ui/login-page.tsx
Normal file
38
modules/identity/src/web/login/ui/login-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
modules/identity/src/web/manifest.ts
Normal file
17
modules/identity/src/web/manifest.ts
Normal 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;
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
2
modules/identity/src/web/shared/adapters/index.ts
Normal file
2
modules/identity/src/web/shared/adapters/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./auth-session.adapter";
|
||||
|
||||
@ -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: {},
|
||||
});
|
||||
}
|
||||
|
||||
38
modules/identity/src/web/shared/api/identity-auth-client.ts
Normal file
38
modules/identity/src/web/shared/api/identity-auth-client.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
6
modules/identity/src/web/shared/api/index.ts
Normal file
6
modules/identity/src/web/shared/api/index.ts
Normal 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";
|
||||
|
||||
14
modules/identity/src/web/shared/api/login.api.ts
Normal file
14
modules/identity/src/web/shared/api/login.api.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
24
modules/identity/src/web/shared/api/logout.api.ts
Normal file
24
modules/identity/src/web/shared/api/logout.api.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
14
modules/identity/src/web/shared/api/refresh-session.api.ts
Normal file
14
modules/identity/src/web/shared/api/refresh-session.api.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
export class IdentityAuthError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "IdentityAuthError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
3
modules/identity/src/web/shared/entities/index.ts
Normal file
3
modules/identity/src/web/shared/entities/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./auth-session.entity";
|
||||
export * from "./identity-auth-error.entity";
|
||||
|
||||
5
modules/identity/src/web/shared/hooks/index.ts
Normal file
5
modules/identity/src/web/shared/hooks/index.ts
Normal 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";
|
||||
|
||||
@ -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 ?? "",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
32
modules/identity/src/web/shared/hooks/use-login-mutation.ts
Normal file
32
modules/identity/src/web/shared/hooks/use-login-mutation.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
22
modules/identity/src/web/shared/hooks/use-logout-mutation.ts
Normal file
22
modules/identity/src/web/shared/hooks/use-logout-mutation.ts
Normal 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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
6
modules/identity/src/web/shared/index.ts
Normal file
6
modules/identity/src/web/shared/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./adapters";
|
||||
export * from "./api";
|
||||
export * from "./entities";
|
||||
export * from "./hooks";
|
||||
export * from "./storage";
|
||||
|
||||
@ -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());
|
||||
},
|
||||
};
|
||||
|
||||
2
modules/identity/src/web/shared/storage/index.ts
Normal file
2
modules/identity/src/web/shared/storage/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./auth-token-storage";
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user