Compare commits
44 Commits
21e7e7b044
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 840ef44670 | |||
| 42cb6e0d67 | |||
| 6de2518a72 | |||
| 6a4b16bb8b | |||
| f70ecaeeb5 | |||
| 8459327d8b | |||
| 29b497468e | |||
| c33b186098 | |||
| 56e7b8b0a8 | |||
| 11638026d9 | |||
| e3db9eb87f | |||
| 3a85201154 | |||
| 865cf9b3e8 | |||
| eb5f04e5e2 | |||
| ca15833526 | |||
| 67b56c1c9b | |||
| ed7586f950 | |||
| 361be0eb9e | |||
| 667b01c2a0 | |||
| d3e77ad9a5 | |||
| fd52352b21 | |||
| 86a4b7cc1d | |||
| 5fed217818 | |||
| 03c2cbf90b | |||
| 2061ea5c0e | |||
| 8729de88a7 | |||
| caf6d492ca | |||
| 653d3e7670 | |||
| 77c5f5419f | |||
| 86e656d593 | |||
| afac617b3f | |||
| 1585339dee | |||
| 39469ad121 | |||
| f8e5060278 | |||
| d7481323f9 | |||
| c91ee30c1b | |||
| 552d1305f8 | |||
| 48aaaa798c | |||
| 08cd32ba85 | |||
| 869cc66a2f | |||
| 4b44a8da08 | |||
| 7b9a7192c1 | |||
| bf2cfbd9fc | |||
| ab98056075 |
2
.gga
2
.gga
@@ -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
|
||||
|
||||
29
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
29
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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]
|
||||
24
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
24
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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
|
||||
21
.gitea/ISSUE_TEMPLATE/task.md
Normal file
21
.gitea/ISSUE_TEMPLATE/task.md
Normal 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
|
||||
21
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
21
.gitea/PULL_REQUEST_TEMPLATE.md
Normal 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
88
.gitea/workflows/ci.yml
Normal 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
5
.gitignore
vendored
@@ -20,5 +20,10 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
## Frontend
|
||||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
|
||||
## Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
50
AGENT.md
50
AGENT.md
@@ -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).
|
||||
|
||||
@@ -51,3 +78,26 @@ Sigue estrictamente la skill **"gitea-workflow"**:
|
||||
|
||||
## 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.
|
||||
|
||||
## 📱 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
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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!!",
|
||||
|
||||
22
Backend/PruebaGentle.Core/DTOs/RegisterDto.cs
Normal file
22
Backend/PruebaGentle.Core/DTOs/RegisterDto.cs
Normal 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;
|
||||
}
|
||||
8
Backend/PruebaGentle.Core/DTOs/RegisterResponseDto.cs
Normal file
8
Backend/PruebaGentle.Core/DTOs/RegisterResponseDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
26
Backend/Sql/sp_User_Register.sql
Normal file
26
Backend/Sql/sp_User_Register.sql
Normal 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
167
Bitacora-CICD-Gitea.md
Normal 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
4
Frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
1
Frontend/.env.example
Normal file
1
Frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:5082/api
|
||||
24
Frontend/.gitignore
vendored
Normal file
24
Frontend/.gitignore
vendored
Normal 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
14
Frontend/Dockerfile
Normal 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
73
Frontend/README.md
Normal 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
23
Frontend/eslint.config.js
Normal 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
13
Frontend/index.html
Normal 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
19
Frontend/nginx.conf
Normal 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
4505
Frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
Frontend/package.json
Normal file
40
Frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
Frontend/public/favicon.svg
Normal file
1
Frontend/public/favicon.svg
Normal file
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
24
Frontend/public/icons.svg
Normal 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
30
Frontend/src/App.tsx
Normal 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
|
||||
60
Frontend/src/api/client.ts
Normal file
60
Frontend/src/api/client.ts
Normal 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();
|
||||
BIN
Frontend/src/assets/hero.png
Normal file
BIN
Frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
Frontend/src/assets/react.svg
Normal file
1
Frontend/src/assets/react.svg
Normal 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 |
1
Frontend/src/assets/vite.svg
Normal file
1
Frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
15
Frontend/src/components/ProtectedRoute.tsx
Normal file
15
Frontend/src/components/ProtectedRoute.tsx
Normal 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;
|
||||
21
Frontend/src/components/TestEcosistemaComponent.tsx
Normal file
21
Frontend/src/components/TestEcosistemaComponent.tsx
Normal 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;
|
||||
32
Frontend/src/hooks/__tests__/useAuth.test.tsx
Normal file
32
Frontend/src/hooks/__tests__/useAuth.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
114
Frontend/src/hooks/useAuth.tsx
Normal file
114
Frontend/src/hooks/useAuth.tsx
Normal 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
1
Frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
16
Frontend/src/main.tsx
Normal file
16
Frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
35
Frontend/src/pages/DashboardPage.tsx
Normal file
35
Frontend/src/pages/DashboardPage.tsx
Normal 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;
|
||||
101
Frontend/src/pages/LoginPage.tsx
Normal file
101
Frontend/src/pages/LoginPage.tsx
Normal 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;
|
||||
134
Frontend/src/pages/RegisterPage.tsx
Normal file
134
Frontend/src/pages/RegisterPage.tsx
Normal 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;
|
||||
2
Frontend/src/test/setup.ts
Normal file
2
Frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Setup file for Vitest with React Testing Library
|
||||
import '@testing-library/jest-dom'
|
||||
20
Frontend/src/types/auth.ts
Normal file
20
Frontend/src/types/auth.ts
Normal 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;
|
||||
}
|
||||
6
Frontend/src/types/user.ts
Normal file
6
Frontend/src/types/user.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
nombreCompleto: string;
|
||||
}
|
||||
28
Frontend/tsconfig.app.json
Normal file
28
Frontend/tsconfig.app.json
Normal 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
7
Frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
Frontend/tsconfig.node.json
Normal file
26
Frontend/tsconfig.node.json
Normal 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
21
Frontend/vite.config.ts
Normal 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
41
README.md
Normal 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
240
design.md
Normal 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)
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user