Fase 2: Creatción de la UI (React + Vite). Implementación de Log In reemplazando texto plano. Y creación de tool para migrar contraseñas.

This commit is contained in:
2025-05-05 15:49:01 -03:00
parent 9b1de95404
commit da7b544372
81 changed files with 12260 additions and 99 deletions

24
Frontend/.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?

54
Frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

28
Frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
Frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/eldia.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sistema de Gestión El Día</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5114
Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
Frontend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2",
"axios": "^1.9.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69">
<path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/>
<path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

7
Frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,7 @@
import AppRoutes from './routes/AppRoutes';
function App() {
return <AppRoutes />;
}
export default App;

View File

@@ -0,0 +1,73 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import type { ReactNode } from 'react'; // Importar como tipo
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto';
interface AuthContextType {
isAuthenticated: boolean;
user: LoginResponseDto | null;
token: string | null;
isLoading: boolean; // Para saber si aún está verificando el token inicial
login: (userData: LoginResponseDto) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [user, setUser] = useState<LoginResponseDto | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); // Empieza cargando
// Efecto para verificar token al cargar la app
useEffect(() => {
const storedToken = localStorage.getItem('authToken');
const storedUser = localStorage.getItem('authUser'); // Guardamos el usuario también
if (storedToken && storedUser) {
try {
// Aquí podrías añadir lógica para validar si el token aún es válido (ej: decodificarlo)
// Por ahora, simplemente asumimos que si está, es válido.
const parsedUser: LoginResponseDto = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser);
setIsAuthenticated(true);
} catch (error) {
console.error("Error parsing stored user data", error);
logout(); // Limpia si hay error al parsear
}
}
setIsLoading(false); // Termina la carga inicial
}, []);
const login = (userData: LoginResponseDto) => {
localStorage.setItem('authToken', userData.Token);
localStorage.setItem('authUser', JSON.stringify(userData)); // Guardar datos de usuario
setToken(userData.Token);
setUser(userData);
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('authUser');
setToken(null);
setUser(null);
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, user, token, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
};
// Hook personalizado para usar el contexto fácilmente
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,48 @@
import React from 'react';
import type { ReactNode } from 'react'; // Importar como tipo
import { Box, AppBar, Toolbar, Typography, Button } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
interface MainLayoutProps {
children: ReactNode; // Para renderizar las páginas hijas
}
const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const { user, logout } = useAuth();
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Gestión Integral
</Typography>
{user && <Typography sx={{ mr: 2 }}>Hola, {user.Username}</Typography> }
<Button color="inherit" onClick={logout}>Cerrar Sesión</Button>
</Toolbar>
{/* Aquí iría el MaterialTabControl o similar para la navegación principal */}
</AppBar>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3, // Padding
// Puedes añadir color de fondo si lo deseas
// backgroundColor: (theme) => theme.palette.background.default,
}}
>
{/* El contenido de la página actual se renderizará aquí */}
{children}
</Box>
{/* Aquí podría ir un Footer o StatusStrip */}
<Box component="footer" sx={{ p: 1, mt: 'auto', backgroundColor: 'primary.dark', color: 'white', textAlign: 'center' }}>
<Typography variant="body2">
{/* Replicar info del StatusStrip original */}
Usuario: {user?.Username} | Acceso: {user?.EsSuperAdmin ? 'Super Admin' : 'Perfil...'} | Versión: {/** Obtener versión **/}
</Typography>
</Box>
</Box>
);
};
export default MainLayout;

18
Frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from './theme/theme';
import { AuthProvider } from './contexts/AuthContext'; // Importar AuthProvider
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<AuthProvider> {/* Envolver con AuthProvider */}
<CssBaseline />
<App />
</AuthProvider> {/* Cerrar AuthProvider */}
</ThemeProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,5 @@
// src/models/dtos/LoginRequestDto.ts
export interface LoginRequestDto {
Username: string; // Coincide con las propiedades C#
Password: string;
}

View File

@@ -0,0 +1,10 @@
// src/models/dtos/LoginResponseDto.ts
export interface LoginResponseDto {
Token: string;
UserId: number;
Username: string;
NombreCompleto: string;
EsSuperAdmin: boolean;
DebeCambiarClave: boolean;
// Añade otros campos si los definiste en el DTO C#
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Typography, Container } from '@mui/material';
// import { useLocation } from 'react-router-dom'; // Para obtener el estado 'firstLogin'
const ChangePasswordPage: React.FC = () => {
// const location = useLocation();
// const isFirstLogin = location.state?.firstLogin;
return (
<Container>
<Typography variant="h4" component="h1" gutterBottom>
Cambiar Contraseña
</Typography>
{/* {isFirstLogin && <Alert severity="warning">Debes cambiar tu contraseña inicial.</Alert>} */}
{/* Aquí irá el formulario de cambio de contraseña */}
<Typography variant="body1">
Formulario de cambio de contraseña irá aquí...
</Typography>
</Container>
);
};
export default ChangePasswordPage;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Typography, Container } from '@mui/material';
const HomePage: React.FC = () => {
return (
<Container>
<Typography variant="h4" component="h1" gutterBottom>
Bienvenido al Sistema
</Typography>
<Typography variant="body1">
Seleccione una opción del menú principal para comenzar.
</Typography>
</Container>
);
};
export default HomePage;

View File

@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import axios from 'axios'; // Importar axios
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import apiClient from '../services/apiClient'; // Nuestro cliente axios
import type { LoginRequestDto } from '../models/dtos/LoginRequestDto'; // Usar type
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto'; // Usar type
// Importaciones de Material UI
import { Container, TextField, Button, Typography, Box, Alert } from '@mui/material';
const LoginPage: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setLoading(true);
const loginData: LoginRequestDto = { Username: username, Password: password };
try {
const response = await apiClient.post<LoginResponseDto>('/auth/login', loginData);
login(response.data); // Guardar token y estado de usuario en el contexto
// TODO: Verificar si response.data.DebeCambiarClave es true y redirigir
// a '/change-password' si es necesario.
// if (response.data.DebeCambiarClave) {
// navigate('/change-password', { state: { firstLogin: true } }); // Pasar estado si es necesario
// } else {
navigate('/'); // Redirigir a la página principal
// }
} catch (err: any) {
console.error("Login error:", err);
if (axios.isAxiosError(err) && err.response) {
// Intenta obtener el mensaje de error de la API, si no, usa uno genérico
setError(err.response.data?.message || 'Error al iniciar sesión. Verifique sus credenciales.');
} else {
setError('Ocurrió un error inesperado.');
}
} finally {
setLoading(false);
}
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Iniciar Sesión
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label="Usuario"
name="username"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Contraseña"
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
{error && (
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
{error}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? 'Ingresando...' : 'Ingresar'}
</Button>
</Box>
</Box>
</Container>
);
};
export default LoginPage;

View File

@@ -0,0 +1,63 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from '../pages/LoginPage';
import HomePage from '../pages/HomePage'; // Crearemos esta página simple
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordPage from '../pages/ChangePasswordPage'; // Crearemos esta
import MainLayout from '../layouts/MainLayout'; // Crearemos este
// Componente para proteger rutas
const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
// Muestra algo mientras verifica el token (ej: un spinner)
return <div>Cargando...</div>;
}
return isAuthenticated ? children : <Navigate to="/login" replace />;
};
// Componente para rutas públicas (redirige si ya está logueado)
const PublicRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <div>Cargando...</div>;
}
return !isAuthenticated ? children : <Navigate to="/" replace />;
};
const AppRoutes = () => {
return (
<BrowserRouter>
<Routes>
{/* Rutas Públicas */}
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/change-password" element={<ProtectedRoute><ChangePasswordPage /></ProtectedRoute>} /> {/* Asumimos que se accede logueado */}
{/* Rutas Protegidas dentro del Layout Principal */}
<Route
path="/*" // Captura todas las demás rutas
element={
<ProtectedRoute>
<MainLayout> {/* Layout que tendrá la navegación principal */}
{/* Aquí irán las rutas de los módulos */}
<Routes>
<Route index element={<HomePage />} /> {/* Página por defecto al loguearse */}
{/* <Route path="/usuarios" element={<GestionUsuariosPage />} /> */}
{/* <Route path="/zonas" element={<GestionZonasPage />} /> */}
{/* ... otras rutas de módulos ... */}
<Route path="*" element={<Navigate to="/" replace />} /> {/* Redirige rutas no encontradas al home */}
</Routes>
</MainLayout>
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
};
export default AppRoutes;

View File

@@ -0,0 +1,30 @@
import axios from 'axios';
// Obtén la URL base de tu API desde variables de entorno o configúrala aquí
// Asegúrate que coincida con la URL donde corre tu API ASP.NET Core
const API_BASE_URL = 'http://localhost:5183/api'; // ¡AJUSTA EL PUERTO SI ES DIFERENTE! (Verifica la salida de 'dotnet run')
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir el token JWT a las peticiones (si existe)
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken'); // O donde guardes el token
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Puedes añadir interceptores de respuesta para manejar errores globales (ej: 401 Unauthorized)
export default apiClient;

View File

@@ -0,0 +1,36 @@
import { createTheme } from '@mui/material/styles';
import { esES } from '@mui/material/locale'; // Importar localización español
// Paleta similar a la que definiste para MaterialSkin
const theme = createTheme({
palette: {
primary: {
main: '#607d8b', // BlueGrey 500
light: '#8eacbb',
dark: '#34515e', // Un poco más oscuro que BlueGrey 700
},
secondary: {
main: '#455a64', // BlueGrey 700
light: '#718792',
dark: '#1c313a', // BlueGrey 900
},
background: {
default: '#eceff1', // BlueGrey 50 (similar a LightBlue50)
paper: '#ffffff', // Blanco para superficies como cards
},
// El Accent de MaterialSkin es más difícil de mapear directamente,
// puedes usar 'secondary' o definir colores personalizados si es necesario.
// Usaremos secondary por ahora.
// text: { // MUI infiere esto, pero puedes forzar blanco si es necesario
// primary: '#ffffff',
// secondary: 'rgba(255, 255, 255, 0.7)',
// },
},
typography: {
// Puedes personalizar fuentes aquí si lo deseas
fontFamily: 'Roboto, Arial, sans-serif',
},
// Añadir localización
}, esES); // Pasar el objeto de localización
export default theme;

1
Frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
Frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
Frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})