Identity -> pantalla de login
This commit is contained in:
parent
494c675483
commit
faf7b2251c
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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}</>;
|
||||||
|
|||||||
@ -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}</>;
|
||||||
|
|||||||
@ -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
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";
|
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;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./datasource-context";
|
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": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
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",
|
"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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user