From 31e2a34f79e6e55678529a92f2bcb2da1e9c4169 Mon Sep 17 00:00:00 2001 From: Antonio Rafael Nieto Mora Date: Thu, 9 Apr 2026 09:04:50 +0200 Subject: [PATCH 1/5] arreglar que se queda colgado --- backend/pom.xml | 4 ++ .../backend/controller/UserController.java | 8 +++ .../magicvs/backend/service/LoginService.java | 21 ++++++-- .../backend/service/RegistroService.java | 27 ++++++++-- .../magicvs/backend/util/ValidationUtils.java | 54 +++++++++++++++++++ frontend/src/app/features/login/login.html | 32 +++++++---- frontend/src/app/features/login/login.ts | 48 ++++++++++++----- .../src/app/features/registro/registro.ts | 23 +++++++- frontend/src/app/shared/validation.ts | 40 ++++++++++++++ 9 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java create mode 100644 frontend/src/app/shared/validation.ts diff --git a/backend/pom.xml b/backend/pom.xml index 21149d0..a37618a 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.security + spring-security-crypto + org.springframework.boot spring-boot-starter-webmvc diff --git a/backend/src/main/java/com/magicvs/backend/controller/UserController.java b/backend/src/main/java/com/magicvs/backend/controller/UserController.java index 22e3245..cc181f2 100644 --- a/backend/src/main/java/com/magicvs/backend/controller/UserController.java +++ b/backend/src/main/java/com/magicvs/backend/controller/UserController.java @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import java.util.Map; @RestController @RequestMapping("/api/users") @@ -26,6 +27,13 @@ public UserController(RegistroService registroService, LoginService loginService this.registroRepository = registroRepository; } + @GetMapping("/exists") + public ResponseEntity> exists(@RequestParam(name = "usernameOrEmail") String usernameOrEmail) { + String value = usernameOrEmail.trim(); + boolean exists = loginService.existsByUsernameOrEmail(value); + return ResponseEntity.ok(Map.of("exists", exists)); + } + // ---- Endpoints expuestos para Registro y Login ---- @PostMapping("/register") diff --git a/backend/src/main/java/com/magicvs/backend/service/LoginService.java b/backend/src/main/java/com/magicvs/backend/service/LoginService.java index a3d8049..b119d0f 100644 --- a/backend/src/main/java/com/magicvs/backend/service/LoginService.java +++ b/backend/src/main/java/com/magicvs/backend/service/LoginService.java @@ -5,6 +5,8 @@ import org.springframework.stereotype.Service; import java.util.Locale; +import com.magicvs.backend.util.ValidationUtils; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Service public class LoginService { @@ -18,15 +20,26 @@ public LoginService(LoginRepository loginRepository) { public User login(String usernameOrEmail, String password) { String value = usernameOrEmail.trim(); + if (!ValidationUtils.isUsernameOrEmail(value)) { + throw new IllegalArgumentException("Formato de usuario o email inválido"); + } + User user = loginRepository.findByUsername(value) .or(() -> loginRepository.findByEmail(value.toLowerCase(Locale.ROOT))) - .orElseThrow(() -> new IllegalArgumentException("Usuario o contraseña incorrectos")); + .orElseThrow(() -> new IllegalArgumentException("Credenciales incorrectas")); - // Comparación directa por simplicidad (recuerda encriptar en producción) - if (!user.getPasswordHash().equals(password)) { - throw new IllegalArgumentException("Usuario o contraseña incorrectos"); + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + if (!encoder.matches(password, user.getPasswordHash())) { + throw new IllegalArgumentException("Credenciales incorrectas"); } return user; } + + public boolean existsByUsernameOrEmail(String usernameOrEmail) { + String value = usernameOrEmail.trim(); + boolean byUsername = loginRepository.findByUsername(value).isPresent(); + boolean byEmail = loginRepository.findByEmail(value.toLowerCase(Locale.ROOT)).isPresent(); + return byUsername || byEmail; + } } diff --git a/backend/src/main/java/com/magicvs/backend/service/RegistroService.java b/backend/src/main/java/com/magicvs/backend/service/RegistroService.java index 6b9c8ca..a140e7a 100644 --- a/backend/src/main/java/com/magicvs/backend/service/RegistroService.java +++ b/backend/src/main/java/com/magicvs/backend/service/RegistroService.java @@ -6,6 +6,8 @@ import java.security.SecureRandom; import java.util.Locale; +import com.magicvs.backend.util.ValidationUtils; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Service public class RegistroService { @@ -20,9 +22,26 @@ public RegistroService(RegistroRepository registroRepository) { } public User registrar(String username, String email, String password, String displayName) { + if (username == null || email == null || password == null) { + throw new IllegalArgumentException("Faltan campos obligatorios"); + } + String normalizedUsername = username.trim(); String normalizedEmail = email.trim().toLowerCase(Locale.ROOT); + if (!ValidationUtils.isValidUsername(normalizedUsername)) { + throw new IllegalArgumentException("Nombre de usuario inválido. Solo letras, números, guion bajo y guion medio (3-30 caracteres)"); + } + if (!ValidationUtils.isValidEmail(normalizedEmail)) { + throw new IllegalArgumentException("Email con formato inválido"); + } + if (!ValidationUtils.isValidPassword(password)) { + throw new IllegalArgumentException("La contraseña debe tener entre 8 y 12 caracteres, al menos una mayúscula, un número y un símbolo"); + } + if (ValidationUtils.containsMaliciousPayload(normalizedUsername) || ValidationUtils.containsMaliciousPayload(normalizedEmail) || ValidationUtils.containsMaliciousPayload(displayName)) { + throw new IllegalArgumentException("Entrada sospechosa detectada"); + } + if (registroRepository.existsByUsername(normalizedUsername)) { throw new IllegalArgumentException("El nombre de usuario ya está en uso"); } @@ -33,9 +52,11 @@ public User registrar(String username, String email, String password, String dis User user = new User(); user.setUsername(normalizedUsername); user.setEmail(normalizedEmail); - // En un entorno real deberías encriptar la contraseña (BCrypt, etc.) - user.setPasswordHash(password); - user.setDisplayName(displayName != null && !displayName.isBlank() ? displayName : normalizedUsername); + // Hash password with BCrypt + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + user.setPasswordHash(encoder.encode(password)); + String safeDisplay = ValidationUtils.sanitizeDisplayName(displayName != null ? displayName : ""); + user.setDisplayName(!safeDisplay.isBlank() ? safeDisplay : normalizedUsername); // Generar friendTag tipo Discord (letras y números), único por usuario user.setFriendTag(generateFriendTag()); diff --git a/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java b/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java new file mode 100644 index 0000000..53d5198 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java @@ -0,0 +1,54 @@ +package com.magicvs.backend.util; + +import java.util.regex.Pattern; + +public final class ValidationUtils { + + private ValidationUtils() {} + + private static final Pattern USERNAME = Pattern.compile("^[A-Za-z0-9_-]{3,30}$"); + private static final Pattern EMAIL = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final Pattern PASSWORD = Pattern.compile("^(?=.{8,12}$)(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).*$"); + + public static boolean isValidUsername(String username) { + if (username == null) return false; + return USERNAME.matcher(username).matches(); + } + + public static boolean isValidEmail(String email) { + if (email == null) return false; + return EMAIL.matcher(email).matches(); + } + + public static boolean isValidPassword(String password) { + if (password == null) return false; + return PASSWORD.matcher(password).matches(); + } + + public static boolean isUsernameOrEmail(String s) { + return isValidUsername(s) || isValidEmail(s); + } + + public static String sanitizeDisplayName(String input) { + if (input == null) return null; + // Remove HTML tags + String stripped = input.replaceAll("<.*?>", ""); + // Allow letters, numbers, spaces, underscore, hyphen + String cleaned = stripped.replaceAll("[^\\p{L}0-9 _\\-]", ""); + // Trim and limit length + if (cleaned.length() > 50) { + cleaned = cleaned.substring(0, 50); + } + return cleaned.trim(); + } + + public static boolean containsMaliciousPayload(String s) { + if (s == null) return false; + String lower = s.toLowerCase(); + // quick checks for SQL/XSS-ish tokens + if (lower.contains("Iniciar sesión

Accede a tus mazos, historial de partidas y amigos.

-
+
@@ -22,18 +22,32 @@

Iniciar type="password" name="password" [(ngModel)]="password" + (ngModelChange)="onInputChange()" class="w-full h-10 rounded-md bg-surface-container/80 border border-outline-variant/40 px-3 text-sm text-zinc-100 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500" placeholder="••••••••" - required />

- -

{{ message }}

-

{{ error }}

-
+

+ {{ message }} +

+ - + \ No newline at end of file diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts index b46d59c..6a74b30 100644 --- a/frontend/src/app/features/login/login.ts +++ b/frontend/src/app/features/login/login.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; +import { isUsernameOrEmail } from '../../shared/validation'; @Component({ selector: 'app-login', @@ -15,39 +16,62 @@ export class Login { password = ''; message: string | null = null; error: string | null = null; + loading = false; private readonly apiUrl = 'http://localhost:8080/api/users/login'; - constructor(private http: HttpClient, private router: Router) {} + constructor( + private http: HttpClient, + private router: Router + ) {} onSubmit(): void { this.message = null; this.error = null; + const usernameOrEmailTrimmed = this.usernameOrEmail.trim(); + const passwordTrimmed = this.password.trim(); + + if (!isUsernameOrEmail(usernameOrEmailTrimmed)) { + this.error = 'Introduce un nombre de usuario válido o un correo válido.'; + return; + } + + if (!passwordTrimmed || passwordTrimmed.length < 8) { + this.error = 'Introduce tu contraseña'; + return; + } + + this.loading = true; + this.http .post(this.apiUrl, { - usernameOrEmail: this.usernameOrEmail, - password: this.password, + usernameOrEmail: usernameOrEmailTrimmed, + password: passwordTrimmed, }) .subscribe({ next: (user) => { - this.message = `Bienvenido, ${user.displayName ?? user.username}!`; - // Guardamos token y usuario en localStorage y navegamos al Home + this.loading = false; + if (user.token) { localStorage.setItem('token', user.token); } + localStorage.setItem('user', JSON.stringify(user)); this.router.navigateByUrl('/'); }, - error: (err: HttpErrorResponse) => { - if (err.status === 401 || err.status === 400) { - this.error = 'Usuario o contraseña incorrectos'; - } else { - this.error = 'Error al conectar con el servidor'; - } + error: (_err: HttpErrorResponse) => { + // Usuario no existe o contraseña incorrecta (u otro error): mensaje genérico + this.error = 'Credenciales incorrectas'; + this.loading = false; }, }); } + + onInputChange(): void { + this.error = null; + this.message = null; + } } interface UserResponse { @@ -59,4 +83,4 @@ interface UserResponse { token?: string; eloRating: number | null; friendsCount: number | null; -} +} \ No newline at end of file diff --git a/frontend/src/app/features/registro/registro.ts b/frontend/src/app/features/registro/registro.ts index 9327104..3597232 100644 --- a/frontend/src/app/features/registro/registro.ts +++ b/frontend/src/app/features/registro/registro.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; +import { isValidUsername, isValidEmail, isValidPassword, sanitizeDisplayName, containsMaliciousPayload } from '../../shared/validation'; @Component({ selector: 'app-registro', @@ -26,12 +27,32 @@ export class Registro { this.message = null; this.error = null; + // Frontend validation + if (!isValidUsername(this.username)) { + this.error = 'Usuario inválido. Solo letras, números, guion bajo o guion medio (3-30 caracteres).'; + return; + } + if (!isValidEmail(this.email)) { + this.error = 'Email con formato inválido.'; + return; + } + if (!isValidPassword(this.password)) { + this.error = 'La contraseña debe tener entre 8 y 12 caracteres, al menos una mayúscula, un número y un símbolo.'; + return; + } + if (containsMaliciousPayload(this.username) || containsMaliciousPayload(this.email) || containsMaliciousPayload(this.displayName)) { + this.error = 'Entrada sospechosa detectada.'; + return; + } + + const safeDisplay = sanitizeDisplayName(this.displayName); + this.http .post(this.apiUrl, { username: this.username, email: this.email, password: this.password, - displayName: this.displayName, + displayName: safeDisplay, }) .subscribe({ next: (user) => { diff --git a/frontend/src/app/shared/validation.ts b/frontend/src/app/shared/validation.ts new file mode 100644 index 0000000..e3d7b3d --- /dev/null +++ b/frontend/src/app/shared/validation.ts @@ -0,0 +1,40 @@ +export function isValidUsername(username: string): boolean { + if (!username) return false; + const re = /^[A-Za-z0-9_-]{3,30}$/; + return re.test(username); +} + +export function isValidEmail(email: string): boolean { + if (!email) return false; + const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; + return re.test(email); +} + +export function isUsernameOrEmail(value: string): boolean { + return isValidUsername(value) || isValidEmail(value); +} + +export function isValidPassword(password: string): boolean { + if (!password) return false; + // 8-12 chars, at least one uppercase, one digit and one symbol + const re = /^(?=.{8,12}$)(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).*$/; + return re.test(password); +} + +export function sanitizeDisplayName(input: string | null | undefined): string { + if (!input) return ''; + // remove tags + const stripped = input.replace(/<.*?>/g, ''); + // allow letters, numbers, spaces, underscore and hyphen + const cleaned = stripped.replace(/[^\p{L}0-9 _-]/gu, ''); + return cleaned.trim().slice(0, 50); +} + +export function containsMaliciousPayload(s: string | null | undefined): boolean { + if (!s) return false; + const lower = s.toLowerCase(); + if (lower.includes(' Date: Thu, 9 Apr 2026 17:17:52 +0200 Subject: [PATCH 2/5] ya va bien el login solo falta registro --- frontend/src/app/features/login/login.ts | 71 ++++++++++++++++++++---- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts index 6a74b30..7dffbda 100644 --- a/frontend/src/app/features/login/login.ts +++ b/frontend/src/app/features/login/login.ts @@ -2,6 +2,8 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { finalize, timeout } from 'rxjs/operators'; +import { ChangeDetectorRef, NgZone } from '@angular/core'; import { Router } from '@angular/router'; import { isUsernameOrEmail } from '../../shared/validation'; @@ -20,9 +22,12 @@ export class Login { private readonly apiUrl = 'http://localhost:8080/api/users/login'; + // inject ChangeDetectorRef and NgZone to force UI updates if needed constructor( private http: HttpClient, - private router: Router + private router: Router, + private cdr: ChangeDetectorRef, + private ngZone: NgZone ) {} onSubmit(): void { @@ -43,27 +48,73 @@ export class Login { } this.loading = true; - this.http .post(this.apiUrl, { usernameOrEmail: usernameOrEmailTrimmed, password: passwordTrimmed, }) + .pipe( + // evita que la petición quede pendiente indefinidamente + timeout(10000), + // asegura que loading vuelva a false siempre (dentro de NgZone) + finalize(() => { + this.ngZone.run(() => { + this.loading = false; + this.cdr.detectChanges(); + }); + }) + ) .subscribe({ next: (user) => { - this.loading = false; - - if (user.token) { - localStorage.setItem('token', user.token); + // backend must return a token on successful auth + if (!user || !user.token) { + // treat missing token as invalid credentials + this.ngZone.run(() => { + this.error = 'Credenciales incorrectas'; + this.cdr.detectChanges(); + }); + return; } + localStorage.setItem('token', user.token); localStorage.setItem('user', JSON.stringify(user)); - this.router.navigateByUrl('/'); + + this.ngZone.run(() => this.router.navigateByUrl('/')); }, - error: (_err: HttpErrorResponse) => { + error: (err: any) => { + if (err && err.name === 'TimeoutError') { + this.ngZone.run(() => { + this.error = 'Tiempo de espera agotado. Intenta de nuevo.'; + this.cdr.detectChanges(); + }); + return; + } + // Usuario no existe o contraseña incorrecta (u otro error): mensaje genérico - this.error = 'Credenciales incorrectas'; - this.loading = false; + if (err instanceof HttpErrorResponse) { + if (err.status === 0) { + this.ngZone.run(() => { + this.error = 'No se ha podido conectar con el servidor. Comprueba que el backend está en ejecución.'; + this.cdr.detectChanges(); + }); + } else if (err.status >= 400 && err.status < 500) { + // map all client errors to invalid credentials + this.ngZone.run(() => { + this.error = 'Credenciales incorrectas'; + this.cdr.detectChanges(); + }); + } else { + this.ngZone.run(() => { + this.error = 'Error del servidor. Intenta más tarde.'; + this.cdr.detectChanges(); + }); + } + } else { + this.ngZone.run(() => { + this.error = 'Error de red. Comprueba tu conexión.'; + this.cdr.detectChanges(); + }); + } }, }); } From aa599cdf5cb2929339202e5a21af37a313cfea0e Mon Sep 17 00:00:00 2001 From: Antonio Rafael Nieto Mora Date: Thu, 9 Apr 2026 18:17:15 +0200 Subject: [PATCH 3/5] gucci --- .../backend/service/RegistroService.java | 7 ++ .../magicvs/backend/util/ValidationUtils.java | 9 ++- frontend/src/app/features/login/login.html | 4 +- frontend/src/app/features/login/login.ts | 7 +- .../src/app/features/registro/registro.html | 13 +++- .../src/app/features/registro/registro.ts | 75 +++++++++++++++---- .../app/layouts/main-layout/main-layout.html | 8 +- .../app/layouts/main-layout/main-layout.scss | 10 +-- .../app/layouts/main-layout/main-layout.ts | 11 ++- frontend/src/app/shared/validation.ts | 10 ++- 10 files changed, 117 insertions(+), 37 deletions(-) diff --git a/backend/src/main/java/com/magicvs/backend/service/RegistroService.java b/backend/src/main/java/com/magicvs/backend/service/RegistroService.java index a140e7a..b8f2288 100644 --- a/backend/src/main/java/com/magicvs/backend/service/RegistroService.java +++ b/backend/src/main/java/com/magicvs/backend/service/RegistroService.java @@ -42,6 +42,13 @@ public User registrar(String username, String email, String password, String dis throw new IllegalArgumentException("Entrada sospechosa detectada"); } + // Validate displayName (apodo): disallow spaces and special characters + if (displayName != null && !displayName.isBlank()) { + if (!ValidationUtils.isValidDisplayName(displayName)) { + throw new IllegalArgumentException("Nombre visible inválido. El apodo no puede contener espacios ni caracteres especiales"); + } + } + if (registroRepository.existsByUsername(normalizedUsername)) { throw new IllegalArgumentException("El nombre de usuario ya está en uso"); } diff --git a/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java b/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java index 53d5198..f4775e3 100644 --- a/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java +++ b/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java @@ -6,9 +6,11 @@ public final class ValidationUtils { private ValidationUtils() {} - private static final Pattern USERNAME = Pattern.compile("^[A-Za-z0-9_-]{3,30}$"); + // allow letters, numbers, spaces, underscore and hyphen (3-30 chars) + private static final Pattern USERNAME = Pattern.compile("^[A-Za-z0-9 _-]{3,30}$"); private static final Pattern EMAIL = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final Pattern PASSWORD = Pattern.compile("^(?=.{8,12}$)(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).*$"); + private static final Pattern DISPLAYNAME = Pattern.compile("^[\\p{L}0-9_-]{1,50}$"); public static boolean isValidUsername(String username) { if (username == null) return false; @@ -29,6 +31,11 @@ public static boolean isUsernameOrEmail(String s) { return isValidUsername(s) || isValidEmail(s); } + public static boolean isValidDisplayName(String name) { + if (name == null) return false; + return DISPLAYNAME.matcher(name).matches(); + } + public static String sanitizeDisplayName(String input) { if (input == null) return null; // Remove HTML tags diff --git a/frontend/src/app/features/login/login.html b/frontend/src/app/features/login/login.html index d52035f..324f5de 100644 --- a/frontend/src/app/features/login/login.html +++ b/frontend/src/app/features/login/login.html @@ -5,14 +5,14 @@

Iniciar
- +
diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts index 7dffbda..35fa4c1 100644 --- a/frontend/src/app/features/login/login.ts +++ b/frontend/src/app/features/login/login.ts @@ -5,7 +5,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { finalize, timeout } from 'rxjs/operators'; import { ChangeDetectorRef, NgZone } from '@angular/core'; import { Router } from '@angular/router'; -import { isUsernameOrEmail } from '../../shared/validation'; +import { isValidEmail } from '../../shared/validation'; @Component({ selector: 'app-login', @@ -37,8 +37,9 @@ export class Login { const usernameOrEmailTrimmed = this.usernameOrEmail.trim(); const passwordTrimmed = this.password.trim(); - if (!isUsernameOrEmail(usernameOrEmailTrimmed)) { - this.error = 'Introduce un nombre de usuario válido o un correo válido.'; + // Only allow login via email (not username) + if (!isValidEmail(usernameOrEmailTrimmed)) { + this.error = 'Introduce un correo válido.'; return; } diff --git a/frontend/src/app/features/registro/registro.html b/frontend/src/app/features/registro/registro.html index 89900cb..bae132b 100644 --- a/frontend/src/app/features/registro/registro.html +++ b/frontend/src/app/features/registro/registro.html @@ -10,6 +10,7 @@

Crear c type="text" name="username" [(ngModel)]="username" + (input)="onInputChange()" class="w-full h-10 rounded-md bg-surface-container/80 border border-outline-variant/40 px-3 text-sm text-zinc-100 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500" placeholder="Nombre de usuario" required @@ -22,8 +23,9 @@

Crear c type="email" name="email" [(ngModel)]="email" + (input)="onInputChange()" class="w-full h-10 rounded-md bg-surface-container/80 border border-outline-variant/40 px-3 text-sm text-zinc-100 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500" - placeholder="tu@email.com" + placeholder="magicvs@gmail.com" required />

@@ -34,6 +36,7 @@

Crear c type="password" name="password" [(ngModel)]="password" + (input)="onInputChange()" class="w-full h-10 rounded-md bg-surface-container/80 border border-outline-variant/40 px-3 text-sm text-zinc-100 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500" placeholder="••••••••" required @@ -46,13 +49,15 @@

Crear c type="text" name="displayName" [(ngModel)]="displayName" + (input)="onInputChange()" class="w-full h-10 rounded-md bg-surface-container/80 border border-outline-variant/40 px-3 text-sm text-zinc-100 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500" - placeholder="Tu nombre para mostrar" + placeholder="Magicvs the Great" /> -

{{ message }}

diff --git a/frontend/src/app/features/registro/registro.ts b/frontend/src/app/features/registro/registro.ts index 3597232..7bfc892 100644 --- a/frontend/src/app/features/registro/registro.ts +++ b/frontend/src/app/features/registro/registro.ts @@ -3,7 +3,9 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; -import { isValidUsername, isValidEmail, isValidPassword, sanitizeDisplayName, containsMaliciousPayload } from '../../shared/validation'; +import { finalize } from 'rxjs/operators'; +import { ChangeDetectorRef, NgZone } from '@angular/core'; +import { isValidUsername, isValidEmail, isValidPassword, sanitizeDisplayName, containsMaliciousPayload, isValidDisplayName } from '../../shared/validation'; @Component({ selector: 'app-registro', @@ -18,10 +20,11 @@ export class Registro { displayName = ''; message: string | null = null; error: string | null = null; + loading = false; private readonly apiUrl = 'http://localhost:8080/api/users/register'; - constructor(private http: HttpClient, private router: Router) {} + constructor(private http: HttpClient, private router: Router, private cdr: ChangeDetectorRef, private ngZone: NgZone) {} onSubmit(): void { this.message = null; @@ -29,7 +32,7 @@ export class Registro { // Frontend validation if (!isValidUsername(this.username)) { - this.error = 'Usuario inválido. Solo letras, números, guion bajo o guion medio (3-30 caracteres).'; + this.error = 'Usuario inválido. Solo letras, números, espacios, guion bajo o guion medio (3-30 caracteres).'; return; } if (!isValidEmail(this.email)) { @@ -47,6 +50,13 @@ export class Registro { const safeDisplay = sanitizeDisplayName(this.displayName); + // validate displayName (apodo) - do not allow spaces + if (safeDisplay && !isValidDisplayName(safeDisplay)) { + this.error = 'Nombre visible inválido. El apodo no puede contener espacios ni caracteres especiales.'; + return; + } + + this.loading = true; this.http .post(this.apiUrl, { username: this.username, @@ -54,24 +64,61 @@ export class Registro { password: this.password, displayName: safeDisplay, }) + .pipe( + finalize(() => { + this.ngZone.run(() => { + this.loading = false; + this.cdr.detectChanges(); + }); + }) + ) .subscribe({ next: (user) => { - this.message = `Cuenta creada. Bienvenido, ${user.displayName ?? user.username}!`; - if (user.token) { - localStorage.setItem('token', user.token); - } - localStorage.setItem('user', JSON.stringify(user)); - this.router.navigateByUrl('/'); + this.ngZone.run(() => { + this.message = `Cuenta creada. Bienvenido, ${user.displayName ?? user.username}!`; + if (user.token) { + localStorage.setItem('token', user.token); + } + localStorage.setItem('user', JSON.stringify(user)); + this.cdr.detectChanges(); + this.router.navigateByUrl('/'); + }); }, error: (err: HttpErrorResponse) => { - if (err.status === 400) { - this.error = err.error?.message ?? 'Datos inválidos o ya existentes'; - } else { - this.error = 'Error al conectar con el servidor'; - } + this.ngZone.run(() => { + // Prefer backend message when available + const backendMsg = err.error?.message || err.error?.error || err.error?.detail || null; + if (err.status === 400) { + if (backendMsg) { + // map some known backend messages to friendlier texts + const lower = backendMsg.toLowerCase(); + if (lower.includes('email') && lower.includes('en uso')) { + this.error = 'El correo ya está registrado.'; + } else if (lower.includes('nombre de usuario') && lower.includes('en uso')) { + this.error = 'El nombre de usuario ya está en uso.'; + } else { + this.error = backendMsg; + } + } else { + this.error = 'Datos inválidos o ya existentes'; + } + } else if (err.status === 409) { + this.error = backendMsg ?? 'Recurso en conflicto'; + } else if (err.status === 0) { + this.error = 'No se ha podido conectar con el servidor'; + } else { + this.error = 'Error al conectar con el servidor'; + } + this.cdr.detectChanges(); + }); }, }); } + + onInputChange(): void { + this.error = null; + this.message = null; + } } interface UserResponse { diff --git a/frontend/src/app/layouts/main-layout/main-layout.html b/frontend/src/app/layouts/main-layout/main-layout.html index 2f4ca3c..c884b2d 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.html +++ b/frontend/src/app/layouts/main-layout/main-layout.html @@ -49,9 +49,8 @@ Cerrar sesión -
- {{ displayName }} - #{{ friendTag }} +
+ {{ displayName }}#{{ friendTag }}
@@ -62,8 +61,7 @@
-
{{ displayName }}
-
#{{ friendTag }}
+ {{ displayName }}#{{ friendTag }}
account_circle diff --git a/frontend/src/app/layouts/main-layout/main-layout.scss b/frontend/src/app/layouts/main-layout/main-layout.scss index e8ab2e1..f0bd0f6 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.scss +++ b/frontend/src/app/layouts/main-layout/main-layout.scss @@ -32,12 +32,12 @@ button.inline-flex, a.inline-flex, button.p-2, .p-2 { flex:0 1 auto; overflow:hidden; margin: 0; - display:flex; - align-items:center; - gap:8px; /* small gap between name and tag */ + display:inline-flex !important; + align-items:baseline; + gap:0 !important; /* remove gap between name and tag */ } -.user-block .display-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;font-size:14px;line-height:1;padding-top:0} -.user-block .friend-tag{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;opacity:0.8;font-size:12px;margin-top:0} +.user-block .display-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;font-size:14px;line-height:1;padding-top:0;margin:0} +.user-block .friend-tag{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;opacity:0.8;font-size:12px;margin:0;padding-left:6px} /* ensure logout and icon keep their natural size and don't shrink */ .logged-in > button{flex:0 0 auto} .logged-in > button.p-2{margin-left:0} diff --git a/frontend/src/app/layouts/main-layout/main-layout.ts b/frontend/src/app/layouts/main-layout/main-layout.ts index bdd4e20..d904d08 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.ts +++ b/frontend/src/app/layouts/main-layout/main-layout.ts @@ -57,8 +57,15 @@ export class MainLayout { } try { const u = JSON.parse(raw) as StoredUser; - this.displayName = u.displayName ?? u.username; - this.friendTag = u.friendTag ?? null; + // normalize and trim displayName to avoid accidental trailing spaces + const rawName = (u.displayName ?? u.username) as string | undefined; + if (rawName) { + const cleaned = rawName.replace(/\u00A0/g, ' ').trim(); + this.displayName = cleaned || (u.username ?? null); + } else { + this.displayName = u.username ?? null; + } + this.friendTag = (u.friendTag ?? null)?.toString() ?? null; } catch { this.displayName = null; this.friendTag = null; diff --git a/frontend/src/app/shared/validation.ts b/frontend/src/app/shared/validation.ts index e3d7b3d..4ca9dc7 100644 --- a/frontend/src/app/shared/validation.ts +++ b/frontend/src/app/shared/validation.ts @@ -1,6 +1,7 @@ export function isValidUsername(username: string): boolean { if (!username) return false; - const re = /^[A-Za-z0-9_-]{3,30}$/; + // allow letters, numbers, spaces, underscore and hyphen (3-30 chars) + const re = /^[A-Za-z0-9 _-]{3,30}$/; return re.test(username); } @@ -29,6 +30,13 @@ export function sanitizeDisplayName(input: string | null | undefined): string { const cleaned = stripped.replace(/[^\p{L}0-9 _-]/gu, ''); return cleaned.trim().slice(0, 50); } +export function isValidDisplayName(name: string | null | undefined): boolean { + if (!name) return false; + // disallow spaces in display name (apodo). Allow letters, numbers, underscore and hyphen. + // use unicode letter class for international names + const re = /^[\p{L}0-9_-]{1,50}$/u; + return re.test(name); +} export function containsMaliciousPayload(s: string | null | undefined): boolean { if (!s) return false; From ec3dde9593aca536f6879922df337636a5ff5e4a Mon Sep 17 00:00:00 2001 From: Antonio Rafael Nieto Mora Date: Thu, 9 Apr 2026 19:06:04 +0200 Subject: [PATCH 4/5] todo con los control flows --- .../backend/controller/UserController.java | 8 +- frontend/src/app/features/login/login.html | 23 +-- .../src/app/features/registro/registro.html | 46 ++++- .../app/layouts/main-layout/main-layout.html | 161 +++++++++++------- 4 files changed, 154 insertions(+), 84 deletions(-) diff --git a/backend/src/main/java/com/magicvs/backend/controller/UserController.java b/backend/src/main/java/com/magicvs/backend/controller/UserController.java index cc181f2..b16431d 100644 --- a/backend/src/main/java/com/magicvs/backend/controller/UserController.java +++ b/backend/src/main/java/com/magicvs/backend/controller/UserController.java @@ -37,7 +37,7 @@ public ResponseEntity> exists(@RequestParam(name = "usernam // ---- Endpoints expuestos para Registro y Login ---- @PostMapping("/register") - public ResponseEntity register(@RequestBody RegistroRequest request) { + public ResponseEntity register(@RequestBody RegistroRequest request) { try { User user = registroService.registrar(request.username, request.email, request.password, request.displayName); String token = authService.createSession(user.getId()); @@ -45,12 +45,12 @@ public ResponseEntity register(@RequestBody RegistroRequest reques resp.token = token; return ResponseEntity.status(HttpStatus.CREATED).body(resp); } catch (IllegalArgumentException ex) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(java.util.Map.of("message", ex.getMessage())); } } @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { + public ResponseEntity login(@RequestBody LoginRequest request) { try { User user = loginService.login(request.usernameOrEmail, request.password); String token = authService.createSession(user.getId()); @@ -58,7 +58,7 @@ public ResponseEntity login(@RequestBody LoginRequest request) { resp.token = token; return ResponseEntity.ok(resp); } catch (IllegalArgumentException ex) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(java.util.Map.of("message", ex.getMessage())); } } diff --git a/frontend/src/app/features/login/login.html b/frontend/src/app/features/login/login.html index 324f5de..8036c44 100644 --- a/frontend/src/app/features/login/login.html +++ b/frontend/src/app/features/login/login.html @@ -28,13 +28,14 @@

Iniciar />

-
- {{ error }} -
+ @if (error) { +
+ {{ error }} +
+ } -

- {{ message }} -

+ @if (message) { +

+ {{ message }} +

+ }
\ No newline at end of file diff --git a/frontend/src/app/features/registro/registro.html b/frontend/src/app/features/registro/registro.html index bae132b..c995734 100644 --- a/frontend/src/app/features/registro/registro.html +++ b/frontend/src/app/features/registro/registro.html @@ -1,9 +1,14 @@
+

Crear cuenta

-

Regístrate para jugar combates 1vs1 y gestionar tus mazos.

+

+ Regístrate para jugar combates 1vs1 y gestionar tus mazos. +

+ +
Crear c />
+
Crear c />
+
Crear c />
+
- + Crear c />
- -

{{ message }}

-

{{ error }}

+ + @if (message) { +

+ {{ message }} +

+ } + + + @if (error) { +

+ {{ error }} +

+ } +
-
+ \ No newline at end of file diff --git a/frontend/src/app/layouts/main-layout/main-layout.html b/frontend/src/app/layouts/main-layout/main-layout.html index c884b2d..775e68f 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.html +++ b/frontend/src/app/layouts/main-layout/main-layout.html @@ -1,73 +1,107 @@
@@ -82,10 +116,13 @@

Proyecto universitario (DRA)

+ - + \ No newline at end of file From 172a089bc998256ff9901d1d6f53b43cbabb5985 Mon Sep 17 00:00:00 2001 From: Antonio Rafael Nieto Mora Date: Sat, 11 Apr 2026 12:51:56 +0200 Subject: [PATCH 5/5] commit --- backend/pom.xml | 5 + .../backend/controller/UserController.java | 38 ++- .../backend/model/PendingRegistration.java | 109 ++++++++ .../PendingRegistrationRepository.java | 9 + .../RegistrationVerificationService.java | 240 ++++++++++++++++++ .../src/main/resources/application.properties | 12 +- docker-compose.yml | 5 +- frontend/src/app/app.routes.ts | 4 +- .../src/app/features/registro/registro.html | 7 +- .../src/app/features/registro/registro.ts | 17 +- .../features/verification/verification.html | 65 +++++ .../app/features/verification/verification.ts | 91 +++++++ scripts/send_email_test.py | 29 +++ 13 files changed, 615 insertions(+), 16 deletions(-) create mode 100644 backend/src/main/java/com/magicvs/backend/model/PendingRegistration.java create mode 100644 backend/src/main/java/com/magicvs/backend/repository/PendingRegistrationRepository.java create mode 100644 backend/src/main/java/com/magicvs/backend/service/RegistrationVerificationService.java create mode 100644 frontend/src/app/features/verification/verification.html create mode 100644 frontend/src/app/features/verification/verification.ts create mode 100644 scripts/send_email_test.py diff --git a/backend/pom.xml b/backend/pom.xml index a37618a..1142208 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -51,6 +51,11 @@ jackson-databind + + org.springframework.boot + spring-boot-starter-mail + + org.postgresql postgresql diff --git a/backend/src/main/java/com/magicvs/backend/controller/UserController.java b/backend/src/main/java/com/magicvs/backend/controller/UserController.java index b16431d..cfdf93e 100644 --- a/backend/src/main/java/com/magicvs/backend/controller/UserController.java +++ b/backend/src/main/java/com/magicvs/backend/controller/UserController.java @@ -19,12 +19,14 @@ public class UserController { private final LoginService loginService; private final AuthService authService; private final RegistroRepository registroRepository; + private final com.magicvs.backend.service.RegistrationVerificationService verificationService; - public UserController(RegistroService registroService, LoginService loginService, AuthService authService, RegistroRepository registroRepository) { + public UserController(RegistroService registroService, LoginService loginService, AuthService authService, RegistroRepository registroRepository, com.magicvs.backend.service.RegistrationVerificationService verificationService) { this.registroService = registroService; this.loginService = loginService; this.authService = authService; this.registroRepository = registroRepository; + this.verificationService = verificationService; } @GetMapping("/exists") @@ -62,6 +64,35 @@ public ResponseEntity login(@RequestBody LoginRequest request) { } } + @PostMapping("/register/initiate") + public ResponseEntity initiate(@RequestBody RegistroRequest request) { + try { + var pending = verificationService.initiate(request.username, request.email, request.password, request.displayName); + return ResponseEntity.ok(java.util.Map.of("pendingId", pending.getId())); + } catch (IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(java.util.Map.of("message", ex.getMessage())); + } catch (Exception ex) { + String msg = ex.getMessage() != null ? ex.getMessage() : "Error interno al iniciar el registro"; + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(java.util.Map.of("message", msg)); + } + } + + @PostMapping("/register/confirm") + public ResponseEntity confirm(@RequestBody ConfirmRequest request) { + try { + User user = verificationService.confirm(request.pendingId, request.code); + String token = authService.createSession(user.getId()); + UserResponse resp = UserResponse.fromEntity(user); + resp.token = token; + return ResponseEntity.status(HttpStatus.CREATED).body(resp); + } catch (IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(java.util.Map.of("message", ex.getMessage())); + } catch (Exception ex) { + String msg = ex.getMessage() != null ? ex.getMessage() : "Error interno al confirmar registro"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(java.util.Map.of("message", msg)); + } + } + @GetMapping("/me") public ResponseEntity me(@RequestHeader(name = "Authorization", required = false) String authorization) { try { @@ -117,4 +148,9 @@ public static UserResponse fromEntity(User user) { return resp; } } + + public static class ConfirmRequest { + public Long pendingId; + public String code; + } } diff --git a/backend/src/main/java/com/magicvs/backend/model/PendingRegistration.java b/backend/src/main/java/com/magicvs/backend/model/PendingRegistration.java new file mode 100644 index 0000000..1265c6d --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/model/PendingRegistration.java @@ -0,0 +1,109 @@ +package com.magicvs.backend.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "pending_registrations") +public class PendingRegistration { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String username; + + @Column(nullable = false, length = 100) + private String email; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "display_name", length = 100) + private String displayName; + + @Column(name = "verification_hash", length = 255) + private String verificationHash; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(name = "attempts") + private Integer attempts = 0; + + public PendingRegistration() { + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getVerificationHash() { + return verificationHash; + } + + public void setVerificationHash(String verificationHash) { + this.verificationHash = verificationHash; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public Integer getAttempts() { + return attempts; + } + + public void setAttempts(Integer attempts) { + this.attempts = attempts; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/repository/PendingRegistrationRepository.java b/backend/src/main/java/com/magicvs/backend/repository/PendingRegistrationRepository.java new file mode 100644 index 0000000..edd0c50 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/repository/PendingRegistrationRepository.java @@ -0,0 +1,9 @@ +package com.magicvs.backend.repository; + +import com.magicvs.backend.model.PendingRegistration; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface PendingRegistrationRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/com/magicvs/backend/service/RegistrationVerificationService.java b/backend/src/main/java/com/magicvs/backend/service/RegistrationVerificationService.java new file mode 100644 index 0000000..3f13dde --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/service/RegistrationVerificationService.java @@ -0,0 +1,240 @@ +package com.magicvs.backend.service; + +import com.magicvs.backend.model.PendingRegistration; +import com.magicvs.backend.model.User; +import com.magicvs.backend.repository.PendingRegistrationRepository; +import com.magicvs.backend.repository.RegistroRepository; +import com.magicvs.backend.util.ValidationUtils; +import org.springframework.mail.javamail.MimeMessageHelper; +import jakarta.mail.internet.MimeMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Optional; +import java.security.SecureRandom; + +@Service +public class RegistrationVerificationService { + + private static final Logger logger = LoggerFactory.getLogger(RegistrationVerificationService.class); + + private final PendingRegistrationRepository pendingRepo; + private final RegistroRepository registroRepository; + private final JavaMailSender mailSender; + + private static final String TAG_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + + public RegistrationVerificationService(PendingRegistrationRepository pendingRepo, + RegistroRepository registroRepository, JavaMailSender mailSender) { + this.pendingRepo = pendingRepo; + this.registroRepository = registroRepository; + this.mailSender = mailSender; + } + + public PendingRegistration initiate(String username, String email, String rawPassword, String displayName) { + String normalizedUsername = username.trim(); + String normalizedEmail = email.trim().toLowerCase(Locale.ROOT); + + logger.info("[DIAGNOSTIC] Iniciando registro. Usuario: {}, Email destino: {}", normalizedUsername, + normalizedEmail); + + if (!ValidationUtils.isValidUsername(normalizedUsername)) { + throw new IllegalArgumentException("Nombre de usuario inválido"); + } + if (!ValidationUtils.isValidEmail(normalizedEmail)) { + throw new IllegalArgumentException("Email con formato inválido"); + } + if (!ValidationUtils.isValidPassword(rawPassword)) { + throw new IllegalArgumentException("Contraseña inválida"); + } + if (registroRepository.existsByUsername(normalizedUsername)) { + throw new IllegalArgumentException("El nombre de usuario ya está en uso"); + } + if (registroRepository.existsByEmail(normalizedEmail)) { + throw new IllegalArgumentException("El email ya está en uso"); + } + + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + String passwordHash = encoder.encode(rawPassword); + + String code = generateNumericCode(6); + String codeHash = encoder.encode(code); + + PendingRegistration pr = new PendingRegistration(); + pr.setUsername(normalizedUsername); + pr.setEmail(normalizedEmail); + pr.setPasswordHash(passwordHash); + pr.setDisplayName(ValidationUtils.sanitizeDisplayName(displayName != null ? displayName : "")); + pr.setVerificationHash(codeHash); + pr.setExpiresAt(LocalDateTime.now().plusMinutes(15)); + + PendingRegistration saved = pendingRepo.save(pr); + + try { + String fromAddress = System.getenv("SMTP_FROM") != null ? System.getenv("SMTP_FROM") + : "noreply@magicvs.local"; + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(normalizedEmail); + helper.setFrom(fromAddress); + helper.setSubject("⚔️ ¡Ya casi estás! Tu código de activación para MagicVS"); + + String formattedCode = code.substring(0, 3) + "-" + code.substring(3); + + String htmlContent = "" + + "" + + "" + + "" + + " " + + + " " + + "
" + + " " + + " " + + " " + + " " + + "
" + + " MAGICVS" + + + "
" + + " " + + "
" + + "
" + + + "
" + + + "
" + + + "
" + + + "
" + + "
" + + "

Verifica tu chispa

" + + + "

Estás a un paso de dominar el Multiverso. Usa el código para completar tu registro:

" + + + "
" + + + " " + + formattedCode + "" + + "
" + + " Confirmar Registro" + + + "

• El código caducará en 15 minutos.

" + + + "
" + + "
© 2026 MAGICVS ARCANE SYSTEMS
" + + + "
" + + "
" + + ""; + + helper.setText(htmlContent, true); + mailSender.send(mimeMessage); + + } catch (Exception ex) { + logger.error("Error sending verification email", ex); + pendingRepo.delete(saved); + throw new RuntimeException("No se pudo enviar el email: " + ex.getMessage()); + } + return saved; + } + + public User confirm(Long pendingId, String code) { + Optional opt = pendingRepo.findById(pendingId); + if (opt.isEmpty()) + throw new IllegalArgumentException("Registro no encontrado"); + + PendingRegistration pr = opt.get(); + if (pr.getExpiresAt().isBefore(LocalDateTime.now())) { + pendingRepo.delete(pr); + throw new IllegalArgumentException("Código expirado"); + } + + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + if (!encoder.matches(code, pr.getVerificationHash())) { + pr.setAttempts(pr.getAttempts() + 1); + pendingRepo.save(pr); + throw new IllegalArgumentException("Código incorrecto"); + } + + User user = new User(); + user.setUsername(pr.getUsername()); + user.setEmail(pr.getEmail()); + user.setPasswordHash(pr.getPasswordHash()); + user.setDisplayName(pr.getDisplayName().isBlank() ? pr.getUsername() : pr.getDisplayName()); + user.setFriendTag(generateFriendTag()); + + User savedUser = registroRepository.save(user); + pendingRepo.delete(pr); + + try { + String fromAddress = System.getenv("SMTP_FROM") != null ? System.getenv("SMTP_FROM") + : "noreply@magicvs.local"; + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(savedUser.getEmail()); + helper.setFrom(fromAddress); + helper.setSubject("✨ ¡Bienvenido oficialmente, " + savedUser.getDisplayName() + "!"); + + String welcomeHtml = "" + + + "
" + + "
" + + "
" + + + "
" + + + "
" + + + "
" + + + "
" + + "
" + + "

¡Bienvenido, Caminante!

" + + "

Tu cuenta ha sido activada con éxito. Ya puedes entrar a la Arena.

" + + "
ENTRAR A MAGICVS" + + + "
"; + + helper.setText(welcomeHtml, true); + mailSender.send(mimeMessage); + } catch (Exception e) { + logger.warn("Error enviando bienvenida a {}", savedUser.getEmail()); + } + + return savedUser; + } + + private String generateNumericCode(int len) { + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) + sb.append(RANDOM.nextInt(10)); + return sb.toString(); + } + + private String generateFriendTag() { + String tag; + do { + StringBuilder sb = new StringBuilder(6); + for (int i = 0; i < 6; i++) + sb.append(TAG_CHARS.charAt(RANDOM.nextInt(TAG_CHARS.length()))); + tag = sb.toString(); + } while (registroRepository.existsByFriendTag(tag)); + return tag; + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index c7b5290..7af41b9 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -12,4 +12,14 @@ spring.jpa.properties.hibernate.jdbc.batch_size=50 spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true -server.port=${PORT:8080} \ No newline at end of file +server.port=${PORT:8080} + +# SMTP settings (set SMTP_USER and SMTP_PASS in environment for credentials) +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${SMTP_USER:} +spring.mail.password=${SMTP_PASS:} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.debug=true +spring.mail.properties.mail.smtp.ssl.trust=smtp.gmail.com diff --git a/docker-compose.yml b/docker-compose.yml index 42e6163..0c53062 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ services: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/magicvs SPRING_DATASOURCE_USERNAME: postgres SPRING_DATASOURCE_PASSWORD: postgres + SMTP_USER: "noreplymagicvs@gmail.com" + SMTP_PASS: "qhxmzwbsoozavefg" + SMTP_FROM: "noreplymagicvs@gmail.com" ports: - "8080:8080" depends_on: @@ -34,4 +37,4 @@ services: - backend volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 77062ff..3dc568e 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { Login } from './features/login/login'; import { Registro } from './features/registro/registro'; import { CatalogComponent } from './features/catalog/catalog'; import { CardDetailComponent } from './features/catalog/card-detail'; +import { Verification } from './features/verification/verification'; import { ProfilePageComponent } from './features/profile/profile-page.component'; export const routes: Routes = [ @@ -21,9 +22,10 @@ export const routes: Routes = [ { path: 'registro', component: Registro }, { path: 'cartas', component: CatalogComponent }, { path: 'cartas/:id', component: CardDetailComponent }, + { path: 'verify/:pendingId', component: Verification }, { path: 'profile', pathMatch: 'full', redirectTo: 'profile/me' }, { path: 'profile/:userId/decks', component: ProfilePageComponent }, { path: 'profile/:userId', component: ProfilePageComponent } ] } -]; +]; \ No newline at end of file diff --git a/frontend/src/app/features/registro/registro.html b/frontend/src/app/features/registro/registro.html index c995734..345d5c3 100644 --- a/frontend/src/app/features/registro/registro.html +++ b/frontend/src/app/features/registro/registro.html @@ -87,9 +87,12 @@

Crear c @if (error) { -

+

{{ error }} -

+
} diff --git a/frontend/src/app/features/registro/registro.ts b/frontend/src/app/features/registro/registro.ts index 7bfc892..5b40160 100644 --- a/frontend/src/app/features/registro/registro.ts +++ b/frontend/src/app/features/registro/registro.ts @@ -22,7 +22,7 @@ export class Registro { error: string | null = null; loading = false; - private readonly apiUrl = 'http://localhost:8080/api/users/register'; + private readonly apiUrlInitiate = 'http://localhost:8080/api/users/register/initiate'; constructor(private http: HttpClient, private router: Router, private cdr: ChangeDetectorRef, private ngZone: NgZone) {} @@ -58,7 +58,7 @@ export class Registro { this.loading = true; this.http - .post(this.apiUrl, { + .post<{ pendingId: number }>(this.apiUrlInitiate, { username: this.username, email: this.email, password: this.password, @@ -73,15 +73,10 @@ export class Registro { }) ) .subscribe({ - next: (user) => { + next: (resp) => { this.ngZone.run(() => { - this.message = `Cuenta creada. Bienvenido, ${user.displayName ?? user.username}!`; - if (user.token) { - localStorage.setItem('token', user.token); - } - localStorage.setItem('user', JSON.stringify(user)); - this.cdr.detectChanges(); - this.router.navigateByUrl('/'); + // navigate to verification page with pendingId + this.router.navigateByUrl(`/verify/${resp.pendingId}`); }); }, error: (err: HttpErrorResponse) => { @@ -104,6 +99,8 @@ export class Registro { } } else if (err.status === 409) { this.error = backendMsg ?? 'Recurso en conflicto'; + } else if (err.status === 502) { + this.error = backendMsg ?? 'No se pudo enviar el correo de verificación (SMTP)'; } else if (err.status === 0) { this.error = 'No se ha podido conectar con el servidor'; } else { diff --git a/frontend/src/app/features/verification/verification.html b/frontend/src/app/features/verification/verification.html new file mode 100644 index 0000000..b110f92 --- /dev/null +++ b/frontend/src/app/features/verification/verification.html @@ -0,0 +1,65 @@ +
+
+ +
+
+ mail_lock +
+

Verifica tu correo

+
+ +

+ Hemos enviado un código a tu email. Por favor, introdúcelo a continuación para activar tu cuenta. +

+ +
+ +
+ + +
+ + + + + + @if (error) { +
+ {{ error }} +
+ } + +
+

+ ¿No has recibido el código? Revisa tu carpeta de spam o intenta registrarte de nuevo. +

+
+ +
+
+
diff --git a/frontend/src/app/features/verification/verification.ts b/frontend/src/app/features/verification/verification.ts new file mode 100644 index 0000000..754bb63 --- /dev/null +++ b/frontend/src/app/features/verification/verification.ts @@ -0,0 +1,91 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Router, ActivatedRoute } from '@angular/router'; +import { finalize } from 'rxjs/operators'; +import { ChangeDetectorRef, NgZone } from '@angular/core'; + +@Component({ + selector: 'app-verification', + imports: [CommonModule, FormsModule], + templateUrl: './verification.html' +}) +export class Verification { + code = ''; + error: string | null = null; + loading = false; + pendingId: number | null = null; + + private readonly apiUrl = 'http://localhost:8080/api/users/register/confirm'; + + constructor(private http: HttpClient, private router: Router, private route: ActivatedRoute, private cdr: ChangeDetectorRef, private ngZone: NgZone) { + const id = this.route.snapshot.paramMap.get('pendingId'); + if (id) { + this.pendingId = Number(id); + } else { + this.error = 'No se ha encontrado una solicitud de registro pendiente.'; + } + } + + submit(): void { + this.error = null; + if (!this.pendingId) { + this.error = 'Código inválido'; + return; + } + if (!this.code || this.code.trim().length === 0) { + this.error = 'Introduce el código de verificación'; + return; + } + + this.loading = true; + this.http.post(this.apiUrl, { pendingId: this.pendingId, code: this.code.trim() }) + .pipe(finalize(() => { + this.ngZone.run(() => { + this.loading = false; + this.cdr.detectChanges(); + }); + })) + .subscribe({ + next: (user) => { + this.ngZone.run(() => { + // Guardar datos en localstorage + if (user.token) { + localStorage.setItem('token', user.token); + } + localStorage.setItem('user', JSON.stringify(user)); + + // Forzar recarga ligera o navegación para actualizar el estado del layout + this.router.navigateByUrl('/').then(() => { + window.location.reload(); // Asegura que el Nav se refresque con el usuario logueado + }); + }); + }, + error: (err: HttpErrorResponse) => { + this.ngZone.run(() => { + const backendMsg = err.error?.message || null; + if (err.status === 400) { + this.error = backendMsg || 'Código de verificación incorrecto o expirado.'; + } else if (err.status === 0) { + this.error = 'No se ha podido conectar con el servidor'; + } else { + this.error = backendMsg || 'Error inesperado al verificar el código'; + } + this.cdr.detectChanges(); + }); + } + }); + } +} + +interface UserResponse { + id: number; + username: string; + email: string; + displayName: string | null; + friendTag: string; + token?: string; + eloRating: number | null; + friendsCount: number | null; +} diff --git a/scripts/send_email_test.py b/scripts/send_email_test.py new file mode 100644 index 0000000..30b31ec --- /dev/null +++ b/scripts/send_email_test.py @@ -0,0 +1,29 @@ +import os +import smtplib +from email.message import EmailMessage + +SMTP_USER = os.environ.get('SMTP_USER') +SMTP_PASS = os.environ.get('SMTP_PASS') +SMTP_FROM = os.environ.get('SMTP_FROM') or SMTP_USER + +if not SMTP_USER or not SMTP_PASS: + print('Missing SMTP_USER or SMTP_PASS environment variables') + raise SystemExit(1) + +msg = EmailMessage() +msg['Subject'] = 'Prueba de envío MagicVs' +msg['From'] = SMTP_FROM +msg['To'] = SMTP_USER +msg.set_content('Este es un email de prueba desde el script de tests.') + +try: + with smtplib.SMTP('smtp.gmail.com', 587, timeout=20) as server: + server.ehlo() + server.starttls() + server.ehlo() + server.login(SMTP_USER, SMTP_PASS) + server.send_message(msg) + print('Email enviado correctamente') +except Exception as e: + print('Fallo al enviar email:', e) + raise