Versión 0.0.6 estable

This commit is contained in:
Germán Enríquez 2023-07-31 01:57:45 -05:00
commit 650b2e2df7
30 changed files with 5776 additions and 0 deletions

20
.eslintrc.cjs Normal file
View file

@ -0,0 +1,20 @@
/* eslint-env node */
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

4646
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

66
package.json Normal file
View file

@ -0,0 +1,66 @@
{
"name": "@chimera-pe/react-saas",
"version": "0.0.5",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.1",
"@mui/lab": "^5.0.0-alpha.137",
"@mui/material": "^5.14.1",
"@mui/x-date-pickers": "^6.10.1",
"@reduxjs/toolkit": "^1.9.5",
"axios": "^1.4.0",
"date-fns": "^2.30.0",
"final-form": "^4.20.9",
"jwt-decode": "^3.1.2",
"mui-rff": "^6.2.0",
"react": ">=18",
"react-dom": ">=18",
"react-final-form": "^6.5.9",
"react-polyglot": "^0.7.2",
"react-redux": "^8.1.1",
"react-router-dom": "^6.14.2"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@vitejs/plugin-react": "^4.0.1",
"eslint": "^8.44.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"vite": "^4.4.0"
},
"description": "Componente integrador con SaaS",
"main": "./dist/react-saas.umd.cjs",
"module": "./dist/react-saas.js",
"exports": {
".": {
"import": "./dist/react-saas.js",
"require": "./dist/react-saas.umd.cjs"
}
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://git.chimera.com.pe/chimera/react-saas.git"
},
"keywords": [
"saas"
],
"author": "Germán Enríquez",
"license": "ISC"
}

2
src/Constantes.js Normal file
View file

@ -0,0 +1,2 @@
export const saasURL="https://saas.chimera.com.pe/backend";
export const authURL="https://saas.chimera.com.pe/oauth";

View file

@ -0,0 +1,9 @@
import axios from "axios";
import {saasURL} from "../Constantes";
export const identidadApi = (url = saasURL,aplicacion) => axios({
url: `${url}/identidad/`,
params: {
codigoAplicacion: aplicacion
}
});

30
src/api/loginApi.jsx Normal file
View file

@ -0,0 +1,30 @@
import axios from "axios";
import {authURL} from "../Constantes";
export const loginApi = (url = authURL) => ({
login: (clientCredentials,data) => axios({
url: `${url}/oauth/token`,
headers: {
"content-type": "application/x-www-form-urlencoded",
Authorization: `Basic ${clientCredentials}`
},
method: "post",
data: {
username: data.correo,
password: data.password,
"grant_type": "password"
}
}),
refreshToken: (clientCredentials,refreshToken) => axios({
url: `${url}/oauth/token`,
headers: {
"content-type": "application/x-www-form-urlencoded",
Authorization: `Basic ${clientCredentials}`
},
method: "post",
data: {
"refresh_token": refreshToken,
"grant_type": "refresh_token"
}
})
});

View file

@ -0,0 +1,15 @@
import {Box,CircularProgress} from "@mui/material";
const Cargando=() => (
<Box sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
justifyContent: "center",
alignItems: "center"
}}>
<CircularProgress />
</Box>
);
export default Cargando;

31
src/components/Error.jsx Normal file
View file

@ -0,0 +1,31 @@
import {Box,Alert,AlertTitle} from "@mui/material";
import {useTranslate} from "react-polyglot";
import PropTypes from "prop-types";
const Error = ({titulo,texto,align = "center",severity = "error"}) => {
const translate = useTranslate();
return (
<Box sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
justifyContent: "center",
alignItems: align
}}>
<Alert severity={severity}>
<AlertTitle>{translate(titulo)}</AlertTitle>
{texto && translate(texto)}
</Alert>
</Box>
);
};
Error.propTypes={
titulo: PropTypes.string.isRequired,
texto: PropTypes.string,
align: PropTypes.string,
severity: PropTypes.string
};
export default Error;

60
src/components/Idioma.jsx Normal file
View file

@ -0,0 +1,60 @@
import {useEffect} from "react";
import PropTypes from "prop-types";
import {useDispatch,useSelector} from "react-redux";
import {I18n} from "react-polyglot";
import {enGB,es} from "date-fns/locale"
import {LocalizationProvider} from "@mui/x-date-pickers";
import {AdapterDateFns} from "@mui/x-date-pickers/AdapterDateFns";
import {cambiarIdioma} from "../redux";
import saasMessages from "../i18n";
const supportedLocales={en: enGB,es};
const IdiomaInner=({messages,children}) => {
const idioma=useSelector(store => store.ui.idioma);
return (
<I18n locale={idioma} messages={messages[idioma]}>
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={supportedLocales[idioma]}>
{children}
</LocalizationProvider>
</I18n>
);
};
IdiomaInner.propTypes={
messages: PropTypes.object,
children: PropTypes.element.isRequired
};
const Idioma=({messages,idioma,children}) => {
const dispatch=useDispatch();
useEffect(() => {
if(idioma){
dispatch(cambiarIdioma(idioma));
}
},[dispatch,idioma]);
const m={};
Object.keys(messages).forEach(key => {
m[key]={
...messages[key],
saas: saasMessages[key]
};
});
return (
<IdiomaInner messages={m}>
{children}
</IdiomaInner>
);
};
Idioma.propTypes={
messages: PropTypes.object,
idioma: PropTypes.string,
children: PropTypes.element.isRequired
};
export default Idioma;

View file

@ -0,0 +1,79 @@
import {useEffect} from "react";
import PropTypes from "prop-types";
import {Box} from "@mui/material";
import {useDispatch,useSelector} from "react-redux";
import {inicializar} from "../redux";
import Cargando from "./Cargando";
import Error from "./Error";
import Notificacion from "./Notificacion";
import Idioma from "./Idioma";
import Tema from "./Tema";
import MainRouter from "./MainRouter";
const InicializarInner = ({devURL,children}) => {
const aplicacion = useSelector(store => store.aplicacion);
return (
<Box sx={{
display: "flex",
flexDirection: "column",
minHeight: "100vh",
justifyContent: "flex-start",
backgroundColor: "background.default"
}}>
{aplicacion.inicializando ?
<Cargando />
: (aplicacion.error || !aplicacion.inicializado) ?
<Error titulo={"saas.inicializar.error.titulo"} texto={"saas.inicializar.error.mensaje"} />
:
<>
<MainRouter devURL={devURL} requiereLogin={aplicacion.instancia.requiereLogin}>
{children}
</MainRouter>
<Notificacion />
</>
}
</Box>
);
};
InicializarInner.propTypes={
devURL: PropTypes.string,
children: PropTypes.element.isRequired
};
const Inicializar = ({
aplicacion,
devSaasURL,
devAuthURL,
messages,
idioma,
children
}) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(inicializar({devURL: devSaasURL,aplicacion: aplicacion}));
},[dispatch,aplicacion,devSaasURL]);
return (
<Idioma messages={messages} idioma={idioma}>
<Tema>
<InicializarInner devURL={devAuthURL}>
{children}
</InicializarInner>
</Tema>
</Idioma>
);
};
Inicializar.propTypes={
aplicacion: PropTypes.string.isRequired,
devSaasURL: PropTypes.string,
devAuthURL: PropTypes.string,
messages: PropTypes.object,
idioma: PropTypes.string,
children: PropTypes.element.isRequired
};
export default Inicializar;

211
src/components/Login.jsx Normal file
View file

@ -0,0 +1,211 @@
import {useEffect} from "react";
import PropTypes from "prop-types";
import {useSelector,useDispatch} from "react-redux";
import {Navigate,useLocation} from "react-router-dom";
import {
Box,
Container,
Grid,
Typography,
Card,
CardHeader,
CardContent,
InputAdornment,
Button,
CircularProgress,
AppBar,
Toolbar,
Link
} from "@mui/material";
import {Email,Lock} from "@mui/icons-material";
import {Form} from "react-final-form";
import {TextField} from "mui-rff";
import {useTranslate} from "react-polyglot";
import {requestToken} from "../redux";
import {useCheckLogin,useNotificar} from "../hooks";
const FormularioLogin = ({devURL}) => {
const dispatch = useDispatch();
const translate = useTranslate();
const notificar = useNotificar();
const {cargando,error} = useSelector(store => store.login);
const instancia = useSelector(store => store.aplicacion.instancia);
const submit = values => {
dispatch(requestToken({
devURL: devURL,
clientCredentials: instancia.clientCredentials,
data: {
correo: values.correo,
password: values.password
}
}));
};
const validate = values => {
const errors = {correo: undefined,password: undefined};
if(!values.correo) {
errors.correo = translate("saas.login.validacion.correo");
}
if(!values.password) {
errors.password = translate("saas.login.validacion.password");
}
return errors;
};
useEffect(() => {
if(error) {
notificar("saas.login.error","error");
}
},[notificar,error]);
return (
<Form
onSubmit={submit}
validate={validate}
render={({handleSubmit}) => (
<form onSubmit={handleSubmit}>
<TextField
id="correo"
name="correo"
label={translate("saas.login.correo")}
variant="outlined"
autoComplete="off"
disabled={cargando}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email color="primary" />
</InputAdornment>
)
}}
/>
<TextField
id="password"
name="password"
type="password"
label={translate("saas.login.password")}
variant="outlined"
autoComplete="current-password"
disabled={cargando}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="primary" />
</InputAdornment>
)
}}
/>
<Grid container>
<Grid item xs={6}></Grid>
<Grid item xs={6} align="right">
<Button
variant="contained"
color="primary"
type="submit"
disabled={cargando}
>
{cargando ?
<CircularProgress size={24} thickness={4} />
:
translate("saas.login.ingresar")
}
</Button>
</Grid>
</Grid>
</form>
)}
/>
);
};
FormularioLogin.propTypes = {
devURL: PropTypes.string
};
const Login = ({devURL}) => {
const instancia = useSelector(store => store.aplicacion.instancia);
const translate = useTranslate();
const location = useLocation();
const autenticado = useCheckLogin(devURL);
const {from} = location.state || {from: {pathname: "/"}};
if(!instancia.requiereLogin || autenticado) {
return <Navigate to={from} />;
}
return (
<Box sx={{
position: "relative",
display: "flex",
flexDirection: "column",
flexGrow: 1,
minHeight: "100vh",
backgroundColor: "primary.main",
"&::before": {
content: '""',
backgroundImage: `url(${instancia.logo})`,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
backgroundPosition: "right",
position: "absolute",
top: "20%",
bottom: 0,
right: 0,
width: "50%",
opacity: 0.02,
filter: "grayscale(100%)"
}
}}>
<Box sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flexGrow: 1
}}>
<Container maxWidth="md">
<Grid container spacing={3}>
<Grid item xs={12} lg={5} align="center">
<img src={instancia.logo} alt={instancia.nombre} style={{maxWidth: "100%"}} />
<Typography variant="h3" align="center">{translate("aplicacion.nombre",{smart_count: 1})}</Typography>
<Typography variant="h5" align="center">{instancia.nombre}</Typography>
</Grid>
<Grid item xs={12} lg={7} sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 5
}}>
<Card elevation={5}>
<CardHeader title={translate("saas.login.titulo")} titleTypographyProps={{align: "center"}} />
<CardContent sx={{
"& .MuiTextField-root": {
mb: 2
},
}}>
<FormularioLogin devURL={devURL} />
</CardContent>
</Card>
</Grid>
</Grid>
</Container>
</Box>
<AppBar position="static" color="primary">
<Toolbar sx={{justifyContent: "center"}}>
<Typography variant="caption">
{translate("saas.copy")}
<Link href="//chimera.com.pe" color="inherit" target="_blank" rel="noreferrer">Chimera Software</Link>
</Typography>
</Toolbar>
</AppBar>
</Box>
);
};
Login.propTypes = {
devURL: PropTypes.string
};
export default Login;

View file

@ -0,0 +1,48 @@
import PropTypes from "prop-types";
import {
BrowserRouter,
Routes,
Route,
Navigate,
useLocation
} from "react-router-dom";
import {useCheckLogin} from "../hooks";
import Login from "./Login";
const RequiereAuth = ({devURL,redirectTo,children}) => {
const location = useLocation();
const autenticado = useCheckLogin(devURL);
return autenticado ? children : <Navigate to={redirectTo} state={{from: location}} replace />;
};
RequiereAuth.propTypes={
devURL: PropTypes.string,
redirectTo: PropTypes.string.isRequired,
children: PropTypes.element.isRequired
};
const MainRouter = ({devURL,requiereLogin,children}) => (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login devURL={devURL} />} />
<Route
path="/*"
element={requiereLogin ? (
<RequiereAuth devURL={devURL} redirectTo="/login">
{children}
</RequiereAuth>
) : children
}
/>
</Routes>
</BrowserRouter>
);
MainRouter.propTypes={
devURL: PropTypes.string,
requiereLogin: PropTypes.bool,
children: PropTypes.element.isRequired
};
export default MainRouter;

View file

@ -0,0 +1,36 @@
import {useEffect,useState} from "react";
import {useSelector,useDispatch} from "react-redux";
import {Snackbar,Alert} from "@mui/material";
import {useTranslate} from "react-polyglot";
import {ocultarNotificacion} from "../redux";
const Notificacion=() => {
const [open,setOpen]=useState(false);
const dispatch=useDispatch();
const translate=useTranslate();
const notificacion=useSelector(store => store.notificaciones[0]);
useEffect(() => {
setOpen(!!notificacion);
},[notificacion]);
const handleClose=() => {
setOpen(false);
dispatch(ocultarNotificacion());
};
return (
<Snackbar
open={open}
message={notificacion && notificacion.mensaje && notificacion.tipo === "default" && translate(notificacion.mensaje)}
autoHideDuration={5000}
onClose={handleClose}
>
{notificacion && notificacion.mensaje && notificacion.tipo !== "default" &&
<Alert severity={notificacion.tipo}>{translate(notificacion.mensaje)}</Alert>
}
</Snackbar>
);
};
export default Notificacion;

View file

@ -0,0 +1,42 @@
import PropTypes from "prop-types";
import {Provider} from "react-redux";
import {store} from "../redux";
import Inicializar from "./Inicializar";
const SaasApp = ({
customReducers,
aplicacion,
devSaasURL,
devAuthURL,
dev = false,
idioma,
messages,
children
}) => {
return (
<Provider store={store(customReducers)}>
<Inicializar
aplicacion={aplicacion}
devSaasURL={dev ? devSaasURL : undefined}
devAuthURL={dev ? devAuthURL : undefined}
idioma={idioma}
messages={messages}
>
{children}
</Inicializar>
</Provider>
);
};
SaasApp.propTypes={
customReducers: PropTypes.object,
aplicacion: PropTypes.string.isRequired,
devSaasURL: PropTypes.string,
devAuthURL: PropTypes.string,
dev: PropTypes.bool,
idioma: PropTypes.string,
messages: PropTypes.object,
children: PropTypes.element.isRequired
};
export default SaasApp;

64
src/components/Tema.jsx Normal file
View file

@ -0,0 +1,64 @@
import {useMemo,useEffect} from "react";
import PropTypes from "prop-types";
import {useDispatch,useSelector} from "react-redux";
import {CssBaseline,useMediaQuery} from "@mui/material";
import {ThemeProvider,createTheme} from "@mui/material/styles";
import {grey} from "@mui/material/colors";
import {cambiarTema} from "../redux";
const Tema=({children}) => {
const dispatch=useDispatch();
const {instancia}=useSelector(store => store.aplicacion);
const tema=useSelector(store => store.ui.tema);
const prefersDarkMode=useMediaQuery("(prefers-color-scheme: dark)");
useEffect(() => {
dispatch(cambiarTema(prefersDarkMode ? "dark" : "light"));
},[dispatch,prefersDarkMode]);
const theme=useMemo(() => createTheme({
palette: {
mode: tema,
primary: {
main: instancia.color.primary
},
secondary: {
main: instancia.color.secondary
},
error: {
main: instancia.color.error
},
warning: {
main: instancia.color.warning
},
info: {
main: instancia.color.info
},
success: {
main: instancia.color.success
},
...(tema === "light" && {
background: {
default: grey["A200"],
paper: grey[100]
}
})
},
shape: {
borderRadius: 6
}
}),[tema,instancia]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
};
Tema.propTypes={
children: PropTypes.element.isRequired
};
export default Tema;

9
src/components/index.jsx Normal file
View file

@ -0,0 +1,9 @@
import Cargando from "./Cargando";
import Error from "./Error";
import SaasApp from "./SaasApp";
export {
Cargando,
Error,
SaasApp
};

7
src/hooks/index.jsx Normal file
View file

@ -0,0 +1,7 @@
import useCheckLogin from "./useCheckLogin";
import useNotificar from "./useNotificar";
export {
useCheckLogin,
useNotificar
};

View file

@ -0,0 +1,24 @@
import {useEffect} from "react";
import {useDispatch,useSelector} from "react-redux";
import {logout, refreshToken} from "../redux";
const useCheckLogin = (devURL) => {
const dispatch = useDispatch();
const login = useSelector(store => store.login);
const instancia = useSelector(store => store.aplicacion.instancia);
useEffect(() => {
if(login.autenticado && !!login.expiracion && new Date(login.expiracion) < new Date()){
if(login.refreshToken){
dispatch(refreshToken(devURL,instancia.clientCredentials,login.refreshToken));
}
else{
dispatch(logout());
}
}
},[devURL,instancia.clientCredentials,login,dispatch]);
return login.autenticado;
};
export default useCheckLogin;

View file

@ -0,0 +1,12 @@
import {useCallback} from "react";
import {useDispatch} from "react-redux";
import {mostrarNotificacion} from "../redux";
const useNotificar = () => {
const dispatch = useDispatch();
return useCallback((mensaje,tipo = "default") => {
dispatch(mostrarNotificacion({mensaje,tipo}));
},[dispatch]);
};
export default useNotificar;

22
src/i18n/es.jsx Normal file
View file

@ -0,0 +1,22 @@
const es={
inicializar: {
error: {
titulo: "Error iniciando aplicación",
mensaje: "No fue posible conectarnos con el servicio"
}
},
login: {
titulo: "Acceder al sistema",
correo: "Correo electrónico",
password: "Contraseña",
ingresar: "Acceder",
validacion: {
correo: "Debe ingresar una dirección de correo",
password: "Debe ingresar una contraseña"
},
error: "Usuario o contraseña incorrectos"
},
copy: "Todos los derechos reservados "
};
export default es;

7
src/i18n/index.jsx Normal file
View file

@ -0,0 +1,7 @@
import es from "./es";
const messages={
es: es
};
export default messages;

12
src/index.js Normal file
View file

@ -0,0 +1,12 @@
import {SaasApp} from "./components";
import {useNotificar} from "./hooks";
import {Cargando,Error} from "./components";
import {logout} from "./redux";
export {
SaasApp,
useNotificar,
Cargando,
Error,
logout
};

17
src/redux/index.jsx Normal file
View file

@ -0,0 +1,17 @@
import {store} from "./store";
import {inicializar} from "./inicializarSlice";
import {refreshToken,requestToken,logout} from "./loginSlice";
import {mostrarNotificacion,ocultarNotificacion} from "./notificacionSlice";
import {cambiarIdioma,cambiarTema} from "./uiSlice";
export {
store,
inicializar,
refreshToken,
requestToken,
logout,
mostrarNotificacion,
ocultarNotificacion,
cambiarIdioma,
cambiarTema
};

View file

@ -0,0 +1,57 @@
import {createSlice,createAsyncThunk} from "@reduxjs/toolkit";
import {identidadApi} from "../api/inicializarApi";
const colorDefault = {
primary: "#1C6CCC",
secondary: "#17A7FF",
error: "#f44336",
warning: "#ff9800",
info: "#2196f3",
success: "#4caf50"
};
const inicializarSlice = createSlice({
name: "inicializar",
initialState: {
inicializando: true,
inicializado: false,
instancia: {
color: colorDefault
},
error: null
},
extraReducers(builder) {
builder
.addCase(inicializar.pending,state => {
state.inicializando = true;
})
.addCase(inicializar.fulfilled,(state,action) => {
state.inicializando = false;
state.inicializado = true;
state.instancia = {
...action.payload,
abreviatura: action.payload.nombre.match(/\b([A-Z])/g).join(""),
color: {
...colorDefault,
...action.payload.color
}
};
state.error = null;
})
.addCase(inicializar.rejected,(state,action) => {
state.inicializando = false;
state.inicializado = false;
state.instancia = {
color: colorDefault
};
state.error = action.payload;
});
}
});
export const inicializar = createAsyncThunk("inicializar",async (payload) => {
const response = await identidadApi(payload.devURL,payload.aplicacion);
return response.data;
});
export default inicializarSlice.reducer;

94
src/redux/loginSlice.jsx Normal file
View file

@ -0,0 +1,94 @@
import {createSlice,createAsyncThunk} from "@reduxjs/toolkit";
import {loginApi} from "../api/loginApi";
import jwtDecode from "jwt-decode";
const loginSlice = createSlice({
name: "login",
initialState: {
cargando: false,
autenticado: false,
token: null,
refreshToken: null,
expiracion: null,
usuario: null,
perfiles: [],
error: null
},
reducers: {
logout: state => {
state.cargando = false;
state.autenticado = false;
state.token = null;
state.refreshToken = null;
state.expiracion = null;
state.usuario = null;
state.perfiles = [];
state.error = null;
}
},
extraReducers(builder) {
builder
.addCase(requestToken.pending,state => {
state.cargando = true;
state.error = null;
})
.addCase(requestToken.fulfilled,(state,action) => {
const token = action.payload.access_token;
const jwtToken = jwtDecode(token);
const expiracion = new Date();
expiracion.setSeconds(expiracion.getSeconds() + action.payload.expires_in);
state.cargando = false;
state.autenticado = true;
state.token = token;
state.refreshToken = action.payload.refresh_token;
state.expiracion = expiracion.getTime();
state.usuario = jwtToken.name;
state.perfiles = jwtToken.authorities;
})
.addCase(requestToken.rejected,(state,action) => {
console.log(action);
state.cargando = false;
state.autenticado = false;
state.token = null;
state.refreshToken = null;
state.expiracion = null;
state.usuario = null;
state.perfiles = [];
state.error = action.error?.message;
})
.addCase(refreshToken.pending,state => {
state.cargando = true;
})
.addCase(refreshToken.fulfilled,(state,action) => {
const expiracion = new Date();
expiracion.setSeconds(expiracion.getSeconds() + action.payload.expires_in);
state.token = action.payload.access_token;
state.refreshToken = action.payload.refresh_token;
state.expiracion = expiracion.getTime();
})
.addCase(refreshToken.rejected,(state,action) => {
state.cargando = false;
state.autenticado = false;
state.token = null;
state.refreshToken = null;
state.expiracion = null;
state.usuario = null;
state.perfiles = [];
state.error = action.error?.message;
});
}
});
export const requestToken = createAsyncThunk("login/requestToken",async (payload) => {
const response = await loginApi(payload.devURL).login(payload.clientCredentials,payload.data);
return response.data;
});
export const refreshToken = createAsyncThunk("login/refreshToken",async (devURL,clientCredentials,refreshToken) => {
const response = await loginApi(devURL).refreshToken(clientCredentials,refreshToken);
return response.data;
});
export const {logout} = loginSlice.actions;
export default loginSlice.reducer;

View file

@ -0,0 +1,20 @@
import {createSlice} from "@reduxjs/toolkit";
const notificacionSlice=createSlice({
name: "notificacion",
initialState: [],
reducers: {
mostrarNotificacion: (state,action) => {
state.push(action.payload);
},
ocultarNotificacion: state => {
state.pop();
}
}
});
export const getNotificacion=store => Array.isArray(store.notificacion) && store.notificacion.length ? store.notificacion[0] : null;
export const {mostrarNotificacion,ocultarNotificacion}=notificacionSlice.actions;
export default notificacionSlice.reducer;

15
src/redux/store.jsx Normal file
View file

@ -0,0 +1,15 @@
import {configureStore} from "@reduxjs/toolkit";
import uiSlice from "./uiSlice";
import loginSlice from "./loginSlice";
import notificacionSlice from "./notificacionSlice";
import inicializarSlice from "./inicializarSlice";
export const store=(customReducers) => configureStore({
reducer: {
ui: uiSlice,
aplicacion: inicializarSlice,
login: loginSlice,
notificaciones: notificacionSlice,
...customReducers
}
});

24
src/redux/uiSlice.jsx Normal file
View file

@ -0,0 +1,24 @@
import {createSlice} from "@reduxjs/toolkit";
const uiSlice=createSlice({
name: "ui",
initialState: {
tema: "light",
temaSeleccionado: false,
idioma: "es",
},
reducers: {
cambiarTema: (state,action) => {
state.tema=action.payload;
state.temaSeleccionado=true;
localStorage.setItem("tema",action.payload);
},
cambiarIdioma: (state,action) => {
state.idioma=action.payload;
}
}
});
export const {cambiarTema,cambiarIdioma}=uiSlice.actions;
export default uiSlice.reducer;

73
vite.config.js Normal file
View file

@ -0,0 +1,73 @@
import {defineConfig} from "vite";
import {resolve} from "path";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname,"src/index.js"),
name: "react-saas",
fileName: "react-saas"
},
rollupOptions: {
external: [
"react",
"react-dom",
"react/jsx-runtime",
"react-is",
"@emotion/react",
"@emotion/styled",
"@mui/icons-material",
"@mui/lab",
"@mui/material",
"@mui/material/styles",
"@mui/material/colors",
"@mui/x-date-pickers",
"@mui/x-date-pickers/AdapterDateFns",
"@reduxjs/toolkit",
"axios",
"date-fns/locale",
"jwt-decode",
"mui-rff",
"react-polyglot",
"react-redux",
"react-router-dom",
"object-assign",
"prop-types",
"react-final-form",
"final-form"
],
output: {
globals: {
"react": "React",
"react-dom": "ReactDOM",
"react/jsx-runtime": "ReactJSX",
"react-is": "ReactIS",
"@emotion/react": "EmotionReact",
"@emotion/styled": "EmotionStyled",
"@mui/icons-material": "MuiIconsMaterial",
"@mui/lab": "MuiLab",
"@mui/material": "MuiMaterial",
"@mui/material/styles": "MuiMaterialStyles",
"@mui/material/colors": "MuiMaterialColors",
"@mui/x-date-pickers": "MuiXDatePickers",
"@mui/x-date-pickers/AdapterDateFns": "MuiXDatePickersAdapter",
"@reduxjs/toolkit": "ReduxToolkit",
"axios": "Axios",
"date-fns/locale": "DateFNSLocale",
"jwt-decode": "JWTDecode",
"mui-rff": "MUIRFF",
"react-polyglot": "ReactPolyglot",
"react-redux": "ReactRedux",
"react-router-dom": "ReactRouterDom",
"object-assign": "ObjectAssign",
"prop-types": "PropTypes",
"react-final-form": "ReactFinalForm",
"final-form": "FinalForm"
}
}
}
}
});