mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-13 00:48:49 +00:00
terminado sign up
This commit is contained in:
@ -4,12 +4,13 @@ import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@ -32,44 +33,67 @@ public class EmailService {
|
||||
Map<String, Object> variables = Map.of(
|
||||
"fullName", fullName,
|
||||
"resetUrl", resetUrl);
|
||||
sendEmail(to, subject, "imprimelibros/email/reset-password", variables);
|
||||
sendEmail(to, subject, "imprimelibros/email/reset-password", variables, locale);
|
||||
}
|
||||
|
||||
public void sendVerificationEmail(String to, String fullName, String verifyUrl, Locale locale) {
|
||||
String subject = messageSource.getMessage("email.verify.title", null, locale);
|
||||
Map<String, Object> variables = Map.of(
|
||||
"fullName", fullName,
|
||||
"verifyUrl", verifyUrl);
|
||||
sendEmail(to, subject, "imprimelibros/email/verify", variables);
|
||||
"verifyUrl", verifyUrl,
|
||||
"minutes", 60);
|
||||
sendEmail(to, subject, "imprimelibros/email/verify", variables, locale);
|
||||
}
|
||||
|
||||
|
||||
// ->>>>>>>> PRIVATE METHODS <<<<<<<<<<<-
|
||||
|
||||
/******************
|
||||
* Envía un email usando una plantilla Thymeleaf.
|
||||
*
|
||||
* @param to
|
||||
* @param subject
|
||||
* @param template
|
||||
* @param variables
|
||||
**********************************************/
|
||||
|
||||
private void sendEmail(String to, String subject, String template, Map<String, Object> variables) {
|
||||
private void sendEmail(String to, String subject, String template, Map<String, Object> variables, Locale locale) {
|
||||
try {
|
||||
Context context = new Context();
|
||||
context.setVariables(variables);
|
||||
String html = templateEngine.process(template, context);
|
||||
Context ctx = new Context(locale);
|
||||
ctx.setVariables(variables);
|
||||
ctx.setVariable("subject", subject);
|
||||
ctx.setVariable("companyName", "ImprimeLibros");
|
||||
ctx.setVariable("year", java.time.Year.now().getValue());
|
||||
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
helper.setFrom("no-reply@imprimelibros.com");
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(html, true);
|
||||
// Incrusta el CSS (no uses <link> en emails)
|
||||
var cssRes = new ClassPathResource("static/assets/css/email.css");
|
||||
String emailCss = org.springframework.util.StreamUtils.copyToString(cssRes.getInputStream(),
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
ctx.setVariable("emailCss", emailCss);
|
||||
|
||||
mailSender.send(message);
|
||||
} catch (MessagingException e) {
|
||||
e.printStackTrace();
|
||||
ctx.setVariable("template", template);
|
||||
String html = templateEngine.process("imprimelibros/email/layout", ctx);
|
||||
|
||||
MimeMessage msg = mailSender.createMimeMessage();
|
||||
|
||||
MimeMessageHelper h = new MimeMessageHelper(
|
||||
msg,
|
||||
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
|
||||
"UTF-8");
|
||||
|
||||
h.setFrom("no-reply@imprimelibros.com");
|
||||
h.setTo(to);
|
||||
h.setSubject(subject);
|
||||
|
||||
h.setText(html, true);
|
||||
|
||||
// 3) ahora el inline, con content-type explícito
|
||||
ClassPathResource logoRes = new ClassPathResource("static/assets/images/logo-light.png");
|
||||
h.addInline("logo", logoRes, "image/png");
|
||||
|
||||
mailSender.send(msg);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error enviando email", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.context.MessageSource;
|
||||
|
||||
import com.imprimelibros.erp.login.dto.SignupForm;
|
||||
|
||||
@ -19,9 +20,11 @@ import jakarta.validation.Valid;
|
||||
public class LoginController {
|
||||
|
||||
private final SignupService signupService;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public LoginController(SignupService signupService) {
|
||||
public LoginController(SignupService signupService, MessageSource messageSource) {
|
||||
this.signupService = signupService;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
@ -47,12 +50,13 @@ public class LoginController {
|
||||
if (br.hasErrors()) {
|
||||
ra.addFlashAttribute("org.springframework.validation.BindingResult.signupForm", br);
|
||||
ra.addFlashAttribute("signupForm", form);
|
||||
ra.addFlashAttribute("signup_error", "Revisa el formulario");
|
||||
ra.addFlashAttribute("signup_error",
|
||||
messageSource.getMessage("login.signup.error.review", null, locale));
|
||||
return "redirect:/signup";
|
||||
}
|
||||
try {
|
||||
signupService.register(form, locale);
|
||||
ra.addFlashAttribute("info", "Te hemos enviado un email para confirmar tu correo.");
|
||||
ra.addFlashAttribute("info", messageSource.getMessage("login.signup.success", null, locale));
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ra.addFlashAttribute("signup_error", ex.getMessage());
|
||||
@ -62,12 +66,12 @@ public class LoginController {
|
||||
}
|
||||
|
||||
@GetMapping("/verify")
|
||||
public String verify(@RequestParam("token") String token, RedirectAttributes ra) {
|
||||
public String verify(@RequestParam("token") String token, RedirectAttributes ra, Locale locale) {
|
||||
boolean ok = signupService.verify(token);
|
||||
if (ok) {
|
||||
ra.addFlashAttribute("info", "¡Cuenta verificada! Ya puedes iniciar sesión.");
|
||||
ra.addFlashAttribute("info", messageSource.getMessage("login.signup.success.verified", null, locale));
|
||||
} else {
|
||||
ra.addFlashAttribute("danger", "Enlace inválido o caducado. Solicita uno nuevo.");
|
||||
ra.addFlashAttribute("danger", messageSource.getMessage("login.signup.error.token.invalid", null, locale));
|
||||
}
|
||||
return "redirect:/login";
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
import org.springframework.context.MessageSource;
|
||||
|
||||
import com.imprimelibros.erp.common.email.EmailService;
|
||||
import com.imprimelibros.erp.login.dto.SignupForm;
|
||||
@ -26,6 +27,7 @@ public class SignupService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final VerificationTokenRepository tokenRepository;
|
||||
private final EmailService emailService;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
// minutos de validez del token
|
||||
private static final long TOKEN_MINUTES = 60;
|
||||
@ -34,22 +36,40 @@ public class SignupService {
|
||||
RoleDao roleRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
VerificationTokenRepository tokenRepository,
|
||||
EmailService emailService) {
|
||||
EmailService emailService,
|
||||
MessageSource messageSource) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.tokenRepository = tokenRepository;
|
||||
this.emailService = emailService;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void register(SignupForm form, Locale locale) {
|
||||
if (!form.getPassword().equals(form.getPasswordConfirm())) {
|
||||
throw new IllegalArgumentException("Las contraseñas no coinciden");
|
||||
throw new IllegalArgumentException(
|
||||
messageSource.getMessage("login.signup.error.password.mismatch", null, locale));
|
||||
}
|
||||
|
||||
if (userRepository.existsByUserNameIgnoreCase(form.getUsername())) {
|
||||
throw new IllegalArgumentException("El correo ya está registrado");
|
||||
String email = form.getUsername().trim().toLowerCase();
|
||||
|
||||
var existingLite = userRepository.findLiteByUserNameIgnoreCase(email);
|
||||
|
||||
if (existingLite.isPresent()) {
|
||||
var lite = existingLite.get();
|
||||
|
||||
boolean isDeleted = lite.getDeleted();
|
||||
if (isDeleted) {
|
||||
// caso: existe pero borrado
|
||||
throw new IllegalArgumentException(
|
||||
messageSource.getMessage("login.signup.error.email.exists-archived", null, locale));
|
||||
} else {
|
||||
// caso: existe y no borrado
|
||||
throw new IllegalArgumentException(
|
||||
messageSource.getMessage("login.signup.error.email.exists", null, locale));
|
||||
}
|
||||
}
|
||||
|
||||
// Crear usuario deshabilitado
|
||||
@ -85,7 +105,6 @@ public class SignupService {
|
||||
locale);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public boolean verify(String tokenValue) {
|
||||
var tokenOpt = tokenRepository.findByToken(tokenValue);
|
||||
|
||||
@ -57,7 +57,8 @@ public class User {
|
||||
@Column(name = "deleted_by")
|
||||
private Long deletedBy;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE,
|
||||
CascadeType.REMOVE }, orphanRemoval = true)
|
||||
@SQLRestriction("deleted = false")
|
||||
@JsonIgnore
|
||||
private Set<UserRole> rolesLink = new HashSet<>();
|
||||
|
||||
@ -29,6 +29,15 @@ public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExec
|
||||
|
||||
boolean existsByUserNameIgnoreCase(String userName);
|
||||
|
||||
// Para comprobar si existe al hacer signup
|
||||
@Query(value = """
|
||||
SELECT id, deleted, enabled
|
||||
FROM users
|
||||
WHERE LOWER(username) = LOWER(:userName)
|
||||
LIMIT 1
|
||||
""", nativeQuery = true)
|
||||
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
|
||||
|
||||
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
||||
|
||||
// Nuevo: para login/negocio "activo"
|
||||
|
||||
9
src/main/java/com/imprimelibros/erp/users/UserLite.java
Normal file
9
src/main/java/com/imprimelibros/erp/users/UserLite.java
Normal file
@ -0,0 +1,9 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
// Proyección para consultas ligeras de usuarios (id, enabled, deleted)
|
||||
public interface UserLite {
|
||||
|
||||
Long getId();
|
||||
Boolean getDeleted();
|
||||
Boolean getEnabled();
|
||||
}
|
||||
@ -18,4 +18,12 @@ login.sign-up-button=Crear cuenta
|
||||
login.sign-up.title=Crear una cuenta
|
||||
login.sign-up.name=Nombre completo
|
||||
|
||||
login.error=Credenciales inválidas
|
||||
login.error=Credenciales inválidas
|
||||
login.signup.error.email.exists=El correo electrónico ya está en uso.
|
||||
login.signup.error.email.exists-archived=El correo pertenece a una cuenta archivada. Por favor, contacta con soporte.
|
||||
login.signup.error.password.mismatch=Las contraseñas no coinciden.
|
||||
login.signup.error.review=Por favor, revisa el formulario.
|
||||
login.signup.error.token.invalid=Enlace inválido o caducado. Solicita uno nuevo.
|
||||
|
||||
login.signup.success=Cuenta creada con éxito. Por favor, revisa tu correo para activar tu cuenta.
|
||||
login.signup.success.verified=¡Cuenta verificada! Ya puedes iniciar sesión.
|
||||
@ -1,52 +1,79 @@
|
||||
/* Email base */
|
||||
/* ===========================
|
||||
Email base
|
||||
=========================== */
|
||||
|
||||
/* Evita modo oscuro forzado */
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f7fb;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #f5f7fb !important;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Contenedor principal */
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
background: #f5f7fb;
|
||||
padding: 20px;
|
||||
background-color: #f5f7fb !important;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
/* Cuerpo centrado */
|
||||
.email-body {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tarjeta del correo */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Encabezado */
|
||||
.email-header {
|
||||
background: #f0f0f0;
|
||||
background-color: #f0f0f0;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-header img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Contenido */
|
||||
.email-content {
|
||||
padding: 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.email-footer {
|
||||
background: #f9f9f9;
|
||||
background-color: #f9f9f9;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.email-footer a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Botones */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
@ -56,4 +83,10 @@ body {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
background-color: #2563eb;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
BIN
src/main/resources/static/assets/images/logo-light-email.png
Normal file
BIN
src/main/resources/static/assets/images/logo-light-email.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@ -1,52 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" xmlns:th="http://www.thymeleaf.org">
|
||||
|
||||
<html lang="es" xmlns:th="http://www.thymeleaf.org" th:fragment="mail(content)">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title th:text="${subject} ?: 'Notificación'">Notificación</title>
|
||||
<link rel="stylesheet" th:href="@{/css/email.css}">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title th:text="${subject} ?: 'Notificación'">Notificación</title>
|
||||
|
||||
<!-- Evitar dark mode agresivo en clientes que lo respetan -->
|
||||
<meta name="color-scheme" content="light only">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
|
||||
<!-- Tu CSS incrustado -->
|
||||
<style th:utext="${emailCss}"></style>
|
||||
|
||||
<!-- Hack específico para Gmail iOS (fuerza blanco en la tarjeta/contenido) -->
|
||||
<style>
|
||||
/* Gmail iOS: el selector u + .body apunta al cuerpo real que Gmail envuelve */
|
||||
u + .body .email-container { background: #ffffff !important; }
|
||||
u + .body .email-content { background: #ffffff !important; color: #333 !important; }
|
||||
u + .body .email-header { background: #f0f0f0 !important; }
|
||||
u + .body .email-footer { background: #f9f9f9 !important; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<table role="presentation" class="email-wrapper">
|
||||
<tr>
|
||||
<td align="center" class="email-body">
|
||||
<table role="presentation" class="email-container">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td class="email-header">
|
||||
<h2>
|
||||
<img src="/assets/images/logo-light.png" alt="" height="45">
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Añade class="body" para el hack de Gmail iOS -->
|
||||
<body class="body" style="margin:0; padding:0; background-color:#f5f7fb;" bgcolor="#f5f7fb">
|
||||
|
||||
<!-- Content (fragmento) -->
|
||||
<tr>
|
||||
<td class="email-content">
|
||||
<div th:replace="~{::bodyContent}"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Wrapper 100% -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
|
||||
class="email-wrapper"
|
||||
style="width:100%; background-color:#f5f7fb;"
|
||||
bgcolor="#f5f7fb">
|
||||
<tr>
|
||||
<td align="center" class="email-body" style="padding:20px 10px;">
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td class="email-footer">
|
||||
<p>
|
||||
<strong th:text="${companyName} ?: 'ImprimeLibros'">ImprimeLibros</strong><br>
|
||||
Calle José Picón, Nº 28 Local A, 28028, Madrid<br>
|
||||
91 005 25 74 -
|
||||
<a href="mailto:contacto@imprimelibros.com" style="color:#2563eb;">
|
||||
contacto@imprimelibros.com
|
||||
</a><br>
|
||||
© <span th:text="${year} ?: ${#dates.year(#dates.createNow())}">2025</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Tarjeta blanca (con pixel blanco para evitar inversión en móvil) -->
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
|
||||
class="email-container"
|
||||
style="max-width:600px; width:100%; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 6px rgba(0,0,0,0.08);"
|
||||
bgcolor="#ffffff"
|
||||
background="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/et0HFAAAAABJRU5ErkJggg==">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td class="email-header" style="background:#f0f0f0; padding:20px; text-align:center;" bgcolor="#f0f0f0">
|
||||
<h2 style="margin:0;">
|
||||
<img src="cid:logo" alt="imprimelibros" height="45" style="display:block; margin:0 auto;">
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</tr>
|
||||
|
||||
</html>
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td class="email-content"
|
||||
style="padding:24px; font-size:14px; line-height:1.6; color:#333; text-align:left; background:#ffffff;"
|
||||
bgcolor="#ffffff">
|
||||
<th:block th:replace="~{${template} :: content}"></th:block>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td class="email-footer" style="background:#f9f9f9; padding:12px; text-align:center; font-size:12px; color:#777;" bgcolor="#f9f9f9">
|
||||
<p style="margin:0;">
|
||||
<strong th:text="${companyName} ?: 'ImprimeLibros'">ImprimeLibros</strong><br>
|
||||
Calle José Picón, Nº 28 Local A, 28028, Madrid<br>
|
||||
91 005 25 74 -
|
||||
<a href="mailto:contacto@imprimelibros.com" style="color:#2563eb; text-decoration:none;">
|
||||
contacto@imprimelibros.com
|
||||
</a><br>
|
||||
© <span th:text="${year} ?: ${#dates.year(#dates.createNow())}">2025</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- /Tarjeta -->
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- /Wrapper -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,25 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" xmlns:th="http://www.thymeleaf.org" th:replace="emails/layout :: bodyContent">
|
||||
|
||||
<body>
|
||||
<p><span th:text="#{email.greeting}">Hola</span> <span th:text="${fullName}">usuario</span>,</p>
|
||||
<p><span th:text="#{email.verify.body}">Haz clic en el siguiente botón para verificar tu correo electrónico:</span>
|
||||
<html lang="es" xmlns:th="http://www.thymeleaf.org">
|
||||
<th:block th:fragment="content">
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.greeting}">Hola</span>
|
||||
<span th:text="${fullName} ?: 'Usuario'">Usuario</span>,
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a th:href="${verifyUrl}" class="btn btn-secondary" target="_blank">
|
||||
<span th:text="#{email.verify.button}">Verificar cuenta</span>
|
||||
</a>
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.body}">
|
||||
Haz clic en el siguiente botón para verificar tu correo electrónico:
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p><span th:text="#{email.verify.link-instruction}">Si no funciona, copia y pega esta URL en tu navegador:</span>
|
||||
<p style="margin:0 0 16px;">
|
||||
<a th:href="${verifyUrl}"
|
||||
style="display:inline-block; padding:12px 20px; border-radius:6px; font-weight:bold;
|
||||
background:#2563eb; color:#ffffff !important; text-decoration:none;">
|
||||
<span th:text="#{email.verify.button}">Verificar cuenta</span>
|
||||
</a>
|
||||
</p>
|
||||
<p><span th:text="${verifyUrl}">https://...</span></p>
|
||||
|
||||
<p><span th:text="#{email.verify.expiration(${minutes} ?: 60)}">Este enlace caduca en 60 minutos.</span></p>
|
||||
|
||||
<p><span th:text="#{email.verify.ignoreMessage}">Si no solicitaste este cambio, puedes ignorar este mensaje.</span>
|
||||
<p style="margin:0 0 8px; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.link-instruction}">
|
||||
Si no funciona, copia y pega esta URL en tu navegador:
|
||||
</span>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="${verifyUrl}">https://...</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.expiration(${minutes})}">
|
||||
Este enlace caduca en 60 minutos.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.ignoreMessage}">
|
||||
Si no solicitaste este cambio, puedes ignorar este mensaje.
|
||||
</span>
|
||||
</p>
|
||||
</th:block>
|
||||
</html>
|
||||
|
||||
@ -1,63 +1,60 @@
|
||||
<div th:fragment="_login">
|
||||
<div class="p-lg-5 p-4">
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5>
|
||||
<p class="text-muted" th:text="#{login.subtitle}">Inicie sesión para continuar:</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<form th:action="@{/login}" method="post">
|
||||
<!-- CSRF obligatorio -->
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5>
|
||||
<p class="text-muted" th:text="#{login.subtitle}">Inicie sesión para continuar:</p>
|
||||
</div>
|
||||
|
||||
<div th:if="${param.error}" class="alert alert-danger"
|
||||
th:text="#{login.error}">
|
||||
Credenciales inválidas
|
||||
<div class="mt-4">
|
||||
<form th:action="@{/login}" method="post">
|
||||
<!-- CSRF obligatorio -->
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div th:if="${param.error}" class="alert alert-danger" th:text="#{login.error}">
|
||||
Credenciales inválidas
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="username" th:placeholder="#{login.email-placeholder}"
|
||||
name="username">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="float-end">
|
||||
<a href="/auth-pass-reset-cover" class="text-muted" th:text="#{login.forgotPassword}">¿Olvidó su
|
||||
contraseña?</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="username" th:placeholder="#{login.email-placeholder}"
|
||||
name="username">
|
||||
<label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label>
|
||||
<div class="position-relative auth-pass-inputgroup mb-3">
|
||||
<input type="password" class="form-control pe-5 password-input"
|
||||
th:placeholder="#{login.password-placeholder}" id="password-input" name="password">
|
||||
<button
|
||||
class="btn btn-link position-absolute end-0 top-0 text-decoration-none text-muted password-addon"
|
||||
type="button" id="password-addon"><i class="ri-eye-fill align-middle"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="float-end">
|
||||
<a href="/auth-pass-reset-cover" class="text-muted" th:text="#{login.forgotPassword}">¿Olvidó su
|
||||
contraseña?</a>
|
||||
</div>
|
||||
<label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label>
|
||||
<div class="position-relative auth-pass-inputgroup mb-3">
|
||||
<input type="password" class="form-control pe-5 password-input"
|
||||
th:placeholder="#{login.password-placeholder}" id="password-input" name="password">
|
||||
<button
|
||||
class="btn btn-link position-absolute end-0 top-0 text-decoration-none text-muted password-addon"
|
||||
type="button" id="password-addon"><i class="ri-eye-fill align-middle"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="" id="remember-me" name="remember-me">
|
||||
<label class="form-check-label" for="remember-me" th:text="#{login.rememberMe}">Recuerdame</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="" id="remember-me" name="remember-me">
|
||||
<label class="form-check-label" for="remember-me" th:text="#{login.rememberMe}">Recuerdame</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.login}">Iniciar
|
||||
Sesión</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.login}">Iniciar
|
||||
Sesión</button>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-center">
|
||||
<p class="mb-0">
|
||||
<span th:text="#{login.new-account}">¿No tienes una cuenta?</span>
|
||||
<a href="/signup" class="fw-semibold text-primary text-decoration-underline"
|
||||
th:text="#{login.sign-up}">
|
||||
Regístrate
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-5 text-center">
|
||||
<p class="mb-0">
|
||||
<span th:text="#{login.new-account}">¿No tienes una cuenta?</span>
|
||||
<a href="/signup" class="fw-semibold text-primary text-decoration-underline" th:text="#{login.sign-up}">
|
||||
Regístrate
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,56 +1,51 @@
|
||||
<div th:fragment="_signup">
|
||||
|
||||
<div class="p-lg-5 p-4">
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5>
|
||||
<p class="text-muted" th:text="#{login.sign-up.title}">Crear cuenta</p>
|
||||
|
||||
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5>
|
||||
<p class="text-muted" th:text="#{login.sign-up.title}">Crear cuenta</p>
|
||||
</div>
|
||||
|
||||
<!-- En el caso del formulario de signup, asegúrate de bindear el DTO -->
|
||||
<form th:if="${form == '_signup'}" th:action="@{/signup}" method="post" th:object="${signupForm}">
|
||||
|
||||
|
||||
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="username" th:field="*{username}"
|
||||
th:placeholder="#{login.email-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></div>
|
||||
</div>
|
||||
|
||||
<div th:if="${info}" class="alert alert-info" th:text="${info}"></div>
|
||||
<div th:if="${danger}" class="alert alert-danger" th:text="${danger}"></div>
|
||||
<div th:if="${signup_error}" class="alert alert-danger" th:text="${signup_error}"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name" th:text="#{login.sign-up.name}">Nombre completo</label>
|
||||
<input type="text" class="form-control" id="name" th:field="*{name}" th:placeholder="#{login.sign-up.name}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
|
||||
</div>
|
||||
|
||||
<!-- En el caso del formulario de signup, asegúrate de bindear el DTO -->
|
||||
<form th:if="${form == '_signup'}" th:action="@{/signup}" method="post" th:object="${signupForm}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label>
|
||||
<input type="password" class="form-control" id="password-input" th:field="*{password}"
|
||||
th:placeholder="#{login.password-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="username" th:field="*{username}"
|
||||
th:placeholder="#{login.email-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-confirm-input" th:text="#{login.confirm-password}">Confirmar
|
||||
contraseña</label>
|
||||
<input type="password" class="form-control" id="password-confirm-input" th:field="*{passwordConfirm}"
|
||||
th:placeholder="#{login.password-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('passwordConfirm')}" th:errors="*{passwordConfirm}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name" th:text="#{login.sign-up.name}">Nombre completo</label>
|
||||
<input type="text" class="form-control" id="name" th:field="*{name}"
|
||||
th:placeholder="#{login.sign-up.name}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label>
|
||||
<input type="password" class="form-control" id="password-input" th:field="*{password}"
|
||||
th:placeholder="#{login.password-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-confirm-input" th:text="#{login.confirm-password}">Confirmar
|
||||
contraseña</label>
|
||||
<input type="password" class="form-control" id="password-confirm-input" th:field="*{passwordConfirm}"
|
||||
th:placeholder="#{login.password-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('passwordConfirm')}" th:errors="*{passwordConfirm}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.sign-up-button}">Crear
|
||||
cuenta</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.sign-up-button}">Crear
|
||||
cuenta</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -37,7 +37,15 @@
|
||||
<!-- end col -->
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div th:insert="~{${'imprimelibros/login/_items/' + form} :: ${form}}"></div>
|
||||
|
||||
<div class="p-lg-5 p-4">
|
||||
<div th:if="${info}" class="alert alert-info" th:text="${info}"></div>
|
||||
<div th:if="${danger}" class="alert alert-danger" th:text="${danger}"></div>
|
||||
<div th:if="${signup_error}" class="alert alert-danger"
|
||||
th:text="${signup_error}"></div>
|
||||
|
||||
<div th:insert="~{${'imprimelibros/login/_items/' + form} :: ${form}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- end col -->
|
||||
|
||||
132
src/test/java/com/imprimelibros/erp/verificationEmailTest.java
Normal file
132
src/test/java/com/imprimelibros/erp/verificationEmailTest.java
Normal file
@ -0,0 +1,132 @@
|
||||
package com.imprimelibros.erp;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Year;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import com.imprimelibros.erp.common.email.EmailService;
|
||||
|
||||
@SpringBootTest
|
||||
public class verificationEmailTest {
|
||||
|
||||
@Autowired
|
||||
private EmailService emailService;
|
||||
|
||||
|
||||
private SpringTemplateEngine buildEngineForTests() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setPrefix("templates/");
|
||||
resolver.setSuffix(".html");
|
||||
resolver.setTemplateMode(TemplateMode.HTML);
|
||||
resolver.setCharacterEncoding("UTF-8");
|
||||
resolver.setCacheable(false);
|
||||
|
||||
// MessageSource para tests (apunta a tus ficheros de la carpeta i18n)
|
||||
ResourceBundleMessageSource ms = new ResourceBundleMessageSource();
|
||||
ms.setBasenames(
|
||||
"i18n/app",
|
||||
"i18n/auth",
|
||||
"i18n/email", // <-- imprescindible para las claves email.*
|
||||
"i18n/login",
|
||||
"i18n/margenesPresupuesto",
|
||||
"i18n/presupuesto",
|
||||
"i18n/users",
|
||||
"i18n/validation");
|
||||
ms.setDefaultEncoding("UTF-8");
|
||||
ms.setFallbackToSystemLocale(false);
|
||||
|
||||
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||
engine.setTemplateResolver(resolver);
|
||||
engine.setTemplateEngineMessageSource(ms); // <-- clave
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private String readClasspath(String path) throws Exception {
|
||||
ClassPathResource res = new ClassPathResource(path);
|
||||
return StreamUtils.copyToString(res.getInputStream(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_verify_email_should_include_content_and_white_card() throws Exception {
|
||||
SpringTemplateEngine engine = buildEngineForTests();
|
||||
|
||||
Locale locale = Locale.forLanguageTag("es-ES");
|
||||
Context ctx = new Context(locale);
|
||||
String verifyUrl = "https://imprimelibros.com/verify?token=TESTTOKEN";
|
||||
ctx.setVariable("subject", "Verifica tu correo");
|
||||
ctx.setVariable("companyName", "ImprimeLibros");
|
||||
ctx.setVariable("year", Year.now().getValue());
|
||||
ctx.setVariable("fullName", "Juan Pérez");
|
||||
ctx.setVariable("verifyUrl", verifyUrl);
|
||||
ctx.setVariable("minutes", 60);
|
||||
|
||||
// CSS embebido para el test (si lo usas)
|
||||
String emailCss = readClasspath("static/assets/css/email.css");
|
||||
ctx.setVariable("emailCss", emailCss);
|
||||
|
||||
// === Render ===
|
||||
String html = engine.process("imprimelibros/email/verify", ctx);
|
||||
|
||||
// Vuelca siempre el HTML para inspección
|
||||
File outDir = new File("target/email-test");
|
||||
outDir.mkdirs();
|
||||
File out = new File(outDir, "verify.html");
|
||||
try (FileOutputStream fos = new FileOutputStream(out)) {
|
||||
fos.write(html.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
System.out.println("📧 HTML generado en: " + out.getAbsolutePath());
|
||||
|
||||
// === Asserts robustos (sin depender de traducciones) ===
|
||||
|
||||
// 1) El slot se ha reemplazado (aparece la celda y hay contenido dentro)
|
||||
assertThat(html)
|
||||
.as("Debe existir la celda del contenido")
|
||||
.contains("<td class=\"email-content\"");
|
||||
assertThat(html.replaceAll("\\s+", " "))
|
||||
.as("El fragmento debe inyectar párrafos dentro del slot")
|
||||
.containsPattern("<td class=\\\"email-content\\\"[^>]*>.*?<p>.*?</p>.*?</td>");
|
||||
|
||||
// 2) Debe aparecer la URL del botón de verificación
|
||||
assertThat(html).as("El verifyUrl debe estar inyectado").contains(verifyUrl);
|
||||
|
||||
// 3) Debe existir un enlace con clase 'btn' (el botón)
|
||||
assertThat(html).as("Debe existir el botón con clase .btn").contains("<a ", "class=\"btn");
|
||||
|
||||
// 4) Fondos (wrapper gris claro y tarjeta blanca) con bgcolor y/o inline
|
||||
assertThat(html).as("Wrapper debe tener fondo claro").contains("bgcolor=\"#f5f7fb\"");
|
||||
assertThat(html).as("Tarjeta debe tener fondo blanco").contains("bgcolor=\"#ffffff\"");
|
||||
|
||||
// 5) Logo inline presente
|
||||
assertThat(html).as("Logo CID debe estar").contains("cid:logo");
|
||||
|
||||
assertThat(html).doesNotContain("??email."); // sin claves sin resolver
|
||||
}
|
||||
|
||||
@Test
|
||||
void send_verification_email_live() {
|
||||
// Lee el destinatario del entorno para no hardcodearlo ni spamear
|
||||
String to = System.getenv().getOrDefault("MAIL_TO", "jaimejimenezortega@gmail.com");
|
||||
String verifyUrl = "https://imprimelibros.com/verify?token=LIVE_TEST";
|
||||
Locale locale = Locale.forLanguageTag("es-ES");
|
||||
|
||||
// Si no lanza excepción, damos el test por OK
|
||||
emailService.sendVerificationEmail(to, "Usuario", verifyUrl, locale);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user