Compare commits

...

44 Commits

Author SHA1 Message Date
840ef44670 fix: add image names to docker-compose and update CI pipeline with login-action
All checks were successful
CI/CD Pipeline / backend-build (push) Successful in 2m3s
CI/CD Pipeline / frontend-build (push) Successful in 43s
CI/CD Pipeline / docker-build (push) Successful in 1m18s
2026-04-04 19:25:52 -03:00
42cb6e0d67 Fix: CI Version
All checks were successful
CI/CD Pipeline / backend-build (push) Successful in 2m14s
CI/CD Pipeline / frontend-build (push) Successful in 41s
CI/CD Pipeline / docker-build (push) Successful in 1m43s
2026-04-04 19:08:58 -03:00
6de2518a72 Fix: Label para Relacion Paquete Repositorio
All checks were successful
CI/CD Pipeline / backend-build (push) Successful in 2m20s
CI/CD Pipeline / frontend-build (push) Successful in 52s
CI/CD Pipeline / docker-build (push) Successful in 1m29s
2026-04-04 18:55:17 -03:00
6a4b16bb8b Retry CI Login con Usuario
All checks were successful
CI/CD Pipeline / backend-build (push) Successful in 2m10s
CI/CD Pipeline / frontend-build (push) Successful in 35s
CI/CD Pipeline / docker-build (push) Successful in 2m4s
2026-04-04 18:37:47 -03:00
f70ecaeeb5 Retry Ci Login
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m9s
CI/CD Pipeline / frontend-build (push) Successful in 40s
CI/CD Pipeline / docker-build (push) Failing after 48s
2026-04-04 18:29:00 -03:00
8459327d8b Retry ci Login
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m9s
CI/CD Pipeline / frontend-build (push) Successful in 45s
CI/CD Pipeline / docker-build (push) Failing after 16s
2026-04-04 18:24:29 -03:00
29b497468e Fix Login CI.yml
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m31s
CI/CD Pipeline / frontend-build (push) Successful in 1m17s
CI/CD Pipeline / docker-build (push) Failing after 18s
2026-04-04 18:14:57 -03:00
c33b186098 Repush 2 2026-04-03 21:03:19 -03:00
56e7b8b0a8 Repush 1 2026-04-03 21:00:15 -03:00
11638026d9 Repush 2026-04-03 20:57:38 -03:00
e3db9eb87f Fix: Login con Secret Token en CI 2026-04-03 20:55:50 -03:00
3a85201154 Fix: ci.yml con login previo a steps
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m6s
CI/CD Pipeline / frontend-build (push) Successful in 36s
CI/CD Pipeline / docker-build (push) Failing after 13s
2026-04-03 20:46:41 -03:00
865cf9b3e8 test ci.yml runner
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m18s
CI/CD Pipeline / frontend-build (push) Successful in 1m8s
CI/CD Pipeline / docker-build (push) Failing after 1m11s
2026-04-03 20:39:29 -03:00
eb5f04e5e2 docs: add README.md 2026-04-03 20:35:27 -03:00
ca15833526 chore: trigger pipeline with empty commit 2026-04-03 20:25:19 -03:00
67b56c1c9b fix: improve docker-build job with login, compose push, and correct image names 2026-04-03 20:23:13 -03:00
ed7586f950 fix: add DOCKER_API_VERSION env to job for API compatibility
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m15s
CI/CD Pipeline / frontend-build (push) Successful in 51s
CI/CD Pipeline / docker-build (push) Failing after 1m52s
2026-04-03 20:05:40 -03:00
361be0eb9e fix: use lowercase repo name and add image cleanup step
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m53s
CI/CD Pipeline / frontend-build (push) Successful in 1m20s
CI/CD Pipeline / docker-build (push) Failing after 2m0s
2026-04-03 19:42:16 -03:00
667b01c2a0 feat: push Docker images to Gitea registry after build
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m36s
CI/CD Pipeline / frontend-build (push) Successful in 54s
CI/CD Pipeline / docker-build (push) Failing after 2m3s
2026-04-03 19:34:42 -03:00
d3e77ad9a5 fix: correct paths in Backend Dockerfile for docker compose build context
All checks were successful
CI/CD Pipeline / backend-build (push) Successful in 2m15s
CI/CD Pipeline / frontend-build (push) Successful in 53s
CI/CD Pipeline / docker-build (push) Successful in 7m14s
The docker-compose.yml uses 'context: .' (root), so paths must include 'Backend/' prefix.
2026-04-03 19:15:29 -03:00
fd52352b21 fix: use docker label for build job (runner has docker-cli)
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m53s
CI/CD Pipeline / frontend-build (push) Successful in 1m19s
CI/CD Pipeline / docker-build (push) Failing after 39s
2026-04-01 21:25:44 -03:00
86a4b7cc1d fix: use docker compose v2 (plugin) in CI
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m58s
CI/CD Pipeline / frontend-build (push) Successful in 1m4s
CI/CD Pipeline / docker-build (push) Failing after 23s
2026-04-01 21:15:01 -03:00
5fed217818 fix: use docker build instead of docker-compose in CI
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m19s
CI/CD Pipeline / docker-build (push) Has been cancelled
CI/CD Pipeline / frontend-build (push) Has been cancelled
2026-04-01 21:11:18 -03:00
03c2cbf90b fix: install Docker CLI in CI step before docker-compose build
Some checks failed
CI/CD Pipeline / frontend-build (push) Has been cancelled
CI/CD Pipeline / backend-build (push) Has been cancelled
CI/CD Pipeline / docker-build (push) Has been cancelled
Each CI step runs in a separate container (node:18-bullseye).
Docker CLI must be installed within that container to use docker-compose.
2026-04-01 21:07:42 -03:00
2061ea5c0e revert: use docker-compose v1 in CI (runner now has docker-cli-compose)
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m14s
CI/CD Pipeline / frontend-build (push) Successful in 1m20s
CI/CD Pipeline / docker-build (push) Failing after 23s
2026-04-01 21:01:01 -03:00
8729de88a7 fix: use docker compose v2 instead of docker-compose in CI
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m4s
CI/CD Pipeline / frontend-build (push) Successful in 57s
CI/CD Pipeline / docker-build (push) Failing after 30s
docker-compose (v1) not available in gitea/act_runner container.
Docker Compose v2 is available as a plugin via 'docker compose'.
2026-04-01 20:31:41 -03:00
caf6d492ca fix: TypeScript build errors in frontend pipeline
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m41s
CI/CD Pipeline / frontend-build (push) Successful in 58s
CI/CD Pipeline / docker-build (push) Failing after 28s
- Remove unused ReactNode import in useAuth.test.tsx
- Use vitest/config for defineConfig to recognize test property
- Add vitest/globals to tsconfig types for test runner globals
2026-04-01 20:17:56 -03:00
653d3e7670 feat: agregar componente TestEcosistemaComponent para validación de ecosistema OpenCode 2026-04-01 19:14:34 -03:00
77c5f5419f chore: Simplify post-restart protocol - only Telegram when user is remote
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m9s
CI/CD Pipeline / frontend-build (push) Failing after 52s
CI/CD Pipeline / docker-build (push) Has been skipped
2026-04-01 18:43:29 -03:00
86e656d593 chore: Remove restart wrapper references, clean up protocol
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m32s
CI/CD Pipeline / frontend-build (push) Failing after 1m1s
CI/CD Pipeline / docker-build (push) Has been skipped
2026-04-01 18:33:53 -03:00
afac617b3f chore: Update restart protocol with wrapper flow
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m3s
CI/CD Pipeline / frontend-build (push) Failing after 50s
CI/CD Pipeline / docker-build (push) Has been skipped
2026-04-01 18:21:47 -03:00
1585339dee chore: Add post-restart remote notification protocol
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m10s
CI/CD Pipeline / frontend-build (push) Failing after 1m10s
CI/CD Pipeline / docker-build (push) Has been skipped
2026-04-01 17:21:12 -03:00
39469ad121 chore: Add Gitea labels, issue templates and PR template
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 3m37s
CI/CD Pipeline / frontend-build (push) Failing after 1m18s
CI/CD Pipeline / docker-build (push) Has been skipped
2026-04-01 16:45:02 -03:00
f8e5060278 test: Add Vitest + React Testing Library setup (Refs #2)
Some checks failed
CI/CD Pipeline / backend-build (push) Successful in 2m30s
CI/CD Pipeline / frontend-build (push) Failing after 2m0s
CI/CD Pipeline / docker-build (push) Has been skipped
2026-04-01 16:14:06 -03:00
d7481323f9 ci: Add Gitea Actions workflow for CI/CD (Refs #2)
Some checks failed
CI/CD Pipeline / frontend-build (push) Has been cancelled
CI/CD Pipeline / docker-build (push) Has been cancelled
CI/CD Pipeline / backend-build (push) Has been cancelled
2026-04-01 16:11:33 -03:00
c91ee30c1b chore: Environment improvements - delegation rules, GGA config, skill audit 2026-04-01 15:51:17 -03:00
552d1305f8 fix: Configure GGA to use AGENT.md instead of AGENTS.md 2026-04-01 15:50:15 -03:00
48aaaa798c fix: Update proxy target to backend port 5082 (Refs #2) 2026-04-01 15:10:15 -03:00
08cd32ba85 fix: Move AuthProvider to main.tsx to fix context error (Refs #2) 2026-04-01 14:58:27 -03:00
869cc66a2f Merge pull request 'feat: Sistema de autenticación frontend (Login + Register + Dashboard)' (#3) from feat/autenticacion-frontend into main
feat: Sistema de autenticación frontend (Login + Register + Dashboard)

Closes #2
2026-04-01 17:38:48 +00:00
4b44a8da08 fix: Remove unused import in ProtectedRoute (Refs #2) 2026-04-01 14:24:25 -03:00
7b9a7192c1 feat(authentication): implement frontend authentication system
- Created auth and user types (T10)
- Implemented API client with token handling (T11)
- Built AuthContext with JWT decoding (T12)
- Added ProtectedRoute component (T13)
- Created LoginPage, RegisterPage, DashboardPage (T14-T16)
- Updated App.tsx with routing and auth provider (T17)
- Added Dockerfile, nginx.conf for frontend deployment (T18-T19)
- Updated docker-compose.yml to include frontend service (T20)
- Updated .gitignore to exclude frontend build artifacts (T21)
- Removed unused App.css (T22)

Refs #2
2026-04-01 13:37:37 -03:00
bf2cfbd9fc feat: Add RegisterAsync endpoint to AuthController (Refs #2) 2026-04-01 13:15:22 -03:00
ab98056075 feat: Add Register DTOs and SP for public registration (Refs #2) 2026-04-01 13:08:51 -03:00
53 changed files with 6232 additions and 11 deletions

2
.gga
View File

@@ -34,7 +34,7 @@ EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.test.tsx,*.spec.tsx,*.d.ts"
# File containing code review rules
# Default: AGENTS.md
RULES_FILE="AGENTS.md"
RULES_FILE="AGENT.md"
# Strict mode: fail if AI response is ambiguous
# Default: true

View File

@@ -0,0 +1,29 @@
---
name: Bug Report
about: Reportar un bug o problema
title: "[BUG] "
labels: bug
---
## Descripción del Bug
Descripción clara del problema.
## Pasos para Reproducir
1. Ir a '...'
2. Hacer click en '...'
3. Ver error
## Comportamiento Esperado
Describir lo que debería pasar.
## Comportamiento Actual
Describir lo que realmente pasa.
## Screenshots
Si aplica, agregar screenshots.
## Entorno
- OS: [ej. Windows 11]
- Browser: [ej. Chrome 120]
- .NET Version: [ej. 10.0]
- Node Version: [ej. 22.x]

View File

@@ -0,0 +1,24 @@
---
name: Feature Request
about: Solicitar una nueva funcionalidad
title: "[FEATURE] "
labels: feature
---
## Descripción de la Feature
Descripción clara de lo que se quiere.
## Problema que Resuelve
¿Qué problema tiene el usuario actualmente?
## Solución Propuesta
Describir la solución deseada.
## Alternativas Consideradas
Otras soluciones que se consideraron.
## Área
- [ ] Backend
- [ ] Frontend
- [ ] DevOps
- [ ] Base de Datos

View File

@@ -0,0 +1,21 @@
---
name: Technical Task
about: Tarea técnica o refactor
title: "[TASK] "
labels: task
---
## Descripción
Descripción de la tarea técnica.
## Objetivo
Qué se quiere lograr.
## Alcance
- [ ] Archivos/carpetas afectados
- [ ] Dependencias
- [ ] Tests necesarios
## Criterios de Aceptación
- [ ] Criterio 1
- [ ] Criterio 2

View File

@@ -0,0 +1,21 @@
## Descripción
Breve descripción de los cambios.
## Tipo de Cambio
- [ ] Bug fix (change that fixes an issue)
- [ ] New feature (change that adds functionality)
- [ ] Breaking change (change that would cause existing functionality to not work as expected)
- [ ] Documentation update
## Issue Relacionada
Closes #
## Checklist
- [ ] Mi código sigue las convenciones del proyecto (AGENT.md)
- [ ] He hecho self-review de mi código
- [ ] He agregado tests que prueban mi cambio (si aplica)
- [ ] Los tests pasan localmente
- [ ] He actualizado la documentación (si aplica)
## Screenshots (si aplica)
Agregar screenshots de los cambios visuales.

88
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,88 @@
name: CI/CD Pipeline
# Triggers: push to main and pull requests to main
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
# Backend build job
backend-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.x
- name: Restore dependencies
run: dotnet restore Backend/PruebaGentle.slnx
- name: Build backend
run: dotnet build Backend/PruebaGentle.slnx --configuration Release
# Frontend build job
frontend-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Install dependencies
run: npm ci
working-directory: Frontend
- name: Build frontend
run: npm run build
working-directory: Frontend
- name: Run tests
run: npm run test:run
working-directory: Frontend
docker-build:
needs: [backend-build, frontend-build]
runs-on: docker
if: github.ref == 'refs/heads/main'
env:
DOCKER_API_VERSION: "1.41"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: repo.eldiaservicios.com
username: dmolinari
password: ${{ secrets.TOKEN_REGISTRY }}
- name: Build Docker images
run: docker compose build
- name: Tag versioned images
run: |
docker tag repo.eldiaservicios.com/dmolinari/pruebagentle/backend:latest repo.eldiaservicios.com/dmolinari/pruebagentle/backend:${{ github.run_number }}
docker tag repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:latest repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:${{ github.run_number }}
- name: Push to registry
run: |
docker compose push
docker push repo.eldiaservicios.com/dmolinari/pruebagentle/backend:${{ github.run_number }}
docker push repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:${{ github.run_number }}
- name: Clean up old images
run: |
docker images --format '{{.Repository}}:{{.Tag}}' | grep 'repo.eldiaservicios.com/dmolinari/pruebagentle' | tail -n +3 | xargs -r docker rmi || true

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

@@ -2,6 +2,33 @@
> **IMPORTANTE:** El idioma oficial de este proyecto es el ESPAÑOL. Todas las respuestas, explicaciones, comentarios de código y logs deben ser exclusivamente en español.
## 🚨 REGLA DE ORO: DELEGAR SIEMPRE
**NUNCA escribas código inline como orquestador.** Tu rol es COORDINAR, no ejecutar.
### Auto-pregunta ANTES de cada acción:
> *"¿Esto infla mi contexto sin necesidad?"*
> - Si SÍ → **DELEGA** a sub-agente
> - Si NO → hazlo inline
### DELEGAR (siempre):
- ❌ Escribir o editar CUALQUIER archivo de código
- ❌ Leer 4+ archivos para entender
- ❌ Ejecutar tests, builds, installs
- ❌ Crear branches, commits complejos
- ❌ Cualquier tarea de implementación
### INLINE (solo esto):
- ✅ git status / diff / log (solo lectura)
- ✅ Leer 1-3 archivos para DECIDIR (no para escribir)
- ✅ mem_search / mem_save
- ✅ Responder al usuario / hacer preguntas
### Anti-patrones que NUNCA debes repetir:
1. Leer archivos como "preparación" y luego editarlos → DELEGA la lectura + edición juntos
2. Escribir un fix "rápido" de 2 líneas → DELEGA igual
3. Ejecutar `npm run build` o `dotnet build` → DELEGA
Este proyecto utiliza C# (.NET Web API), React (TypeScript + Vite + Tailwind CSS), SQL Server, Docker y Gitea.
DEBES cumplir estrictamente estas reglas en TODAS las fases del SDD (Spec, Design, Apply, Verify).
@@ -50,4 +77,27 @@ 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.
### 🔄 Protocolo Post-Reinicio (OBLIGATORIO)
Si OpenCode se reinició:
1. **PRIMERA ACCIÓN**: Buscar en engram `mem_search(query: "remote-communication-protocol")`
2. **Si el usuario está EN EL CHAT** (mensajes directos en esta ventana):
- Continuar normalmente, NO enviar Telegram
3. **Si el usuario NO está** (último mensaje fue "salgo de la pc" o "modo remoto"):
- Ejecutar: `node E:/telegram-mcp/index.js "Reinicio completado. Estoy de vuelta y a la espera de tu mensaje."`
- Esperar respuesta antes de continuar

View File

@@ -4,12 +4,12 @@ EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY PruebaGentle.Core/PruebaGentle.Core.csproj PruebaGentle.Core/
COPY PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj PruebaGentle.Infrastructure/
COPY PruebaGentle.API/PruebaGentle.API.csproj PruebaGentle.API/
RUN dotnet restore PruebaGentle.API/PruebaGentle.API.csproj
COPY Backend/PruebaGentle.Core/PruebaGentle.Core.csproj Backend/PruebaGentle.Core/
COPY Backend/PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj Backend/PruebaGentle.Infrastructure/
COPY Backend/PruebaGentle.API/PruebaGentle.API.csproj Backend/PruebaGentle.API/
RUN dotnet restore Backend/PruebaGentle.API/PruebaGentle.API.csproj
COPY . .
WORKDIR /src/PruebaGentle.API
WORKDIR /src/Backend/PruebaGentle.API
RUN dotnet build -c Release -o /app/build
FROM build AS publish

View File

@@ -48,6 +48,40 @@ public class AuthController : ControllerBase
});
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
{
var user = new PruebaGentle.Core.Entities.User
{
Username = dto.Username,
PasswordHash = _passwordHasher.Hash(dto.Password),
Email = dto.Email,
NombreCompleto = dto.NombreCompleto
};
try
{
var created = await _userRepository.RegisterAsync(user);
var expiresAt = DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours);
var token = GenerateJwtToken(created.Id, created.Username, created.Email, expiresAt);
return Created(string.Empty, new RegisterResponseDto
{
Token = token,
ExpiresAt = expiresAt,
UserId = created.Id
});
}
catch (Microsoft.Data.SqlClient.SqlException ex) when (ex.Number == 50001)
{
return Conflict(new { error = "El nombre de usuario ya existe." });
}
catch (Microsoft.Data.SqlClient.SqlException ex) when (ex.Number == 50002)
{
return Conflict(new { error = "El email ya existe." });
}
}
private string GenerateJwtToken(int userId, string username, string email, DateTime expiresAt)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));

View File

@@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=PruebaGentle;User Id=sa;Password=YourStrong@Password123;TrustServerCertificate=true;"
"DefaultConnection": "Server=localhost;Database=Desarrollo;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=true;"
},
"JwtSettings": {
"Secret": "ThisIsA32CharacterLongSecretKey!!",

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace PruebaGentle.Core.DTOs;
public class RegisterDto
{
[Required]
[StringLength(50, MinimumLength = 3)]
public string Username { get; set; } = string.Empty;
[Required]
[StringLength(100, MinimumLength = 6)]
public string Password { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[StringLength(100)]
public string NombreCompleto { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace PruebaGentle.Core.DTOs;
public class RegisterResponseDto
{
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public int UserId { get; set; }
}

View File

@@ -10,4 +10,5 @@ public interface IUserRepository
Task<User?> UpdateAsync(User user);
Task<bool> DeleteAsync(int id);
Task<User?> GetByUsernameAsync(string username);
Task<User> RegisterAsync(User user);
}

View File

@@ -86,4 +86,21 @@ public class UserRepository : IUserRepository
new { Username = username },
commandType: CommandType.StoredProcedure);
}
public async Task<User> RegisterAsync(User user)
{
using var connection = CreateConnection();
var result = await connection.QuerySingleAsync<User>(
"sp_User_Register",
new
{
user.Username,
user.PasswordHash,
user.Email,
user.NombreCompleto
},
commandType: CommandType.StoredProcedure);
return result;
}
}

View File

@@ -0,0 +1,26 @@
CREATE OR ALTER PROCEDURE sp_User_Register
@Username NVARCHAR(50),
@PasswordHash NVARCHAR(255),
@Email NVARCHAR(100),
@NombreCompleto NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON;
-- Check if username already exists
IF EXISTS (SELECT 1 FROM Users WHERE Username = @Username)
BEGIN
THROW 50001, 'El nombre de usuario ya existe.', 1;
END
-- Check if email already exists
IF EXISTS (SELECT 1 FROM Users WHERE Email = @Email)
BEGIN
THROW 50002, 'El email ya existe.', 1;
END
-- Insert new user
INSERT INTO Users (Username, PasswordHash, Email, NombreCompleto)
OUTPUT INSERTED.Id, INSERTED.Username, INSERTED.Email, INSERTED.NombreCompleto, INSERTED.FechaCreacion
VALUES (@Username, @PasswordHash, @Email, @NombreCompleto);
END

167
Bitacora-CICD-Gitea.md Normal file
View File

@@ -0,0 +1,167 @@
# 📖 Bitácora de Configuración CI/CD en Gitea
## Fase 1: Configuración del Gitea Act-Runner
**Objetivo:** Levantar el contenedor que ejecutará los pipelines de Gitea (el "runner").
* **Problema 1:** El contenedor del runner no iniciaba y arrojaba el error `accepts at most 0 arg(s), received 1`.
* **Causa:** Un espacio en blanco después de la coma en la variable de entorno `GITEA_RUNNER_LABELS` que Bash interpretaba como un argumento adicional.
* **Solución:** Eliminar el espacio y encerrar toda la declaración entre comillas dobles en el `docker-compose.yml` de Gitea:
`- "GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:18-bullseye,docker:docker://repo.eldiaservicios.com/dmolinari/act-runner:latest"`
## Fase 2: Preparación del Proyecto y Docker Compose
**Objetivo:** Que el código fuente (Backend y Frontend) se construya correctamente dentro de Docker.
* **Problema 2:** Error al construir la imagen del backend: `ERROR: "/PruebaGentle.API.csproj" not found`.
* **Causa:** El "contexto" de construcción (`context`) estaba mal referenciado; Docker buscaba el archivo en la raíz cuando en realidad estaba dentro de la carpeta `Backend/`.
* **Solución:** Se ajustó el `docker-compose.yml` del proyecto definiendo explícitamente los contextos (`context: ./Backend` y `context: ./Frontend`).
* **Problema 3:** Gitea no mostraba los paquetes en la pestaña "Paquetes" del repositorio.
* **Causa:** Las imágenes no tenían metadatos que le dijeran a Gitea a qué repositorio pertenecían.
* **Solución:** Se agregaron etiquetas OCI (`labels`) al `docker-compose.yml` del proyecto.
* **Problema 4:** Docker Compose no sabía cómo llamar a las imágenes, dando errores al intentar etiquetarlas con `docker tag backend`.
* **Solución:** Declarar el nombre exacto de la imagen en la propiedad `image:` de cada servicio.
**✅ Resultado final de `docker-compose.yml` (Proyecto):**
```yaml
services:
backend:
image: repo.eldiaservicios.com/dmolinari/pruebagentle/backend:latest
build:
context: ./Backend
dockerfile: Dockerfile
labels:
- "org.opencontainers.image.source=https://repo.eldiaservicios.com/dmolinari/PruebaGentle"
frontend:
image: repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:latest
build:
context: ./Frontend
dockerfile: Dockerfile
labels:
- "org.opencontainers.image.source=https://repo.eldiaservicios.com/dmolinari/PruebaGentle"
```
## Fase 3: Solución de Problemas en el Pipeline (Errores de Gitea Actions)
**Objetivo:** Que el Action compile, inicie sesión y suba las imágenes a Gitea Registry.
* **Problema 5:** Error `client version 1.52 is too new. Maximum supported API version is 1.41`.
* **Causa:** El Docker Engine del servidor host es más antiguo que el cliente instalado en el runner. Las variables de entorno del `Dockerfile` se borraban al iniciar el job.
* **Solución:** Forzar la compatibilidad inyectando `env: DOCKER_API_VERSION: "1.41"` directamente a nivel del Job en el archivo `ci.yml`.
* **Problema 6:** Errores de Autenticación (`unauthorized`, `cannot perform an interactive login from a non TTY device` y `Password required`).
* **Causa:** Faltaba el paso para iniciar sesión en el Registry. Al intentar pasarlo por consola, si una variable estaba vacía, Docker pedía la clave por teclado, colgando el proceso (non TTY). Además, el token por defecto de Gitea no tenía permisos de escritura para paquetes.
* **Solución:**
1. Se generó un **Token de Acceso Personal (PAT)** desde el perfil del usuario con permisos de lectura/escritura en *Packages*.
2. Se guardó este token en **Configuración del Repositorio -> Acciones -> Secretos** bajo el nombre `TOKEN_REGISTRY`.
3. Se implementó la acción oficial `docker/login-action@v3` para manejar el login de forma segura.
* **Problema 7:** Las imágenes subidas sobreescribían el historial (todo era `latest`).
* **Causa:** La etiqueta `latest` reemplaza a la anterior sin guardar historial.
* **Solución:** Implementar doble etiquetado. Subir `:latest` (para producción) y subir `:${{ github.run_number }}` (para mantener un registro de versiones atado a la ejecución del Action).
## Fase 4: El Archivo Pipeline Final (`ci.yml`)
Este es el archivo definitivo, pulido y profesional que logró integrar todo el proceso:
```yaml
name: CI/CD Pipeline
# Triggers: push to main and pull requests to main
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
# Backend build job
backend-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.x
- name: Restore dependencies
run: dotnet restore Backend/PruebaGentle.slnx
- name: Build backend
run: dotnet build Backend/PruebaGentle.slnx --configuration Release
# Frontend build job
frontend-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Install dependencies
run: npm ci
working-directory: Frontend
- name: Build frontend
run: npm run build
working-directory: Frontend
- name: Run tests
run: npm run test:run
working-directory: Frontend
docker-build:
needs: [backend-build, frontend-build]
runs-on: docker
if: github.ref == 'refs/heads/main'
env:
DOCKER_API_VERSION: "1.41"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: repo.eldiaservicios.com
username: dmolinari
password: ${{ secrets.TOKEN_REGISTRY }}
- name: Build Docker images
run: docker compose build
- name: Tag versioned images
run: |
docker tag repo.eldiaservicios.com/dmolinari/pruebagentle/backend:latest repo.eldiaservicios.com/dmolinari/pruebagentle/backend:${{ github.run_number }}
docker tag repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:latest repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:${{ github.run_number }}
- name: Push to registry
run: |
docker compose push
docker push repo.eldiaservicios.com/dmolinari/pruebagentle/backend:${{ github.run_number }}
docker push repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:${{ github.run_number }}
- name: Clean up old images
run: |
docker images --format '{{.Repository}}:{{.Tag}}' | grep 'repo.eldiaservicios.com/dmolinari/pruebagentle' | tail -n +3 | xargs -r docker rmi || true
```
## Fase 5: Mantenimiento y Retención del Servidor
**Objetivo:** Evitar que el disco del servidor colapse por acumular cientos de versiones de imágenes.
* **Acción tomada:** Se configuró una regla de limpieza automática desde la cuenta de **Administrador del sitio** de Gitea (`/admin/packages`).
* **Regla implementada:**
* Tipo: `Container`
* Mantener el más reciente: `5 versiones por paquete` (Esto actúa como escudo de seguridad mínimo).
* Eliminar versiones anteriores a: `X días` (Para limpiar la basura antigua periódicamente).
* **Aclaración de Interfaz:** Se aprendió que Gitea agrupa los paquetes en la vista del repositorio (hay que hacer clic para ver las versiones), mientras que en la vista de administración muestra todas las etiquetas desagrupadas.
---

4
Frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local

1
Frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:5082/api

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;
}
}

4505
Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
Frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"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",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@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",
"jsdom": "^29.0.1",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1",
"vitest": "^4.1.2"
}
}

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 { useAuth } 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() {
const { isAuthenticated } = useAuth();
return (
<Routes>
<Route path="/" element={
<Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
</Routes>
);
}
export default App

View File

@@ -0,0 +1,60 @@
import type { LoginRequest, RegisterRequest, AuthResponse, RegisterResponse } from '../types/auth';
import type { 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(() => ({}));
// Handle both { error: "message" } and { message: "..." } formats
const errorMessage = errorData.error || errorData.message || `Error ${response.status}`;
throw new Error(errorMessage);
}
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,15 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import type { PropsWithChildren } from 'react';
const ProtectedRoute = ({ children }: PropsWithChildren<{}>) => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,21 @@
import React from 'react';
const TestEcosistemaComponent: React.FC = () => {
return (
<div>
<h2>Test Ecosistema OpenCode</h2>
<p>Componente creado para completar la fase apply del flujo SDD</p>
<ul>
<li>MCP engram: Funcional</li>
<li>MCP mssql: Sin conexión</li>
<li>MCP convention-checker: Funcional</li>
<li>MCP release-mcp: Funcional</li>
<li>MCP coordinator-mcp: Funcional</li>
<li>MCP context7: Funcional</li>
<li>MCP sdd-mcp: Parcial/Stub</li>
</ul>
</div>
);
};
export default TestEcosistemaComponent;

View File

@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import { AuthProvider } from '../useAuth';
import { useAuth } from '../useAuth';
// Test component that uses useAuth hook
const TestComponent = () => {
const auth = useAuth();
return <div data-testid="auth-user">{auth.user?.username ?? 'null'}</div>;
};
describe('useAuth hook', () => {
test('throws error when used outside AuthProvider', () => {
// Render component without AuthProvider wrapper
const renderTestComponent = () => render(<TestComponent />);
// Expect it to throw an error
expect(renderTestComponent).toThrow('useAuth must be used within an AuthProvider');
});
test('provides context through AuthProvider', () => {
// Render component with AuthProvider wrapper
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
// Should initially show null since no user is set
const authContextElement = screen.getByTestId('auth-user');
expect(authContextElement).toHaveTextContent('null');
});
});

View File

@@ -0,0 +1,114 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { apiClient } from '../api/client';
import type { LoginRequest, RegisterRequest } from '../types/auth';
import type { 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";

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

@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { BrowserRouter } from 'react-router-dom'
import { AuthProvider } from './hooks/useAuth'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</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,101 @@
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) {
if (err instanceof Error) {
setError(err.message || 'Error al iniciar sesión');
} else {
setError('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,134 @@
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) {
// Handle 409 Conflict for duplicate username/email
if (err instanceof Error && err.message?.includes('409')) {
setError('El usuario o correo electrónico ya existe');
} else if (err instanceof Error) {
setError(err.message || 'Error al registrarse');
} else {
setError('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,2 @@
// Setup file for Vitest with React Testing Library
import '@testing-library/jest-dom'

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", "vitest/globals"],
"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"]
}

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

@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config'
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:5082',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
globals: true,
},
})

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# PruebaGentle
Proyecto de prueba con backend .NET y frontend React.
## Tech Stack
- **Backend**: .NET 10 + Dapper + SQL Server
- **Frontend**: React 19 + Vite + Tailwind CSS
- **Database**: SQL Server (Docker)
- **CI/CD**: Gitea Actions
## Desarrollo Local
### Requisitos
- .NET 10 SDK
- Node.js 22+
- Docker Desktop
### Comandos
```bash
# Frontend
cd Frontend
npm install
npm run dev
# Backend
cd Backend
dotnet restore
dotnet run
# Docker (todo)
docker compose up -d
```
## Pipeline
El pipeline de CI/CD corre en Gitea Actions y construye:
1. Backend (.NET)
2. Frontend (React + Vite)
3. Imágenes Docker (push al registry de Gitea)

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

@@ -1,5 +1,3 @@
version: '3.8'
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
@@ -20,9 +18,13 @@ services:
start_period: 30s
backend:
# 1. AÑADIMOS EL NOMBRE DE LA IMAGEN AQUÍ
image: repo.eldiaservicios.com/dmolinari/pruebagentle/backend:latest
build:
context: .
dockerfile: Backend/Dockerfile
labels:
- "org.opencontainers.image.source=https://repo.eldiaservicios.com/dmolinari/PruebaGentle"
ports:
- "5000:8080"
environment:
@@ -34,5 +36,18 @@ services:
sqlserver:
condition: service_healthy
frontend:
# 2. AÑADIMOS EL NOMBRE DE LA IMAGEN AQUÍ
image: repo.eldiaservicios.com/dmolinari/pruebagentle/frontend:latest
build:
context: ./Frontend
dockerfile: Dockerfile
labels:
- "org.opencontainers.image.source=https://repo.eldiaservicios.com/dmolinari/PruebaGentle"
ports:
- "8181:80"
depends_on:
- backend
volumes:
sqlserver_data:
sqlserver_data: