AuthController.java
package com.wavii.controller;
import com.wavii.dto.auth.*;
import com.wavii.repository.UserRepository;
import com.wavii.service.AuthService;
import com.wavii.service.EmailService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Controlador REST para operaciones de autenticación.
* Proporciona endpoints para registro, login, verificación de email y recuperación de contraseña.
*
* @author eduglezexp
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {
private final AuthService authService;
private final EmailService emailService;
private final UserRepository userRepository;
/**
* Registra un usuario nuevo.
*
* Endpoint: POST /api/auth/register
* Recibe {@link RegisterRequest} con los datos de registro y delega el alta en {@link AuthService}.
*
* Respuestas:
* - 201: Usuario creado correctamente.
* - 409: Conflicto (por ejemplo, email o nombre ya existente).
* - 500: Error interno del servidor.
*/
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) {
try {
AuthResponse response = authService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} catch (IllegalArgumentException e) {
String msg = e.getMessage();
boolean isNameConflict = msg != null && msg.startsWith("__NAME__");
String cleanMsg = isNameConflict ? msg.substring(8) : msg;
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of(
"code", "CONFLICT",
"field", isNameConflict ? "name" : "email",
"message", cleanMsg != null ? cleanMsg : "Conflicto al registrar"
));
} catch (Exception e) {
log.error("Error en registro", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorBody("SERVER_ERROR", "Error interno del servidor"));
}
}
/**
* Comprueba si un nombre de usuario está disponible.
*
* Endpoint: GET /api/auth/check-name?name=...
* Permite validar en el frontend si el nombre ya está en uso.
*
* @param name Nombre a comprobar.
* @param currentUser Usuario autenticado (opcional). Si el nombre coincide con el del propio usuario,
* se considera disponible.
* @return 200 OK con un JSON: { "taken": boolean }.
*/
@GetMapping("/check-name")
public ResponseEntity<Map<String, Boolean>> checkName(
@RequestParam String name,
@AuthenticationPrincipal com.wavii.model.User currentUser) {
String trimmed = name.strip();
boolean taken = userRepository.existsByNameIgnoreCase(trimmed)
&& (currentUser == null || !currentUser.getName().equalsIgnoreCase(trimmed));
return ResponseEntity.ok(Map.of("taken", taken));
}
/**
* Inicia sesión de un usuario.
*
* Endpoint: POST /api/auth/login
* Recibe {@link LoginRequest} y delega la autenticación en {@link AuthService}.
*
* Respuestas típicas:
* - 200: Login correcto.
* - 403: Email no verificado.
* - 401: Credenciales inválidas.
* - 500: Error interno del servidor.
*/
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
try {
AuthResponse response = authService.login(request);
return ResponseEntity.ok(response);
} catch (AuthService.EmailNotVerifiedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody(e.getCode(), e.getMessage()));
} catch (BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(errorBody("INVALID_CREDENTIALS", e.getMessage()));
} catch (Exception e) {
log.error("Error en login", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorBody("SERVER_ERROR", "Error interno del servidor"));
}
}
/**
* Genera un nuevo acceso a partir de un refresh token.
*
* Endpoint: POST /api/auth/refresh
* Respuestas:
* - 200: Token actualizado.
* - 401: Token inválido.
* - 500: Error interno del servidor.
*/
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@Valid @RequestBody RefreshTokenRequest request) {
try {
AuthResponse response = authService.refreshToken(request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(errorBody("INVALID_TOKEN", e.getMessage()));
} catch (Exception e) {
log.error("Error en refresh token", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorBody("SERVER_ERROR", "Error interno del servidor"));
}
}
/**
* Cierra la sesión del usuario.
*
* @return 200 OK con mensaje de éxito.
*/
@PostMapping("/logout")
public ResponseEntity<?> logout() {
return ResponseEntity.ok(Map.of("message", "Sesión cerrada correctamente"));
}
/**
* Solicita el restablecimiento de contraseña.
*
* @param request Datos con el email del usuario.
* @return 200 OK con mensaje informativo.
*/
@PostMapping("/forgot-password")
public ResponseEntity<?> forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) {
try {
authService.forgotPassword(request);
return ResponseEntity.ok(Map.of(
"message", "Si el email existe, recibirás un enlace para restablecer tu contraseña"
));
} catch (Exception e) {
log.error("Error en forgot-password", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorBody("SERVER_ERROR", "Error interno del servidor"));
}
}
/**
* Muestra el formulario HTML para restablecer la contraseña.
*
* @param token Token de recuperación.
* @return HTML con el formulario.
*/
@GetMapping("/reset-password")
public ResponseEntity<String> resetPasswordForm(@RequestParam String token) {
String html = """
<!DOCTYPE html><html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Wavii — Restablecer contraseña</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:'Nunito',sans-serif;background:#FFF7ED;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:20px;overflow-y:auto}
.card{background:#fff;border-radius:24px;padding:36px 28px 40px;text-align:center;box-shadow:0 8px 40px rgba(255,122,0,0.12);max-width:420px;width:100%;margin:auto}
.logo{font-size:28px;font-weight:900;color:#FF7A00;letter-spacing:-0.5px;margin-bottom:24px}
h1{font-size:20px;font-weight:800;color:#1A1A2E;margin-bottom:8px}
p{font-size:14px;color:#666680;margin-bottom:28px;line-height:1.5}
label{display:block;text-align:left;font-size:13px;font-weight:700;color:#1A1A2E;margin-bottom:6px}
input{width:100%;padding:14px 16px;border:1.5px solid #E5E7EB;border-radius:12px;font-size:15px;font-family:inherit;outline:none;transition:border-color .2s;margin-bottom:16px}
input:focus{border-color:#FF7A00}
button{width:100%;padding:16px;background:#FF7A00;color:#fff;border:none;border-radius:12px;font-size:16px;font-weight:800;font-family:inherit;cursor:pointer;margin-top:4px;transition:opacity .2s}
button:disabled{opacity:.6;cursor:default}
.msg{margin-top:20px;padding:14px 16px;border-radius:12px;font-size:14px;font-weight:600;display:none}
.msg.ok{background:rgba(34,197,94,0.1);color:#16A34A;display:block}
.msg.err{background:rgba(239,68,68,0.1);color:#DC2626;display:block}
</style>
</head>
<body>
<div class="card">
<div class="logo">Wavii</div>
<h1>Nueva contraseña</h1>
<p>Introduce tu nueva contraseña.<br>Debe tener al menos 6 caracteres.</p>
<form id="form">
<label for="pwd">Nueva contraseña</label>
<input id="pwd" type="password" placeholder="••••••••" minlength="6" required>
<label for="pwd2">Repetir contraseña</label>
<input id="pwd2" type="password" placeholder="••••••••" minlength="6" required>
<button type="submit" id="btn">Restablecer contraseña</button>
</form>
<div class="msg" id="msg"></div>
</div>
<script>
const token = {{TOKEN}};
document.getElementById('form').addEventListener('submit', async e => {
e.preventDefault();
const pwd = document.getElementById('pwd').value;
const pwd2 = document.getElementById('pwd2').value;
const msg = document.getElementById('msg');
const btn = document.getElementById('btn');
msg.className = 'msg';
if (pwd !== pwd2) {
msg.textContent = 'Las contraseñas no coinciden.';
msg.className = 'msg err';
return;
}
btn.disabled = true;
btn.textContent = 'Guardando...';
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': 'true'
},
body: JSON.stringify({ token, newPassword: pwd })
});
if (res.ok) {
document.getElementById('form').style.display = 'none';
msg.textContent = 'Contrasena actualizada. Ya puedes iniciar sesion en la app.';
msg.className = 'msg ok';
} else {
let errMsg = 'El enlace ha expirado o ya fue usado.';
try { const d = await res.json(); if (d.message) errMsg = d.message; } catch {}
msg.textContent = errMsg;
msg.className = 'msg err';
btn.disabled = false;
btn.textContent = 'Restablecer contraseña';
}
} catch {
msg.textContent = 'Error de red. Inténtalo de nuevo.';
msg.className = 'msg err';
btn.disabled = false;
btn.textContent = 'Restablecer contraseña';
}
});
</script>
</body></html>
""".replace("{{TOKEN}}", quoteJs(token));
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(html);
}
/**
* Procesa el cambio de contraseña tras el formulario de recuperación.
*
* @param request Datos con el token y la nueva contraseña.
* @return 200 OK si se cambió correctamente.
*/
@PostMapping("/reset-password")
public ResponseEntity<?> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
try {
authService.resetPassword(request);
return ResponseEntity.ok(Map.of("message", "Contraseña restablecida correctamente"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(errorBody("INVALID_TOKEN", e.getMessage()));
} catch (Exception e) {
log.error("Error en reset-password", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorBody("SERVER_ERROR", "Error interno del servidor"));
}
}
/**
* Verifica el email del usuario mediante un token.
*
* @param token Token de verificación.
* @return HTML con el resultado de la verificación.
*/
@GetMapping("/verify-email")
public ResponseEntity<String> verifyEmail(@RequestParam String token) {
try {
authService.verifyEmail(token);
String html = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Wavii — Cuenta verificada</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Nunito',sans-serif;background:#FFF7ED;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{
background:#fff;
border-radius:24px;
padding:52px 40px 56px;
text-align:center;
box-shadow:0 8px 40px rgba(255,122,0,0.12);
max-width:400px;
width:100%%;
display:flex;
flex-direction:column;
align-items:center;
gap:0;
}
.brand{
font-size:64px;
font-weight:900;
color:#FF7A00;
letter-spacing:-3px;
line-height:1;
margin-bottom:36px;
}
.badge{
display:inline-block;
background:rgba(34,197,94,0.12);
color:#16A34A;
font-weight:700;
font-size:13px;
padding:6px 18px;
border-radius:999px;
margin-bottom:24px;
}
h1{
font-size:28px;
font-weight:800;
color:#1A1A2E;
margin-bottom:14px;
}
.body-text{
font-size:15px;
color:#666680;
line-height:1.7;
margin-bottom:0;
}
.body-text strong{color:#1A1A2E;font-weight:700}
.hint{
font-size:12px;
color:#C0C0C0;
margin-top:28px;
}
</style>
</head>
<body>
<div class="card">
<div class="brand">Wavii</div>
<div class="badge">Cuenta verificada</div>
<h1>Ya estas dentro</h1>
<p class="body-text">Tu correo ha sido confirmado.<br>Vuelve a la app: <strong>entrara automaticamente</strong> en unos segundos.</p>
<p class="hint">Puedes cerrar esta ventana.</p>
</div>
</body>
</html>
""";
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(html);
} catch (Exception e) {
String html = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Wavii — Enlace invalido</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Nunito',sans-serif;background:#FFF7ED;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{
background:#fff;
border-radius:24px;
padding:52px 40px 56px;
text-align:center;
box-shadow:0 8px 40px rgba(255,122,0,0.12);
max-width:400px;
width:100%%;
display:flex;
flex-direction:column;
align-items:center;
gap:0;
}
.brand{
font-size:64px;
font-weight:900;
color:#FF7A00;
letter-spacing:-3px;
line-height:1;
margin-bottom:36px;
}
.badge{
display:inline-block;
background:rgba(239,68,68,0.10);
color:#DC2626;
font-weight:700;
font-size:13px;
padding:6px 18px;
border-radius:999px;
margin-bottom:24px;
}
h1{
font-size:28px;
font-weight:800;
color:#1A1A2E;
margin-bottom:14px;
}
.body-text{
font-size:15px;
color:#666680;
line-height:1.7;
margin-bottom:0;
}
.hint{
font-size:12px;
color:#C0C0C0;
margin-top:28px;
}
</style>
</head>
<body>
<div class="card">
<div class="brand">Wavii</div>
<div class="badge">Enlace invalido</div>
<h1>Algo ha salido mal</h1>
<p class="body-text">Este enlace ya fue usado o ha expirado.<br>Solicita uno nuevo desde la app.</p>
<p class="hint">Los enlaces expiran en 24 horas.</p>
</div>
</body>
</html>
""";
return ResponseEntity.badRequest().contentType(MediaType.TEXT_HTML).body(html);
}
}
/**
* Comprueba si el email de un usuario está verificado.
*
* @param email Email a comprobar.
* @return 200 OK con { verified: boolean }.
*/
@GetMapping("/check-verification")
public ResponseEntity<Map<String, Boolean>> checkVerification(@RequestParam String email) {
boolean verified = authService.isEmailVerified(email);
return ResponseEntity.ok(Map.of("verified", verified));
}
/**
* Reenvía el email de verificación.
*
* @param body Mapa que contiene el campo "email".
* @return 200 OK si se envió correctamente.
*/
@PostMapping("/resend-verification")
public ResponseEntity<?> resendVerification(@RequestBody Map<String, String> body) {
try {
String email = body.get("email");
if (email == null || email.isBlank()) {
return ResponseEntity.badRequest()
.body(errorBody("VALIDATION_ERROR", "El email es obligatorio"));
}
authService.resendVerification(email);
return ResponseEntity.ok(Map.of("message", "Email de verificación reenviado"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(errorBody("BAD_REQUEST", e.getMessage()));
} catch (Exception e) {
log.error("Error en resend-verification", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorBody("SERVER_ERROR", "Error interno del servidor"));
}
}
// Los endpoints verify-teacher-phone y confirm-teacher-phone han sido eliminados.
// La verificación de profesor_particular ocurre automáticamente al verificar el email.
/**
* Endpoint de prueba para enviar un email.
*
* @param to Destinatario del email.
* @return 200 OK si se envió correctamente.
*/
@GetMapping("/test-email")
public ResponseEntity<?> testEmail(@RequestParam String to) {
try {
emailService.sendTestEmail(to);
return ResponseEntity.ok(Map.of("message", "Test email enviado a " + to));
} catch (Exception e) {
log.error("Error en test-email", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorBody("SMTP_ERROR", e.getMessage()));
}
}
private Map<String, String> errorBody(String code, String message) {
return Map.of("code", code, "message", message);
}
/** Escapa un String para incrustarlo como literal JS entre comillas simples. */
private String quoteJs(String value) {
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
}
}