From fcb2fa87873d490b767367f5c51f388c154e48c0 Mon Sep 17 00:00:00 2001 From: Jose Date: Sun, 15 Feb 2026 02:58:06 +0100 Subject: [PATCH] Updated template with better features --- src/context/AuthContext.jsx | 105 ++++++++++++++++--- src/context/DataContext.jsx | 4 +- src/context/ErrorContext.jsx | 40 ++++++++ src/hooks/useAuth.js | 2 +- src/hooks/useConfig.js | 2 +- src/hooks/useData.js | 172 +++++++++++++++++--------------- src/hooks/useDataContext.js | 2 +- src/hooks/useRequestCount.js | 35 +++++++ src/hooks/useSessionRenewal.jsx | 6 +- src/util/alertHelpers.jsx | 15 --- src/util/array.js | 5 + src/util/parsers/dateParser.js | 30 ------ src/util/parsers/errorParser.js | 10 -- src/util/passwordGenerator.js | 29 ------ src/util/tokenUtils.js | 7 -- 15 files changed, 268 insertions(+), 196 deletions(-) create mode 100644 src/context/ErrorContext.jsx create mode 100644 src/hooks/useRequestCount.js delete mode 100644 src/util/alertHelpers.jsx create mode 100644 src/util/array.js delete mode 100644 src/util/parsers/dateParser.js delete mode 100644 src/util/parsers/errorParser.js delete mode 100644 src/util/passwordGenerator.js delete mode 100644 src/util/tokenUtils.js diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 8f2b2a4..b732d67 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -8,8 +8,12 @@ export const AuthProvider = ({ children }) => { const axios = createAxiosInstance(); const { config } = useConfig(); - const [user, setUser] = useState(() => JSON.parse(localStorage.getItem("user")) || null); const [token, setToken] = useState(() => localStorage.getItem("token")); + const [identity, setIdentity] = useState(() => { + const stored = localStorage.getItem("identity"); + return stored ? JSON.parse(stored) : null; + }); + const [authStatus, setAuthStatus] = useState("checking"); const [error, setError] = useState(null); @@ -29,6 +33,7 @@ export const AuthProvider = ({ children }) => { const res = await axios.get(VALIDATE_URL, { headers: { Authorization: `Bearer ${token}` }, }); + if (res.status === 200) { setAuthStatus("authenticated"); } else { @@ -45,53 +50,121 @@ export const AuthProvider = ({ children }) => { const login = async (formData) => { setError(null); + const BASE_URL = config.apiConfig.baseUrl; const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`; - + try { const res = await axios.post(LOGIN_URL, formData); - const { token, member, tokenTime } = res.data.data; - + + const { token, user, account, metadata } = res.data; + + const identity = { + user, + account, + metadata, + }; + localStorage.setItem("token", token); - localStorage.setItem("user", JSON.stringify(member)); - localStorage.setItem("tokenTime", tokenTime); - + localStorage.setItem("identity", JSON.stringify(identity)); + setToken(token); - setUser(member); + setIdentity(identity); setAuthStatus("authenticated"); } catch (err) { console.error("Error al iniciar sesión:", err); - + let message = "Ha ocurrido un error inesperado."; - + if (err.response) { const { status, data } = err.response; - + if (status === 400) { message = "Usuario o contraseña incorrectos."; } else if (status === 403) { - message = "Tu cuenta está inactiva o ha sido suspendida."; + message = "Tu cuenta está inactiva o suspendida."; } else if (status === 404) { message = "Usuario no encontrado."; } else if (data?.message) { message = data.message; } } - + + setError(message); + throw new Error(message); + } + }; + + const register = async (formData) => { + setError(null); + + const BASE_URL = config.apiConfig.baseUrl; + const REGISTER_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.register}`; + + try { + const res = await axios.post(REGISTER_URL, formData); + + const { token, user, account, metadata } = res.data; + + const identity = { + user, + account, + metadata, + }; + + localStorage.setItem("token", token); + localStorage.setItem("identity", JSON.stringify(identity)); + + setToken(token); + setIdentity(identity); + setAuthStatus("authenticated"); + } catch (err) { + console.error("Error al registrarse:", err); + + let message = "Ha ocurrido un error inesperado."; + + if (err.response) { + const { status, data } = err.response; + + if (status === 400) { + message = "Usuario o contraseña incorrectos."; + } else if (status === 403) { + message = "Tu cuenta está inactiva o suspendida."; + } else if (status === 404) { + message = "Usuario no encontrado."; + } else if (data?.message) { + message = data.message; + } + } + setError(message); throw new Error(message); } }; const logout = () => { - localStorage.clear(); - setUser(null); + localStorage.removeItem("token"); + localStorage.removeItem("identity"); + setIdentity(null); setToken(null); setAuthStatus("unauthenticated"); }; + const clearError = () => setError(null); + return ( - + {children} ); diff --git a/src/context/DataContext.jsx b/src/context/DataContext.jsx index a99a74e..7887f90 100644 --- a/src/context/DataContext.jsx +++ b/src/context/DataContext.jsx @@ -4,8 +4,8 @@ import { useData } from "../hooks/useData"; export const DataContext = createContext(); -export const DataProvider = ({ config, children }) => { - const data = useData(config); +export const DataProvider = ({ config, onError, children }) => { + const data = useData(config, onError); return ( diff --git a/src/context/ErrorContext.jsx b/src/context/ErrorContext.jsx new file mode 100644 index 0000000..1ca0cb9 --- /dev/null +++ b/src/context/ErrorContext.jsx @@ -0,0 +1,40 @@ +import { createContext, useState, useContext } from 'react'; +import PropTypes from 'prop-types'; +import NotificationModal from '@/components/NotificationModal'; + +const ErrorContext = createContext(); + +export const ErrorProvider = ({ children }) => { + const [error, setError] = useState(null); + + const showError = (err) => { + setError({ + title: err.status ? `Error ${err.status}` : "Error", + message: err.message, + variant: 'danger' + }); + }; + + const closeError = () => setError(null); + + return ( + + {children} + {error && ( + + )} + + ); +}; +ErrorProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export const useError = () => useContext(ErrorContext); diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js index ad9a9f7..a6e5c3a 100644 --- a/src/hooks/useAuth.js +++ b/src/hooks/useAuth.js @@ -1,4 +1,4 @@ import { useContext } from "react"; -import { AuthContext } from "@/context/AuthContext"; +import { AuthContext } from "../context/AuthContext"; export const useAuth = () => useContext(AuthContext); diff --git a/src/hooks/useConfig.js b/src/hooks/useConfig.js index 024ee84..a895e6b 100644 --- a/src/hooks/useConfig.js +++ b/src/hooks/useConfig.js @@ -1,4 +1,4 @@ import { useContext } from "react"; -import { ConfigContext } from "@/context/ConfigContext.jsx"; +import { ConfigContext } from "../context/ConfigContext.jsx"; export const useConfig = () => useContext(ConfigContext); diff --git a/src/hooks/useData.js b/src/hooks/useData.js index a1c5dd5..9ad7365 100644 --- a/src/hooks/useData.js +++ b/src/hooks/useData.js @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import axios from "axios"; -export const useData = (config) => { +export const useData = (config, onError) => { const [data, setData] = useState(null); const [dataLoading, setLoading] = useState(true); const [dataError, setError] = useState(null); @@ -13,10 +13,59 @@ export const useData = (config) => { } }, [config]); - const getAuthHeaders = () => ({ - "Content-Type": "application/json", - "Authorization": `Bearer ${localStorage.getItem("token")}`, - }); + const getAuthHeaders = (isFormData = false) => { + const token = localStorage.getItem("token"); + + const headers = {}; + if (token) headers.Authorization = `Bearer ${token}`; + + if (!isFormData) { + headers["Content-Type"] = "application/json"; + } + + return headers; + }; + + const handleAxiosError = (err) => { + if (err.response && err.response.data) { + const data = err.response.data; + + if (data.status === 422 && data.errors) { + return { + status: 422, + errors: data.errors, + path: data.path ?? null, + timestamp: data.timestamp ?? null, + }; + } + + return { + status: data.status ?? err.response.status, + error: data.error ?? null, + message: data.message ?? err.response.statusText ?? "Error desconocido", + path: data.path ?? null, + timestamp: data.timestamp ?? null, + }; + } + + if (err.request) { + return { + status: null, + error: "Network Error", + message: "No se pudo conectar al servidor", + path: null, + timestamp: new Date().toISOString(), + }; + } + + return { + status: null, + error: "Client Error", + message: err.message || "Error desconocido", + path: null, + timestamp: new Date().toISOString(), + }; + }; const fetchData = useCallback(async () => { const current = configRef.current; @@ -30,101 +79,62 @@ export const useData = (config) => { headers: getAuthHeaders(), params: current.params, }); - setData(response.data.data); + setData(response.data); } catch (err) { - setError(err.response?.data?.message || err.message); + const error = handleAxiosError(err); + setError(error); } finally { setLoading(false); } }, []); useEffect(() => { - if (config?.baseUrl) { - fetchData(); - } + if (config?.baseUrl) fetchData(); }, [config, fetchData]); - const getData = async (url, params = {}) => { + const requestWrapper = async (method, endpoint, payload = null, refresh = false) => { try { - const response = await axios.get(url, { - headers: getAuthHeaders(), - params, - }); - return { data: response.data.data, error: null }; - } catch (err) { - return { - data: null, - error: err.response?.data?.message || err.message, - }; - } - }; + const isFormData = payload instanceof FormData; + const headers = getAuthHeaders(isFormData); + const cfg = { headers }; + let response; - const postData = async (endpoint, payload) => { - const headers = { - Authorization: `Bearer ${localStorage.getItem("token")}`, - ...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }), - }; - const response = await axios.post(endpoint, payload, { headers }); - await fetchData(); - return response.data.data; - }; - - const postDataValidated = async (endpoint, payload) => { - try { - const headers = { - Authorization: `Bearer ${localStorage.getItem("token")}`, - ...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }), - }; - const response = await axios.post(endpoint, payload, { headers }); - return { data: response.data.data, errors: null }; - } catch (err) { - const raw = err.response?.data?.message; - let parsed = {}; - - try { - parsed = JSON.parse(raw); - } catch { - return { data: null, errors: { general: raw || err.message } }; + if (method === "get") { + if (payload) cfg.params = payload; + response = await axios.get(endpoint, cfg); + } else if (method === "delete") { + if (payload) cfg.data = payload; + response = await axios.delete(endpoint, cfg); + } else { + response = await axios[method](endpoint, payload, cfg); } - - return { data: null, errors: parsed }; + + if (refresh) await fetchData(); + return response.data; + + } catch (err) { + const error = handleAxiosError(err); + + if (error.status !== 422 && onError) { + onError(error); + } + + setError(error); + throw error; } - }; - - const putData = async (endpoint, payload) => { - const response = await axios.put(endpoint, payload, { - headers: getAuthHeaders(), - }); - await fetchData(); - return response.data.data; }; - const deleteData = async (endpoint) => { - const response = await axios.delete(endpoint, { - headers: getAuthHeaders(), - }); - await fetchData(); - return response.data.data; - }; - - const deleteDataWithBody = async (endpoint, payload) => { - const response = await axios.delete(endpoint, { - headers: getAuthHeaders(), - data: payload, - }); - await fetchData(); - return response.data.data; - }; + const clearError = () => setError(null); return { data, dataLoading, dataError, - getData, - postData, - postDataValidated, - putData, - deleteData, - deleteDataWithBody, + clearError, + getData: (url, params, refresh = true) => requestWrapper("get", url, params, refresh), + postData: (url, body, refresh = true) => requestWrapper("post", url, body, refresh), + putData: (url, body, refresh = true) => requestWrapper("put", url, body, refresh), + deleteData: (url, refresh = true) => requestWrapper("delete", url, null, refresh), + deleteDataWithBody: (url, body, refresh = true) => requestWrapper("delete", url, body, refresh) }; }; diff --git a/src/hooks/useDataContext.js b/src/hooks/useDataContext.js index e139079..f4b6a80 100644 --- a/src/hooks/useDataContext.js +++ b/src/hooks/useDataContext.js @@ -1,4 +1,4 @@ import { useContext } from "react"; -import { DataContext } from "@/context/DataContext"; +import { DataContext } from "../context/DataContext"; export const useDataContext = () => useContext(DataContext); diff --git a/src/hooks/useRequestCount.js b/src/hooks/useRequestCount.js new file mode 100644 index 0000000..6f302ca --- /dev/null +++ b/src/hooks/useRequestCount.js @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import axios from 'axios'; +import { useConfig } from './useConfig'; + +const useRequestCount = () => { + const { config } = useConfig(); + const [count, setCount] = useState(null); + + useEffect(() => { + if (!config) return; + + const fetchCount = async () => { + try { + const res = await axios.get( + config.apiConfig.baseUrl + config.apiConfig.endpoints.requests.count, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + } + ); + setCount(res.data.count); + } catch (err) { + console.error('❌ Error al obtener el número de solicitudes:', err.message); + } + }; + + fetchCount(); + }, [config]); + + return count; +}; + +export default useRequestCount; diff --git a/src/hooks/useSessionRenewal.jsx b/src/hooks/useSessionRenewal.jsx index 4566619..ab187e0 100644 --- a/src/hooks/useSessionRenewal.jsx +++ b/src/hooks/useSessionRenewal.jsx @@ -32,7 +32,7 @@ const useSessionRenewal = () => { clearInterval(interval); logout(); } - }, 10000); // revisa cada 10 segundos + }, 10000); return () => clearInterval(interval); }, [alreadyWarned, logout]); @@ -50,7 +50,7 @@ const useSessionRenewal = () => { try { const response = await axios.get( - `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.refreshToken}`, + `${config.apiConfig.coreUrl}${config.apiConfig.endpoints.auth.refreshToken}`, null, { headers: { @@ -59,7 +59,7 @@ const useSessionRenewal = () => { } ); - const newToken = response.data.data.token; + const newToken = response.data.token; localStorage.setItem("token", newToken); setShowModal(false); setAlreadyWarned(false); diff --git a/src/util/alertHelpers.jsx b/src/util/alertHelpers.jsx deleted file mode 100644 index 17124b8..0000000 --- a/src/util/alertHelpers.jsx +++ /dev/null @@ -1,15 +0,0 @@ -export const renderErrorAlert = (error, options = {}) => { - const { className = 'alert alert-danger py-1 px-2 small', role = 'alert' } = options; - - if (!error) return null; - - return ( -
- {typeof error === 'string' ? error : 'An unexpected error occurred.'} -
- ); -}; - -export const resetErrorIfEditEnds = (editMode, setError) => { - if (!editMode) setError(null); -}; diff --git a/src/util/array.js b/src/util/array.js new file mode 100644 index 0000000..7480a5d --- /dev/null +++ b/src/util/array.js @@ -0,0 +1,5 @@ +const random = (arr) => { + return arr[Math.floor(Math.random() * arr.length)] +} + +export { random } \ No newline at end of file diff --git a/src/util/parsers/dateParser.js b/src/util/parsers/dateParser.js deleted file mode 100644 index dc80506..0000000 --- a/src/util/parsers/dateParser.js +++ /dev/null @@ -1,30 +0,0 @@ -export const DateParser = { - sqlToString: (sqlDate) => { - const [datePart] = sqlDate.split('T'); - const [year, month, day] = datePart.split('-'); - return `${day}/${month}/${year}`; - }, - - timestampToString: (timestamp) => { - const [datePart] = timestamp.split('T'); - const [year, month, day] = datePart.split('-'); - return `${day}/${month}/${year}`; - }, - - isoToStringWithTime: (isoString) => { - if (!isoString) return '—'; - - const date = new Date(isoString); - if (isNaN(date)) return '—'; // Para proteger aún más por si llega basura - - return new Intl.DateTimeFormat('es-ES', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZone: 'Europe/Madrid' - }).format(date); - } -}; diff --git a/src/util/parsers/errorParser.js b/src/util/parsers/errorParser.js deleted file mode 100644 index 971bcd8..0000000 --- a/src/util/parsers/errorParser.js +++ /dev/null @@ -1,10 +0,0 @@ -export const errorParser = (err) => { - const message = err.response?.data?.message; - try { - const parsed = JSON.parse(message); - return Object.values(parsed)[0]; - // eslint-disable-next-line no-unused-vars - } catch (e) { - return message || err.message || "Unknown error"; - } -}; diff --git a/src/util/passwordGenerator.js b/src/util/passwordGenerator.js deleted file mode 100644 index 9a99610..0000000 --- a/src/util/passwordGenerator.js +++ /dev/null @@ -1,29 +0,0 @@ -export const generateSecurePassword = (length = 12) => { - const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const lower = 'abcdefghijklmnopqrstuvwxyz'; - const digits = '0123456789'; - const symbols = '!@#$%^&*'; // <- compatibles con bcrypt - const all = upper + lower + digits + symbols; - - if (length < 8) length = 8; - - const getRand = (chars) => chars[Math.floor(Math.random() * chars.length)]; - - let password = [ - getRand(upper), - getRand(lower), - getRand(digits), - getRand(symbols), - ]; - - for (let i = password.length; i < length; i++) { - password.push(getRand(all)); - } - - for (let i = password.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [password[i], password[j]] = [password[j], password[i]]; - } - - return password.join(''); -}; diff --git a/src/util/tokenUtils.js b/src/util/tokenUtils.js deleted file mode 100644 index 38e5970..0000000 --- a/src/util/tokenUtils.js +++ /dev/null @@ -1,7 +0,0 @@ -export const parseJwt = (token) => { - try { - return JSON.parse(atob(token.split('.')[1])); - } catch (e) { - return null; - } -};