feat: Sistema de autenticación frontend (Login + Register + Dashboard) #3

Merged
dmolinari merged 4 commits from feat/autenticacion-frontend into main 2026-04-01 17:38:49 +00:00
32 changed files with 4450 additions and 1 deletions
Showing only changes of commit 7b9a7192c1 - Show all commits

5
.gitignore vendored
View File

@@ -20,5 +20,10 @@
.DS_Store
Thumbs.db
## Frontend
node_modules/
dist/
.env.local
## Docker
docker-compose.override.yml

View File

@@ -50,4 +50,16 @@ Sigue estrictamente la skill **"gitea-workflow"**:
- Al finalizar con éxito la fase `verify`, DEBES crear un Pull Request (PR) desde tu rama de trabajo hacia `main`, describiendo los cambios realizados y referenciando la incidencia (ej: `Closes #12`).
## 6. Reglas de entorno
- **Comandos:** Para todos los comandos de terminal, utiliza PowerShell o comandos directos de Windows, no uses 'bash' ya que mi entorno no lo soporta correctamente.
- **Comandos:** Para todos los comandos de terminal, utiliza PowerShell o comandos directos de Windows, no uses 'bash' ya que mi entorno no lo soporta correctamente.
## 📱 Protocolo de Comunicación Remota (MODO_REMOTO)
Cuando el usuario te indique que activa el MODO REMOTO (ej. con `/away`), DEBES detener tus consultas interactivas en el chat.
Para cualquier aprobación, pregunta o decisión que requieras del usuario, DEBES usar tu herramienta de terminal (`bash` o comando de sistema) para ejecutar este script de Node.js:
`node E:/telegram-mcp/index.js "TU PREGUNTA AQUI"`
**REGLA DE ORO:**
1. Pasa tu pregunta SIEMPRE entre comillas dobles.
2. Al ejecutar este comando, la terminal se pausará automáticamente (pueden ser minutos u horas). NO ENTRES EN PÁNICO, solo espera.
3. Cuando el comando termine, capturará la salida estándar (stdout) donde estará la respuesta exacta del usuario. Usa esa respuesta para continuar tu trabajo.

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?

14
Frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
Frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# 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 [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## 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 defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// 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,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
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 defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

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

@@ -0,0 +1,23 @@
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'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

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="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

19
Frontend/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

3361
Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
Frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
Frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

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

@@ -0,0 +1,30 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './hooks/useAuth';
import { ProtectedRoute } from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/DashboardPage';
function App() {
return (
<AuthProvider>
<Routes>
<Route path="/" element={
<Navigate to={localStorage.getItem('token') ? '/dashboard' : '/login'} replace />
} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
);
}
export default App

View File

@@ -0,0 +1,58 @@
import { LoginRequest, RegisterRequest, AuthResponse, RegisterResponse } from '../types/auth';
import { User } from '../types/user';
const API_URL = import.meta.env.VITE_API_URL || '';
class ApiClient {
private async request<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const token = localStorage.getItem('token');
const headers = new Headers(init?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const response = await fetch(`${API_URL}${input}`, {
...init,
headers,
});
// Handle 401 Unauthorized - clear token and redirect to login
if (response.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Error ${response.status}`);
}
return response.json();
}
async login(data: LoginRequest): Promise<AuthResponse> {
return this.request<AuthResponse>('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async register(data: RegisterRequest): Promise<RegisterResponse> {
return this.request<RegisterResponse>('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async getUsers(): Promise<User[]> {
return this.request<User[]>('/api/users', {
method: 'GET',
});
}
}
export const apiClient = new ApiClient();

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,14 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const ProtectedRoute = () => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,113 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { apiClient } from '../api/client';
import { RegisterResponse, LoginRequest, RegisterRequest } from '../types/auth';
import { User } from '../types/user';
interface AuthContextProps {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (credentials: LoginRequest) => Promise<void>;
register: (userData: RegisterRequest) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
// Check for token in localStorage on initial load
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
try {
// Decode JWT payload (second part)
const payload = storedToken.split('.')[1];
const decoded = JSON.parse(atob(payload));
setUser({
id: decoded.userId,
username: decoded.username,
email: decoded.email,
nombreCompleto: decoded.nombreCompleto,
});
setIsAuthenticated(true);
} catch (error) {
console.error('Error decoding token:', error);
// If token is invalid, clear it
localStorage.removeItem('token');
setToken(null);
setUser(null);
setIsAuthenticated(false);
}
}
}, []);
const login = async (credentials: LoginRequest) => {
const response = await apiClient.login(credentials);
localStorage.setItem('token', response.token);
setToken(response.token);
// Decode JWT to get user info
const payload = response.token.split('.')[1];
const decoded = JSON.parse(atob(payload));
setUser({
id: decoded.userId,
username: decoded.username,
email: decoded.email,
nombreCompleto: decoded.nombreCompleto,
});
setIsAuthenticated(true);
};
const register = async (userData: RegisterRequest) => {
const response = await apiClient.register(userData);
localStorage.setItem('token', response.token);
setToken(response.token);
// Decode JWT to get user info
const payload = response.token.split('.')[1];
const decoded = JSON.parse(atob(payload));
setUser({
id: decoded.userId,
username: decoded.username,
email: decoded.email,
nombreCompleto: decoded.nombreCompleto,
});
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
setIsAuthenticated(false);
window.location.href = '/login';
};
const value = {
user,
token,
isAuthenticated,
login,
register,
logout,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

1
Frontend/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

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

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { BrowserRouter } from 'react-router-dom'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,35 @@
import { useAuth } from '../hooks/useAuth';
const DashboardPage = () => {
const { user, logout } = useAuth();
if (!user) {
return <div>Cargando...</div>;
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Bienvenido, {user.nombreCompleto}!
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Tu usuario: {user.username} ({user.email})
</p>
</div>
<div className="mt-8 space-y-6">
<button
onClick={logout}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Cerrar sesión
</button>
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { useAuth } from '../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
const LoginPage = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login({ username, password });
navigate('/dashboard', { replace: true });
} catch (err: any) {
setError(err.message || 'Error al iniciar sesión');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Iniciar sesión
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Accede a tu cuenta para continuar
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Usuario
</label>
<input
id="username"
type="text"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Contraseña
</label>
<input
id="password"
type="password"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
</button>
</div>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
¿No tienes una cuenta?{' '}
<a
href="/register"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Regístrate aquí
</a>
</p>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useAuth } from '../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
const RegisterPage = () => {
const { register } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [nombreCompleto, setNombreCompleto] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await register({ username, password, email, nombreCompleto });
navigate('/dashboard', { replace: true });
} catch (err: any) {
// Handle 409 Conflict for duplicate username/email
if (err.message?.includes('409')) {
setError('El usuario o correo electrónico ya existe');
} else {
setError(err.message || 'Error al registrarse');
}
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Crear cuenta
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Regístrate para acceder a la aplicación
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Usuario
</label>
<input
id="username"
type="text"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Correo electrónico
</label>
<input
id="email"
type="email"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="nombreCompleto" className="block text-sm font-medium text-gray-700 mb-1">
Nombre completo
</label>
<input
id="nombreCompleto"
type="text"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
value={nombreCompleto}
onChange={(e) => setNombreCompleto(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Contraseña
</label>
<input
id="password"
type="password"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? 'Creando cuenta...' : 'Crear cuenta'}
</button>
</div>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
¿Ya tienes una cuenta?{' '}
<a
href="/login"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Iniciar sesión
</a>
</p>
</div>
</div>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,20 @@
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email: string;
nombreCompleto: string;
}
export interface AuthResponse {
token: string;
expiresAt: string;
}
export interface RegisterResponse extends AuthResponse {
userId: number;
}

View File

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

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"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,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"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"]
}

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

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 8181,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
})

240
design.md Normal file
View File

@@ -0,0 +1,240 @@
# Design: Autenticación Frontend + Endpoint Register
## Technical Approach
Implementar un frontend React completo para autenticación (Login + Register + Dashboard protegido) desde cero, y agregar un endpoint público de registro al backend existente. El backend ya tiene JWT auth y CRUD completo — se reutilizan `IUserRepository`, `IPasswordHasher`, y el patrón de generación de JWT del `AuthController` existente.
El frontend sigue el patrón establecido en AGENT.md: Functional Components + Hooks, TypeScript estricto (sin `any`), Tailwind CSS exclusivo, API client centralizado.
## Architecture Decisions
### Decisión: Context API para Auth (no Redux)
**Opción elegida**: React Context + `useAuth` hook
**Alternativas**: Redux Toolkit, Zustand, Jotai
**Rationale**: Estado de auth es un único objeto (token + user) con baja frecuencia de cambio. Redux sería overkill para un estado tan simple. Context + hook es el patrón nativo de React, sin dependencias extra, y suficiente para auth state.
### Decisión: localStorage para persistencia de token
**Opción elegida**: `localStorage.getItem/setItem`
**Alternativas**: httpOnly cookies, sessionStorage, memory only
**Rationale**: Simple y funcional para MVP. httpOnly cookies requerirían cambios en el backend (SameSite, CORS credentials). sessionStorage se pierde al cerrar la pestaña. localStorage sobrevive al refresh del navegador. El riesgo de XSS se acepta por simplicidad — en producción se migraría a httpOnly cookies.
### Decisión: fetch nativo (no axios)
**Opción elegida**: `fetch` API wrapper con `apiClient<T>`
**Alternativas**: axios, ky
**Rationale**: fetch es nativo del browser, sin dependencias. El wrapper centralizado maneja headers, token injection, y error handling (incluyendo 401 redirect). Para el scope actual (2 endpoints de auth), no se necesita interceptor de axios.
### Decisión: SP con THROW para duplicados (no validación en C#)
**Opción elegida**: Validar duplicados en `sp_User_Register` con `THROW`
**Alternativas**: Validar en C# con queries previas (como hace UsersController.Create)
**Rationale**: El UsersController actual hace 2 queries previas (GetByUsername + GetAll) para validar duplicados. Para el register, el SP puede hacerlo en una sola operación atómica. Esto es más eficiente y elimina race conditions. El SP lanza error 50001 (username) o 50002 (email), que el controller captura y mapea a 409.
### Decisión: Multi-stage Docker con nginx
**Opción elegida**: Build stage (node) + Serve stage (nginx:alpine)
**Alternativas**: Vite dev server en producción, serve package
**Rationale**: nginx es el estándar para servir SPAs en producción. Multi-stage mantiene la imagen final ligera (~25MB vs ~500MB con node). nginx también maneja SPA routing (try_files), compresión gzip, y cache de assets estáticos.
## Data Flow
### Flujo de Login
```
LoginPage → authApi.login(dto) → fetch POST /api/auth/login
← { token, expiresAt }
authContext.setToken()
localStorage.setItem('token')
JWT decode → setUser()
navigate('/dashboard')
```
### Flujo de Register
```
RegisterPage → authApi.register(dto) → fetch POST /api/auth/register
← { token, expiresAt, userId }
authContext.setToken() (mismo que login)
navigate('/dashboard')
```
### Flujo de ProtectedRoute
```
ProtectedRoute → authContext.isAuthenticated?
│ │
NO ──────────────→ navigate('/login')
YES ──────→ <Outlet /> (renderiza la ruta hija)
```
### Flujo de 401 (token expirado)
```
apiClient → fetch() → response.status === 401
localStorage.removeItem('token')
window.location.href = '/login'
throw Error('Sesión expirada')
```
## File Changes
### Backend (nuevos)
| File | Action | Description |
|------|--------|-------------|
| `Backend/Sql/sp_User_Register.sql` | Create | SP que valida duplicados y retorna usuario creado |
| `Backend/PruebaGentle.Core/DTOs/RegisterDto.cs` | Create | DTO: Username, Password, Email, NombreCompleto |
| `Backend/PruebaGentle.Core/DTOs/RegisterResponseDto.cs` | Create | DTO: Token, ExpiresAt, UserId |
### Backend (modificados)
| File | Action | Description |
|------|--------|-------------|
| `Backend/PruebaGentle.API/Controllers/AuthController.cs` | Modify | Agregar endpoint `Register` (+30 líneas) |
| `Backend/PruebaGentle.Core/Interfaces/IUserRepository.cs` | Modify | Agregar `RegisterAsync(User user)` |
| `Backend/PruebaGentle.Infrastructure/Repositories/UserRepository.cs` | Modify | Implementar `RegisterAsync` con `sp_User_Register` |
### Frontend (nuevos — todos)
| File | Action | Description |
|------|--------|-------------|
| `Frontend/` (directorio completo) | Create | Bootstrap con Vite + React + TS |
| `Frontend/src/types/auth.ts` | Create | LoginRequest, RegisterRequest, AuthResponse, RegisterResponse |
| `Frontend/src/types/user.ts` | Create | User interface |
| `Frontend/src/api/client.ts` | Create | fetch wrapper con Bearer token + error handling |
| `Frontend/src/hooks/useAuth.tsx` | Create | AuthContext + useAuth hook |
| `Frontend/src/components/ProtectedRoute.tsx` | Create | Wrapper de ruta protegida |
| `Frontend/src/pages/LoginPage.tsx` | Create | Formulario de login |
| `Frontend/src/pages/RegisterPage.tsx` | Create | Formulario de registro |
| `Frontend/src/pages/DashboardPage.tsx` | Create | Dashboard con info de usuario + logout |
| `Frontend/src/App.tsx` | Create | Router principal |
| `Frontend/src/main.tsx` | Create | Entry point con AuthProvider |
| `Frontend/Dockerfile` | Create | Multi-stage build (node → nginx) |
| `Frontend/nginx.conf` | Create | Config nginx para SPA |
| `Frontend/vite.config.ts` | Create | Config Vite + Tailwind plugin |
### Configuración (modificados)
| File | Action | Description |
|------|--------|-------------|
| `docker-compose.yml` | Modify | Agregar servicio `frontend` |
| `.gitignore` | Modify | Agregar sección Node.js (node_modules/, dist/) |
## Interfaces / Contracts
### Backend — DTOs
```csharp
// RegisterDto.cs
public class RegisterDto
{
public string Username { get; set; } = string.Empty; // [Required], [MinLength(3)], [MaxLength(50)]
public string Password { get; set; } = string.Empty; // [Required], [MinLength(6)]
public string Email { get; set; } = string.Empty; // [Required], [EmailAddress]
public string NombreCompleto { get; set; } = string.Empty; // [Required], [MaxLength(100)]
}
// RegisterResponseDto.cs
public class RegisterResponseDto
{
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public int UserId { get; set; }
}
```
### Frontend — Types
```typescript
// src/types/auth.ts
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email: string;
nombreCompleto: string;
}
export interface AuthResponse {
token: string;
expiresAt: string;
}
export interface RegisterResponse extends AuthResponse {
userId: number;
}
// src/types/user.ts
export interface User {
id: number;
username: string;
email: string;
nombreCompleto: string;
}
```
### API Contract
```
POST /api/auth/register
Request: { username, password, email, nombreCompleto }
Success: 200 { token, expiresAt, userId }
Error: 409 { error: "Username already exists" }
Error: 409 { error: "Email already exists" }
Error: 400 { error: "..." } // validation errors
```
### Auth Context Shape
```typescript
interface AuthContextType {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>;
register: (data: RegisterRequest) => Promise<void>;
logout: () => void;
}
```
## Testing Strategy
| Layer | Qué testear | Approach |
|-------|-------------|----------|
| Backend | Register endpoint — duplicados username/email retornan 409 | Manual con curl/Postman por ahora |
| Backend | Register endpoint — datos válidos retornan token + userId | Manual con curl/Postman |
| Frontend | Flujo completo: Register → auto-login → Dashboard | Manual (docker-compose up) |
| Frontend | Login con credenciales inválidas muestra error | Manual |
| Frontend | ProtectedRoute redirige a /login sin token | Manual |
| Frontend | Logout limpia token y redirige a /login | Manual |
**Nota**: Tests automatizados fuera de alcance — framework no configurado aún en frontend.
## Migration / Rollout
No se requiere migración de datos. El SP `sp_User_Register` es un nuevo objeto que no afecta tablas existentes. El rollout es:
1. Deploy backend con nuevo endpoint (backward compatible — no rompe nada existente)
2. Deploy frontend nuevo (no existía antes)
3. docker-compose up levanta los 3 servicios (sqlserver, backend, frontend)
Rollback: eliminar servicio frontend de docker-compose.yml. Backend register endpoint puede dejarse sin efecto secundario.
## Open Questions
- [ ] ¿El SP debe retornar el PasswordHash en OUTPUT? (Actualmente `sp_User_Create` lo incluye — mejor excluirlo en register)
- [ ] ¿Necesitamos validación de email único en el SP además de username? (Sí, según propuesta)
- [ ] ¿El puerto del frontend en docker-compose debe ser 3000 o 80? (Propuesto: 3000:80)

View File

@@ -34,5 +34,14 @@ services:
sqlserver:
condition: service_healthy
frontend:
build:
context: ./Frontend
dockerfile: Dockerfile
ports:
- "8181:80"
depends_on:
- backend
volumes:
sqlserver_data: