diff --git a/src/main/java/com/imprimelibros/erp/users/ProfileController.java b/src/main/java/com/imprimelibros/erp/users/ProfileController.java
new file mode 100644
index 0000000..a43fbd4
--- /dev/null
+++ b/src/main/java/com/imprimelibros/erp/users/ProfileController.java
@@ -0,0 +1,155 @@
+package com.imprimelibros.erp.users;
+
+import java.util.Locale;
+
+import org.springframework.context.MessageSource;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import com.imprimelibros.erp.config.Sanitizer;
+import com.imprimelibros.erp.users.validation.ProfileForm;
+
+@Controller
+@RequestMapping("/pages-profile")
+@PreAuthorize("isAuthenticated()")
+public class ProfileController {
+
+ private final UserDao userDao;
+ private final PasswordEncoder passwordEncoder;
+ private final MessageSource messageSource;
+ private final Sanitizer sanitizer;
+
+ public ProfileController(UserDao userDao, PasswordEncoder passwordEncoder,
+ MessageSource messageSource, Sanitizer sanitizer) {
+ this.userDao = userDao;
+ this.passwordEncoder = passwordEncoder;
+ this.messageSource = messageSource;
+ this.sanitizer = sanitizer;
+ }
+
+ @GetMapping
+ public String view(
+ Authentication authentication,
+ @RequestParam(name = "success", required = false) String success,
+ Model model,
+ Locale locale) {
+
+ if (authentication == null) {
+ return "redirect:/login";
+ }
+
+ User user = userDao.findByUserNameIgnoreCase(authentication.getName()).orElse(null);
+ if (user == null) {
+ return "redirect:/login";
+ }
+
+ ProfileForm form = new ProfileForm();
+ form.setId(user.getId());
+ form.setFullName(user.getFullName());
+ form.setUserName(user.getUserName());
+
+ model.addAttribute("user", form);
+ model.addAttribute("success", success != null);
+ return "imprimelibros/users/profile";
+ }
+
+ @PostMapping
+ public String update(
+ Authentication authentication,
+ @Validated @ModelAttribute("user") ProfileForm form,
+ BindingResult binding,
+ Model model,
+ RedirectAttributes redirectAttributes,
+ Locale locale) {
+
+ if (authentication == null) {
+ return "redirect:/login";
+ }
+
+ User user = userDao.findByUserNameIgnoreCase(authentication.getName()).orElse(null);
+ if (user == null) {
+ return "redirect:/login";
+ }
+
+ String normalized = sanitizer.plain(form.getUserName());
+ if (normalized != null) {
+ normalized = normalized.trim().toLowerCase();
+ }
+
+ if (normalized == null || normalized.isBlank()) {
+ binding.rejectValue("userName", "usuarios.error.email",
+ messageSource.getMessage("usuarios.error.email", null, locale));
+ } else if (userDao.existsByUserNameIgnoreCaseAndIdNot(normalized, user.getId())) {
+ binding.rejectValue("userName", "usuarios.error.duplicado",
+ messageSource.getMessage("usuarios.error.duplicado", null, locale));
+ }
+
+ String cleanName = sanitizer.plain(form.getFullName());
+ if (cleanName == null || cleanName.isBlank()) {
+ binding.rejectValue("fullName", "usuarios.error.nombre",
+ messageSource.getMessage("usuarios.error.nombre", null, locale));
+ }
+
+ boolean wantsPasswordChange = hasText(form.getCurrentPassword())
+ || hasText(form.getNewPassword())
+ || hasText(form.getConfirmPassword());
+
+ if (wantsPasswordChange) {
+ if (!hasText(form.getCurrentPassword())) {
+ binding.rejectValue("currentPassword", "usuarios.error.password.actual",
+ messageSource.getMessage("usuarios.error.password.actual", null, locale));
+ } else if (!passwordEncoder.matches(form.getCurrentPassword(), user.getPassword())) {
+ binding.rejectValue("currentPassword", "usuarios.error.password.actual.incorrecta",
+ messageSource.getMessage("usuarios.error.password.actual.incorrecta", null, locale));
+ }
+
+ if (!hasText(form.getNewPassword())) {
+ binding.rejectValue("newPassword", "usuarios.error.password.nueva.requerida",
+ messageSource.getMessage("usuarios.error.password.nueva.requerida", null, locale));
+ } else if (form.getNewPassword().length() < 6) {
+ binding.rejectValue("newPassword", "usuarios.error.password.min",
+ messageSource.getMessage("usuarios.error.password.min", null, locale));
+ }
+
+ if (!hasText(form.getConfirmPassword())) {
+ binding.rejectValue("confirmPassword", "usuarios.error.confirmPassword.requerida",
+ messageSource.getMessage("usuarios.error.confirmPassword.requerida", null, locale));
+ } else if (hasText(form.getNewPassword()) && !form.getNewPassword().equals(form.getConfirmPassword())) {
+ binding.rejectValue("confirmPassword", "usuarios.error.password-coinciden",
+ messageSource.getMessage("usuarios.error.password-coinciden", null, locale));
+ }
+ }
+
+ if (binding.hasErrors()) {
+ model.addAttribute("success", false);
+ return "imprimelibros/users/profile";
+ }
+
+ user.setFullName(cleanName.trim());
+ user.setUserName(normalized);
+
+ if (wantsPasswordChange) {
+ user.setPassword(passwordEncoder.encode(form.getNewPassword()));
+ }
+
+ userDao.save(user);
+
+ redirectAttributes.addAttribute("success", "1");
+ return "redirect:/pages-profile";
+ }
+
+ private static boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/src/main/java/com/imprimelibros/erp/users/validation/ProfileForm.java b/src/main/java/com/imprimelibros/erp/users/validation/ProfileForm.java
new file mode 100644
index 0000000..0055444
--- /dev/null
+++ b/src/main/java/com/imprimelibros/erp/users/validation/ProfileForm.java
@@ -0,0 +1,68 @@
+package com.imprimelibros.erp.users.validation;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public class ProfileForm {
+
+ private Long id;
+
+ @NotBlank(message = "{usuarios.error.nombre}")
+ private String fullName;
+
+ @NotBlank(message = "{usuarios.error.email}")
+ @Email(message = "{usuarios.error.email.formato}")
+ private String userName;
+
+ private String currentPassword;
+ private String newPassword;
+ private String confirmPassword;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getFullName() {
+ return fullName;
+ }
+
+ public void setFullName(String fullName) {
+ this.fullName = fullName;
+ }
+
+ public String getUserName() {
+ return userName;
+ }
+
+ public void setUserName(String userName) {
+ this.userName = userName;
+ }
+
+ public String getCurrentPassword() {
+ return currentPassword;
+ }
+
+ public void setCurrentPassword(String currentPassword) {
+ this.currentPassword = currentPassword;
+ }
+
+ public String getNewPassword() {
+ return newPassword;
+ }
+
+ public void setNewPassword(String newPassword) {
+ this.newPassword = newPassword;
+ }
+
+ public String getConfirmPassword() {
+ return confirmPassword;
+ }
+
+ public void setConfirmPassword(String confirmPassword) {
+ this.confirmPassword = confirmPassword;
+ }
+}
diff --git a/src/main/resources/i18n/users_en.properties b/src/main/resources/i18n/users_en.properties
index 73daca1..9f1b17e 100644
--- a/src/main/resources/i18n/users_en.properties
+++ b/src/main/resources/i18n/users_en.properties
@@ -1,3 +1,23 @@
+usuarios.form.nombre=Full name
+usuarios.form.email=Email
+usuarios.form.confirmarPassword=Confirm password
+usuarios.form.password.actual=Current password
+usuarios.form.password.nueva=New password
+usuarios.form.password.nota=You can only change the password if you provide the current one.
+
+usuarios.error.nombre=Name is required.
+usuarios.error.email=Email is required.
+usuarios.error.email.formato=Email is not valid.
+usuarios.error.password.min=Password must be at least 6 characters.
+usuarios.error.password.actual=Current password is required.
+usuarios.error.password.actual.incorrecta=Current password is not correct.
+usuarios.error.password.nueva.requerida=New password is required.
+usuarios.error.confirmPassword.requerida=Password confirmation is required.
+usuarios.error.password-coinciden=Passwords do not match.
+usuarios.error.duplicado=There is already a user with that email.
+
usuarios.impersonate.title=Sign in as user
usuarios.impersonate.text=You are about to sign in as {0}. You can return to your user from the menu.
usuarios.impersonate.button=Continue
+usuarios.profile.title=Edit profile
+usuarios.profile.success=Profile updated successfully.
diff --git a/src/main/resources/i18n/users_es.properties b/src/main/resources/i18n/users_es.properties
index c528825..fcd1b40 100644
--- a/src/main/resources/i18n/users_es.properties
+++ b/src/main/resources/i18n/users_es.properties
@@ -20,6 +20,9 @@ usuarios.form.nombre=Nombre completo
usuarios.form.email=Correo electrónico
usuarios.form.password=Contraseña
usuarios.form.confirmarPassword=Confirmar contraseña
+usuarios.form.password.actual=Contraseña actual
+usuarios.form.password.nueva=Nueva contraseña
+usuarios.form.password.nota=Solo podrás cambiar la contraseña si indicas la actual.
usuarios.form.rol=Rol
usuarios.form.estado=Estado
@@ -37,6 +40,9 @@ usuarios.error.email.formato=El correo electrónico no es válido.
usuarios.error.rol=El rol seleccionado no es válido.
usuarios.error.password.requerida=La contraseña es obligatoria.
usuarios.error.password.min=La contraseña debe tener al menos 6 caracteres.
+usuarios.error.password.actual=La contraseña actual es obligatoria.
+usuarios.error.password.actual.incorrecta=La contraseña actual no es correcta.
+usuarios.error.password.nueva.requerida=La nueva contraseña es obligatoria.
usuarios.error.confirmPassword.requerida=La confirmación de la contraseña es obligatoria.
usuarios.error.password-coinciden=Las contraseñas no coinciden.
usuarios.error.delete-relational-data=No se puede eliminar el usuario porque tiene datos relacionados.
@@ -55,5 +61,7 @@ usuarios.delete.text=¿Está seguro de que desea eliminar al usuario?
Esta ac
usuarios.delete.ok.title=Usuario eliminado
usuarios.delete.ok.text=El usuario ha sido eliminado con éxito.
usuarios.impersonate.title=Entrar como usuario
-usuarios.impersonate.text=Vas a iniciar sesión como {0}. Podrás volver a tu usuario desde el menú.
+usuarios.impersonate.text=Vas a iniciar sesión como {0}. Podrás volver a tu usuario desde el menú.
usuarios.impersonate.button=Entrar
+usuarios.profile.title=Editar perfil
+usuarios.profile.success=Perfil actualizado correctamente.
diff --git a/src/main/resources/templates/imprimelibros/partials/topbar.html b/src/main/resources/templates/imprimelibros/partials/topbar.html
index a8f5dc1..f58ea4a 100644
--- a/src/main/resources/templates/imprimelibros/partials/topbar.html
+++ b/src/main/resources/templates/imprimelibros/partials/topbar.html
@@ -100,9 +100,6 @@
Perfil
-
- Mensajes