diff --git a/src/main/java/com/imprimelibros/erp/common/email/EmailService.java b/src/main/java/com/imprimelibros/erp/common/email/EmailService.java index 7b13e8d..ead795e 100644 --- a/src/main/java/com/imprimelibros/erp/common/email/EmailService.java +++ b/src/main/java/com/imprimelibros/erp/common/email/EmailService.java @@ -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 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 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 variables) { + private void sendEmail(String to, String subject, String template, Map 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 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); } } + } diff --git a/src/main/java/com/imprimelibros/erp/login/LoginController.java b/src/main/java/com/imprimelibros/erp/login/LoginController.java index 2e574f1..545b39d 100644 --- a/src/main/java/com/imprimelibros/erp/login/LoginController.java +++ b/src/main/java/com/imprimelibros/erp/login/LoginController.java @@ -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"; } diff --git a/src/main/java/com/imprimelibros/erp/login/SignupService.java b/src/main/java/com/imprimelibros/erp/login/SignupService.java index 3ba08f0..87aea1b 100644 --- a/src/main/java/com/imprimelibros/erp/login/SignupService.java +++ b/src/main/java/com/imprimelibros/erp/login/SignupService.java @@ -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); diff --git a/src/main/java/com/imprimelibros/erp/users/User.java b/src/main/java/com/imprimelibros/erp/users/User.java index 3576e4f..b60953f 100644 --- a/src/main/java/com/imprimelibros/erp/users/User.java +++ b/src/main/java/com/imprimelibros/erp/users/User.java @@ -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 rolesLink = new HashSet<>(); diff --git a/src/main/java/com/imprimelibros/erp/users/UserDao.java b/src/main/java/com/imprimelibros/erp/users/UserDao.java index 3afd2e9..cf62722 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserDao.java +++ b/src/main/java/com/imprimelibros/erp/users/UserDao.java @@ -29,6 +29,15 @@ public interface UserDao extends JpaRepository, 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 findLiteByUserNameIgnoreCase(@Param("userName") String userName); + boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id); // Nuevo: para login/negocio "activo" diff --git a/src/main/java/com/imprimelibros/erp/users/UserLite.java b/src/main/java/com/imprimelibros/erp/users/UserLite.java new file mode 100644 index 0000000..0f752af --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/users/UserLite.java @@ -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(); +} diff --git a/src/main/resources/i18n/login_es.properties b/src/main/resources/i18n/login_es.properties index 42d2669..500bd37 100644 --- a/src/main/resources/i18n/login_es.properties +++ b/src/main/resources/i18n/login_es.properties @@ -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 \ No newline at end of file +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. \ No newline at end of file diff --git a/src/main/resources/static/assets/css/email.css b/src/main/resources/static/assets/css/email.css index 012eb9c..331ce3b 100644 --- a/src/main/resources/static/assets/css/email.css +++ b/src/main/resources/static/assets/css/email.css @@ -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; } diff --git a/src/main/resources/static/assets/images/logo-light-email.png b/src/main/resources/static/assets/images/logo-light-email.png new file mode 100644 index 0000000..e7ed384 Binary files /dev/null and b/src/main/resources/static/assets/images/logo-light-email.png differ diff --git a/src/main/resources/templates/imprimelibros/email/layout.html b/src/main/resources/templates/imprimelibros/email/layout.html index 877b5d9..60e3976 100644 --- a/src/main/resources/templates/imprimelibros/email/layout.html +++ b/src/main/resources/templates/imprimelibros/email/layout.html @@ -1,52 +1,84 @@ - - + - - - Notificación - + + + Notificación + + + + + + + + + + - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/imprimelibros/email/verify.html b/src/main/resources/templates/imprimelibros/email/verify.html index 3d51595..0db410d 100644 --- a/src/main/resources/templates/imprimelibros/email/verify.html +++ b/src/main/resources/templates/imprimelibros/email/verify.html @@ -1,25 +1,45 @@ - - - -

Hola usuario,

-

Haz clic en el siguiente botón para verificar tu correo electrónico: + + +

+ Hola + Usuario,

-

- - Verificar cuenta - +

+ + Haz clic en el siguiente botón para verificar tu correo electrónico: +

-

Si no funciona, copia y pega esta URL en tu navegador: +

+ + Verificar cuenta +

-

https://...

-

Este enlace caduca en 60 minutos.

- -

Si no solicitaste este cambio, puedes ignorar este mensaje. +

+ + Si no funciona, copia y pega esta URL en tu navegador: +

- - \ No newline at end of file +

+ https://... +

+ +

+ + Este enlace caduca en 60 minutos. + +

+ +

+ + Si no solicitaste este cambio, puedes ignorar este mensaje. + +

+ + diff --git a/src/main/resources/templates/imprimelibros/login/_items/_login.html b/src/main/resources/templates/imprimelibros/login/_items/_login.html index ca709fb..13479f0 100644 --- a/src/main/resources/templates/imprimelibros/login/_items/_login.html +++ b/src/main/resources/templates/imprimelibros/login/_items/_login.html @@ -1,63 +1,60 @@
-
-
-
¡Bienvenido!
-

Inicie sesión para continuar:

-
-
-
- - +
+
¡Bienvenido!
+

Inicie sesión para continuar:

+
-
- Credenciales inválidas +
+ + + + +
+ Credenciales inválidas +
+ +
+ + +
+ +
+ - -
- - + +
+ +
+
-
- - -
- - -
-
+
+ + +
-
- - -
- -
- -
+
+ +
- -
+ +
-
-

- ¿No tienes una cuenta? - - Regístrate - -

-
+
+

+ ¿No tienes una cuenta? + + Regístrate + +

\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/login/_items/_signup.html b/src/main/resources/templates/imprimelibros/login/_items/_signup.html index c8a4ac0..585d537 100644 --- a/src/main/resources/templates/imprimelibros/login/_items/_signup.html +++ b/src/main/resources/templates/imprimelibros/login/_items/_signup.html @@ -1,56 +1,51 @@
-
-
-
¡Bienvenido!
-

Crear cuenta

+ + +
+
¡Bienvenido!
+

Crear cuenta

+
+ + +
+ + + + + +
+ + +
-
-
-
+
+ + +
+
- - +
+ + +
+
- - - - -
- - -
+
+ + +
+
-
- - -
-
- -
- - -
-
- -
- - -
-
-
- -
- -
- -
+
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/login/login.html b/src/main/resources/templates/imprimelibros/login/login.html index ff5be3e..e3f3cda 100644 --- a/src/main/resources/templates/imprimelibros/login/login.html +++ b/src/main/resources/templates/imprimelibros/login/login.html @@ -37,7 +37,15 @@
-
+ +
+
+
+
+ +
+
diff --git a/src/test/java/com/imprimelibros/erp/verificationEmailTest.java b/src/test/java/com/imprimelibros/erp/verificationEmailTest.java new file mode 100644 index 0000000..ccaf2e8 --- /dev/null +++ b/src/test/java/com/imprimelibros/erp/verificationEmailTest.java @@ -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("]*>.*?

.*?

.*?"); + + // 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("