This commit is contained in:
David Arranz 2025-05-29 13:15:28 +02:00
parent 2c89ebb002
commit 0fdf18baf3
35 changed files with 1866 additions and 2008 deletions

View File

@ -41,7 +41,7 @@
"i18next-browser-languagedetector": "^8.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"react-hook-form": "^7.56.2",
"react-hook-form-persist": "^3.0.0",
"react-i18next": "^15.0.1",
"react-router-dom": "^6.26.0",

View File

@ -6,15 +6,12 @@ import { I18nextProvider } from "react-i18next";
import { UnsavedWarnProvider } from "@/lib/hooks";
import { i18n } from "@/locales";
import { clearAccessToken, getAccessToken } from "@erp/auth/client";
import { AuthProvider, createAuthService } from "@erp/auth/client";
import { DataSourceProvider, createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import "./app.css";
import { AppRoutes } from "./routes/app-routes";
/**
* Clave utilizada en el almacenamiento local para el token JWT.
*/
const TOKEN_STORAGE_KEY = "factuges.auth";
import "./app.css";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
import { AppRoutes } from "./routes";
export const App = () => {
const queryClient = new QueryClient({
@ -26,28 +23,39 @@ export const App = () => {
},
});
const dataSource = createAxiosDataSource(
createAxiosInstance({
baseURL: import.meta.env.VITE_API_URL,
getAccessToken: () => getAccessToken(TOKEN_STORAGE_KEY),
onAuthError: () => {
clearAccessToken(TOKEN_STORAGE_KEY);
window.location.href = "/login"; // o usar navegación programática
},
})
);
const axiosInstance = createAxiosInstance({
baseURL: import.meta.env.VITE_API_URL,
getAccessToken,
onAuthError: () => {
clearAccessToken();
window.location.href = "/login"; // o usar navegación programática
},
});
console.log(axiosInstance.defaults.env);
const dataSource = createAxiosDataSource(axiosInstance);
return (
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<DataSourceProvider dataSource={dataSource}>
<TooltipProvider delayDuration={0}>
<UnsavedWarnProvider>
<AppRoutes />
</UnsavedWarnProvider>
</TooltipProvider>
<Toaster />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
<AuthProvider
params={{
getAccessToken,
setAccessToken,
clearAccessToken,
authService: createAuthService(dataSource),
}}
>
<TooltipProvider delayDuration={0}>
<UnsavedWarnProvider>
<AppRoutes />
</UnsavedWarnProvider>
</TooltipProvider>
<Toaster />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AuthProvider>
</DataSourceProvider>
</QueryClientProvider>
</I18nextProvider>

View File

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

40
apps/web/src/lib/token.ts Normal file
View File

@ -0,0 +1,40 @@
import secureLocalStorage from "react-secure-storage";
/**
* 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.
*/
/**
* 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);
};
setAccessToken;
/**
* Limpia el token JWT del almacenamiento local.
*/
export const clearAccessToken = (): void => {
secureLocalStorage.removeItem(TOKEN_STORAGE_KEY);
};

View File

@ -28,6 +28,7 @@
"useOptionalChain": "info"
},
"suspicious": {
"noImplicitAnyLet": "info",
"noExplicitAny": "info"
},
"style": {

View File

@ -14,12 +14,14 @@
"typescript": "^5.8.3"
},
"dependencies": {
"@repo/shadcn-ui": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@erp/core": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"i18next": "^25.1.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.2",
"react-router-dom": "^6.26.0",
"react-secure-storage": "^1.3.2"
}

View File

@ -1,16 +1,27 @@
import { ModuleClientParams } from "@erp/core/client";
import { RouteObject } from "react-router-dom";
import { Outlet, RouteObject } from "react-router-dom";
import { AuthLayout } from "./components";
import { LoginPage } from "./pages";
export const AuthRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
{
path: "login",
element: <LoginPage />,
},
{
path: "register",
element: <div>Register</div>, // o tu componente real
path: "*",
element: (
<AuthLayout>
<Outlet context={params} />
</AuthLayout>
),
children: [
{
path: "login",
element: <LoginPage />,
},
{
path: "register",
element: <div>Register</div>,
},
],
},
];
};

View File

@ -0,0 +1,5 @@
import { PropsWithChildren } from "react";
export const AuthLayout = ({ children }: PropsWithChildren) => {
return <>{children}</>;
};

View File

@ -1 +1,3 @@
export * from "./auth-guard";
export * from "./auth-layout";
export * from "./login-form";

View File

@ -0,0 +1,85 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form";
type LoginFormValues = {
email: string;
password: string;
};
interface LoginFormProps extends React.ComponentPropsWithoutRef<"div"> {
onSubmit: SubmitHandler<LoginFormValues>;
onInvalid?: SubmitErrorHandler<LoginFormValues>;
}
export function LoginForm({ onSubmit, onInvalid, className, ...props }: LoginFormProps) {
const form = useForm<LoginFormValues>({
mode: "onBlur",
defaultValues: {
email: "",
password: "",
},
});
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle className='text-2xl'>Login</CardTitle>
<CardDescription>Enter your email below to login to your account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit, onInvalid)} className='flex flex-col gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type='email' autoComplete='username' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type='password' autoComplete='current-password' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' className='w-full'>
Login
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,18 +1,28 @@
import { createContext, useContext, useEffect, useState } from "react";
import { PropsWithChildren, createContext, useEffect, useState } from "react";
import { IAuthService } from "../services";
interface AuthContextType {
export interface AuthContextType {
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export interface AuthContextParams {
authService: IAuthService;
getAccessToken: () => string | null;
setAccessToken: (token: string) => void;
clearAccessToken: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
* Proveedor de autenticación para toda la app.
*/
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
export const AuthProvider = ({ params, children }: PropsWithChildren<{ params: any }>) => {
const { getAccessToken, setAccessToken, clearAccessToken, authService } = params;
const [token, setToken] = useState<string | null>(getAccessToken());
useEffect(() => {
@ -21,7 +31,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const login = async (email: string, password: string) => {
const { access_token } = await authService.login({ email, password });
localStorage.setItem("access_token", access_token);
setAccessToken(access_token);
setToken(access_token);
};
@ -36,14 +46,3 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
</AuthContext.Provider>
);
};
/**
* Hook para acceder al contexto de autenticación.
*/
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth debe usarse dentro de <AuthProvider>");
}
return context;
};

View File

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

View File

@ -1,43 +0,0 @@
import { IGetProfileResponse_DTO } from "@shared/contexts";
export type SuccessNotificationResponse = {
message: string;
description?: string;
};
export type PermissionResponse = unknown;
export type ProfileResponse = IGetProfileResponse_DTO | null;
export type AuthActionCheckResponse = {
authenticated: boolean;
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type AuthActionOnErrorResponse = {
redirectTo?: string;
logout?: boolean;
error?: Error;
};
export type AuthActionResponse = {
success: boolean;
redirectTo?: string;
error?: Error;
[key: string]: unknown;
successNotification?: SuccessNotificationResponse;
};
export interface IAuthActions {
login: (params: any) => Promise<AuthActionResponse>;
logout: (params: any) => Promise<AuthActionResponse>;
check: () => Promise<AuthActionCheckResponse>;
onError?: (error: any) => Promise<AuthActionOnErrorResponse>;
register?: (params: unknown) => Promise<AuthActionResponse>;
forgotPassword?: (params: unknown) => Promise<AuthActionResponse>;
updatePassword?: (params: unknown) => Promise<AuthActionResponse>;
getPermissions?: (params?: Record<string, unknown>) => Promise<PermissionResponse>;
getProfile?: (params?: unknown) => Promise<ProfileResponse>;
}

View File

@ -1,51 +0,0 @@
import { PropsWithChildren, createContext } from "react";
import { IAuthActions } from "./auth-actions";
export interface IAuthContextState extends Partial<IAuthActions> {}
export const AuthContext = createContext<Partial<IAuthContextState>>({});
export const AuthProvider = ({
children,
authActions,
}: PropsWithChildren<{ authActions: Partial<IAuthActions> }>) => {
const handleLogin = (params: unknown) => {
try {
return Promise.resolve(authActions.login?.(params));
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
const handleLogout = (params: unknown) => {
try {
return Promise.resolve(authActions.logout?.(params));
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
const handleCheck = async () => {
try {
return Promise.resolve(authActions.check?.());
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
return (
<AuthContext.Provider
value={{
...authActions,
login: handleLogin as IAuthActions["login"],
logout: handleLogout as IAuthActions["logout"],
check: handleCheck as IAuthActions["check"],
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@ -1,6 +0,0 @@
export * from "./auth-actions";
export * from "./auth-context";
export * from "./use-auth";
export * from "./use-get-profile";
export * from "./use-is-logged-in";
export * from "./use-login";

View File

@ -1,8 +0,0 @@
import { useContext } from "react";
import { AuthContext } from "./auth-context";
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === null) throw new Error("useAuth must be used within a AuthProvider");
return context;
};

View File

@ -1,16 +0,0 @@
import { ProfileResponse } from "./auth-actions";
export const useGetProfile = (
queryOptions?: Omit<UseQueryOptions<ProfileResponse>, "queryKey" | "queryFn">
) => {
const keys = useQueryKey();
const { getProfile } = useAuth();
const result = useQuery<ProfileResponse>({
queryKey: keys().auth().action("profile").get(),
queryFn: getProfile,
...queryOptions,
});
return result;
};

View File

@ -1,16 +0,0 @@
import { AuthActionCheckResponse } from "./auth-actions";
import { useAuth } from "./use-auth";
export const useIsLoggedIn = (queryOptions?: UseQueryOptions<AuthActionCheckResponse>) => {
const keys = useQueryKey();
const { check } = useAuth();
const result = useQuery<AuthActionCheckResponse>({
queryKey: keys().auth().action("check").get(),
queryFn: check,
retry: false,
...queryOptions,
});
return result;
};

View File

@ -1,16 +0,0 @@
import { AuthActionResponse, useAuth } from "@/lib/hooks";
import { ILogin_DTO } from "@shared/contexts";
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
import { useQueryKey } from "../useQueryKey";
export const useLogin = (params?: UseMutationOptions<AuthActionResponse, Error, ILogin_DTO>) => {
const keys = useQueryKey();
const { login } = useAuth();
return useMutation({
mutationKey: keys().auth().action("login").get(),
mutationFn: login,
...params,
});
};

View File

@ -1,45 +0,0 @@
import { AuthActionResponse, useAuth } from "@/lib/hooks";
import { UseMutationOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/ui/use-toast";
import { useQueryKey } from "../useQueryKey";
export const useLogout = (params?: UseMutationOptions<AuthActionResponse, Error>) => {
const { onSuccess, onError, ...restParams } = params || {};
const queryClient = useQueryClient();
const keys = useQueryKey();
const { logout } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
return useMutation({
mutationKey: keys().auth().action("logout").get(),
mutationFn: logout,
onSuccess: async (data, variables, context) => {
queryClient.clear();
const { success, redirectTo } = data;
if (success && redirectTo) {
navigate(redirectTo || "/");
}
if (onSuccess) {
onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
const { message } = error;
toast({
title: "Error",
description: message,
variant: "destructive",
});
if (onError) {
onError(error, variables, context);
}
},
...restParams,
});
};

View File

@ -1,3 +1,2 @@
export * from "./useAuth";
export * from "./useCurrentUser";
export * from "./useIsAuthenticated";
export * from "./use-auth";
export * from "./use-is-authenticated";

View File

@ -0,0 +1,13 @@
import { useContext } from "react";
import { AuthContext, AuthContextType } from "../context";
/**
* Hook para acceder al contexto de autenticación.
*/
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth debe usarse dentro de <AuthProvider>");
}
return context;
};

View File

@ -1,4 +1,4 @@
import { useAuth } from "./useAuth";
import { useAuth } from "./use-auth";
/**
* Devuelve un booleano reactivo si el usuario está autenticado.

View File

@ -1,2 +1,5 @@
export * from "./context";
export * from "./hooks";
export * from "./lib";
export * from "./manifest";
export * from "./services";

View File

@ -1,7 +1,20 @@
import { LoginForm } from "../components";
import { useAuth } from "../hooks";
export const LoginPage = () => {
const { login } = useAuth();
const handleOnSubmit = (data) => {
console.log(data);
const { email, password } = data;
login(email, password);
};
return (
<div>
<h2>Iniciar Sesión</h2>
<div className='flex min-h-svh w-full items-center justify-center p-6 md:p-10'>
<div className='w-full max-w-sm'>
<LoginForm onSubmit={handleOnSubmit} />
</div>
</div>
);
};

View File

@ -1,17 +1,20 @@
export const authService = createAuthActions(axiosClient);
import { IDataSource } from "@erp/core/client";
import { ILoginRequestDTO, ILoginResponseDTO } from "../../common";
/**
* Autentica al usuario con email y password, y guarda el token en localStorage.
*/
export const login = async (email: string, password: string): Promise<void> => {
const { access_token } = await authService.login({ email, password });
setAccessToken(access_token);
};
export interface IAuthService {
login: (credentials: ILoginRequestDTO) => Promise<ILoginResponseDTO>;
}
/**
* Limpia el token local y ejecuta el logout remoto (si aplica).
*/
export const logout = async (): Promise<void> => {
await authService.logout(); // opcional: puede no existir en backend
clearAccessToken();
export const createAuthService = (dataSource: IDataSource): IAuthService => {
return {
login: async (credentials: ILoginRequestDTO) => {
const data = await dataSource.custom<ILoginResponseDTO>({
path: "login",
method: "post",
data: credentials,
});
return data;
},
};
};

View File

@ -0,0 +1 @@
export * from "./auth-service";

View File

@ -0,0 +1,16 @@
import { useDataSource } from "@erp/core/client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../hooks";
import { User } from "./types";
export const useCurrentUser = () => {
const { token } = useAuth();
const client = useDataSource();
return useQuery({
queryKey: ["me"],
queryFn: () => client.getOne<User>("/auth", "me"),
enabled: !!token,
staleTime: 1000 * 60 * 5,
});
};

View File

@ -0,0 +1,6 @@
export interface User {
id: number;
name: string;
email: string;
role: string;
}

View File

@ -0,0 +1,22 @@
import { useDataSource } from "@erp/core/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "./types";
export const useUsers = () => {
const client = useDataSource();
return useQuery({
queryKey: ["users"],
queryFn: () => client.getList<User>("/users"),
});
};
export const useCreateUser = () => {
const client = useDataSource();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (user: Partial<User>) => client.createOne<User>("/users", user),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
});
};

View File

@ -1,8 +1,21 @@
import { AxiosInstance } from "axios";
import { IDataSource } from "../datasource.interface";
import { ICustomParams, IDataSource } from "../datasource.interface";
import { defaultAxiosRequestConfig } from "./create-axios-instance";
export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
function getBaseUrlOrFail(): string {
const baseUrl = client.getUri();
if (!baseUrl) {
throw new Error("[Axios] baseURL no está definido en esta instancia.");
}
return baseUrl;
}
return {
getBaseUrl: getBaseUrlOrFail,
getList: async <T>(resource: string, params?: Record<string, any>) => {
const res = await client.get<T[]>(resource, { params });
return res.data;
@ -31,5 +44,54 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
deleteOne: async <T>(resource: string, id: string | number) => {
await client.delete(`${resource}/${id}`);
},
custom: async <T>(customParams: ICustomParams) => {
const { url, path, method, responseType, headers, signal, data, ...payload } = customParams;
const requestUrl = path ? `${getBaseUrlOrFail()}/${path}` : url;
if (!requestUrl) throw new Error('"url" or "path" param is missing');
const config = {
url: requestUrl,
method,
responseType,
signal,
...payload,
...defaultAxiosRequestConfig,
headers,
};
let customResponse;
switch (method) {
case "put":
case "post":
case "patch":
customResponse = await client.request<T>({
...config,
data,
});
break;
case "delete":
customResponse = await client.delete<T>(requestUrl, {
responseType,
headers,
...payload,
});
break;
default:
customResponse = await client.get<T>(requestUrl, {
responseType,
signal,
headers,
...payload,
});
break;
}
return customResponse.data;
},
};
};

View File

@ -20,6 +20,15 @@ export interface AxiosFactoryConfig {
onAuthError?: () => void;
}
export const defaultAxiosRequestConfig = {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*", // Could work and fix the previous problem, but not in all APIs
},
};
/**
* Crea una instancia de Axios preconfigurada con interceptores.
*
@ -33,12 +42,7 @@ export const createAxiosInstance = ({
}: AxiosFactoryConfig): AxiosInstance => {
const instance = axios.create({
baseURL,
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-cache",
"Access-Control-Allow-Origin": "*", // Could work and fix the previous problem, but not in all APIs
},
...defaultAxiosRequestConfig,
});
setupInterceptors(instance, getAccessToken, onAuthError);

View File

@ -1,8 +1,25 @@
import { ResponseType } from "axios";
export interface ICustomParams {
url?: string;
path?: string;
method: "get" | "delete" | "head" | "options" | "post" | "put" | "patch";
signal?: AbortSignal;
responseType?: ResponseType;
headers?: {
[key: string]: any;
};
[key: string]: unknown;
}
export interface IDataSource {
getBaseUrl(): string;
getList<T>(resource: string, params?: Record<string, any>): Promise<T[]>;
getOne<T>(resource: string, id: string | number): Promise<T>;
getMany<T>(resource: string, ids: Array<string | number>): Promise<T[]>;
createOne<T>(resource: string, data: Partial<T>): Promise<T>;
updateOne<T>(resource: string, id: string | number, data: Partial<T>): Promise<T>;
deleteOne<T>(resource: string, id: string | number): Promise<void>;
custom: <R>(customParams: ICustomParams) => Promise<R>;
}

File diff suppressed because it is too large Load Diff