diff --git a/pom.xml b/pom.xml index 33df75e..05266e8 100644 --- a/pom.xml +++ b/pom.xml @@ -102,6 +102,19 @@ 1.17.2 + + org.jsoup + jsoup + 1.17.2 + + + + + org.owasp.encoder + encoder + 1.3.1 + + diff --git a/src/main/java/com/imprimelibros/erp/config/Sanitizer.java b/src/main/java/com/imprimelibros/erp/config/Sanitizer.java new file mode 100644 index 0000000..3eb2c5e --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/config/Sanitizer.java @@ -0,0 +1,25 @@ +package com.imprimelibros.erp.config; + +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; +import org.springframework.stereotype.Component; + +@Component +public class Sanitizer { + + // Sin HTML: todo a texto plano + public String plain(String input) { + if (input == null) return null; + String cleaned = Jsoup.clean(input, Safelist.none()); + return cleaned.strip(); + } + + // HTML mínimo permitido (opcional) + public String minimalHtml(String input) { + if (input == null) return null; + Safelist wl = Safelist.basic(); // b, i, em, strong, a... + wl.addTags("ul","ol","li"); // añade lo que necesites + wl.addAttributes("a","rel","nofollow"); // endurece enlaces + return Jsoup.clean(input, wl); + } +} diff --git a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java index 3645a9b..b2d8e25 100644 --- a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java +++ b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java @@ -5,7 +5,6 @@ import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; diff --git a/src/main/java/com/imprimelibros/erp/datatables/DataTable.java b/src/main/java/com/imprimelibros/erp/datatables/DataTable.java index 53a9a3d..c620ced 100644 --- a/src/main/java/com/imprimelibros/erp/datatables/DataTable.java +++ b/src/main/java/com/imprimelibros/erp/datatables/DataTable.java @@ -7,7 +7,6 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import java.util.*; import java.util.function.BiConsumer; -import java.util.function.BiFunction; import java.util.function.Function; public class DataTable { @@ -28,6 +27,7 @@ public class DataTable { private final List> filters = new ArrayList<>(); private Specification baseSpec = (root, q, cb) -> cb.conjunction(); private final ObjectMapper om = new ObjectMapper(); + private List orderable = null; private DataTable(JpaSpecificationExecutor repo, Class entityClass, DataTablesRequest dt, List searchable) { @@ -79,6 +79,15 @@ public class DataTable { return this; } + public DataTable orderable(List fields) { + this.orderable = fields; + return this; + } + + private List getOrderable() { + return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable; + } + /** filter((builder, req) -> builder.add(miExtraSpec(req))) */ public DataTable filter(FilterHook hook) { filters.add(hook); @@ -105,7 +114,7 @@ public class DataTable { continue; if (!col.orderable) continue; - if (!searchable.contains(field)) + if (!getOrderable().contains(field)) continue; // << usa tu whitelist orders.add(new Sort.Order( @@ -117,7 +126,7 @@ public class DataTable { } else { for (var c : dt.columns) { if (c != null && c.orderable && c.name != null && !c.name.isBlank() - && searchable.contains(c.name)) { + && getOrderable().contains(c.name)) { sort = Sort.by(c.name); break; } diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java index 4f4883e..47a8f38 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoController.java @@ -3,7 +3,6 @@ package com.imprimelibros.erp.presupuesto; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import java.math.BigDecimal; import java.util.HashMap; import java.util.Locale; import java.util.Map; diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java index deea724..5b4e095 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/PresupuestoService.java @@ -6,14 +6,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.BiFunction; import java.util.stream.Collectors; import java.util.Locale; import java.text.NumberFormat; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import com.fasterxml.jackson.core.JsonProcessingException; diff --git a/src/main/java/com/imprimelibros/erp/presupuesto/classes/PresupuestoFormatter.java b/src/main/java/com/imprimelibros/erp/presupuesto/classes/PresupuestoFormatter.java index c3e6980..a01c41f 100644 --- a/src/main/java/com/imprimelibros/erp/presupuesto/classes/PresupuestoFormatter.java +++ b/src/main/java/com/imprimelibros/erp/presupuesto/classes/PresupuestoFormatter.java @@ -6,7 +6,6 @@ import com.imprimelibros.erp.i18n.TranslationService; import com.imprimelibros.erp.presupuesto.Presupuesto; import org.springframework.context.MessageSource; -import java.util.Arrays; import java.util.Locale; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/imprimelibros/erp/users/RoleDao.java b/src/main/java/com/imprimelibros/erp/users/RoleDao.java index 6208d8a..9d4fa69 100644 --- a/src/main/java/com/imprimelibros/erp/users/RoleDao.java +++ b/src/main/java/com/imprimelibros/erp/users/RoleDao.java @@ -1,9 +1,9 @@ package com.imprimelibros.erp.users; -import com.imprimelibros.erp.users.Role; +import java.util.Optional; public interface RoleDao { - public Role findRoleByName(String theRoleName); + Optional findRoleByName(String theRoleName); } diff --git a/src/main/java/com/imprimelibros/erp/users/RoleDaoImpl.java b/src/main/java/com/imprimelibros/erp/users/RoleDaoImpl.java index 2c808b9..870ae37 100644 --- a/src/main/java/com/imprimelibros/erp/users/RoleDaoImpl.java +++ b/src/main/java/com/imprimelibros/erp/users/RoleDaoImpl.java @@ -2,6 +2,9 @@ package com.imprimelibros.erp.users; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; + +import java.util.Optional; + import org.springframework.stereotype.Repository; @Repository @@ -14,7 +17,7 @@ public class RoleDaoImpl implements RoleDao { } @Override - public Role findRoleByName(String theRoleName) { + public Optional findRoleByName(String theRoleName) { // retrieve/read from database using name TypedQuery theQuery = entityManager.createQuery("from Role where name=:roleName", Role.class); @@ -27,7 +30,6 @@ public class RoleDaoImpl implements RoleDao { } catch (Exception e) { theRole = null; } - - return theRole; + return Optional.ofNullable(theRole); } } diff --git a/src/main/java/com/imprimelibros/erp/users/User.java b/src/main/java/com/imprimelibros/erp/users/User.java index 2bb794b..c437c1f 100644 --- a/src/main/java/com/imprimelibros/erp/users/User.java +++ b/src/main/java/com/imprimelibros/erp/users/User.java @@ -1,35 +1,67 @@ package com.imprimelibros.erp.users; import jakarta.persistence.*; -import java.util.Collection; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +import java.util.Set; + +import org.hibernate.annotations.Formula; @Entity -@Table(name = "users") +@Table(name = "users", uniqueConstraints = { + @UniqueConstraint(name = "uk_users_username", columnNames = "username") +}) public class User { - + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @Column(name = "fullname") + @NotBlank(message = "{validation.required}") private String fullName; - @Column(name = "username") + @Column(name = "username", nullable = false, length = 190) + @Email(message = "{validation.email}") + @NotBlank(message = "{validation.required}") private String userName; - + @Column(name = "password") + @NotBlank(message = "{validation.required}") private String password; - + @Column(name = "enabled") private boolean enabled; @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) - @JoinTable(name = "users_roles", - joinColumns = @JoinColumn(name = "user_id"), - inverseJoinColumns = @JoinColumn(name = "role_id")) - private Collection roles; + @JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) + private Set roles = new java.util.HashSet<>(); + // SUPERADMIN=3, ADMIN=2, USER=1 (ajusta a tus nombres reales) + @Formula(""" + ( + select coalesce(max( + case r.name + when 'SUPERADMIN' then 3 + when 'ADMIN' then 2 + else 1 + end + ), 0) + from users_roles ur + join roles r on r.id = ur.role_id + where ur.user_id = id + ) + """) + private Integer roleRank; + + @Formula(""" + (select group_concat(lower(r.name) order by r.name separator ', ') + from users_roles ur join roles r on r.id = ur.role_id + where ur.user_id = id) + """) + private String rolesConcat; /* Constructors */ public User() { @@ -43,7 +75,7 @@ public class User { } public User(String fullName, String userName, String password, boolean enabled, - Collection roles) { + Set roles) { this.fullName = fullName; this.userName = userName; this.password = password; @@ -93,14 +125,22 @@ public class User { this.enabled = enabled; } - public Collection getRoles() { + public Set getRoles() { return roles; } - public void setRoles(Collection roles) { + public void setRoles(Set roles) { this.roles = roles; } + public Integer getRoleRank() { + return roleRank; + } + + public String getRolesConcat() { + return rolesConcat; + } + @Override public String toString() { return "User{" + @@ -112,5 +152,5 @@ public class User { ", roles=" + roles + '}'; } - + } diff --git a/src/main/java/com/imprimelibros/erp/users/UserController.java b/src/main/java/com/imprimelibros/erp/users/UserController.java index c4f3714..b55e367 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserController.java +++ b/src/main/java/com/imprimelibros/erp/users/UserController.java @@ -1,27 +1,41 @@ package com.imprimelibros.erp.users; import com.imprimelibros.erp.datatables.DataTablesResponse; +import com.imprimelibros.erp.users.validation.UserForm; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.MessageSource; import org.springframework.data.jpa.domain.Specification; 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.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import com.imprimelibros.erp.datatables.DataTablesRequest; import com.imprimelibros.erp.datatables.DataTablesParser; +import com.imprimelibros.erp.config.Sanitizer; import com.imprimelibros.erp.datatables.DataTable; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import java.util.List; import java.util.Locale; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; @Controller @PreAuthorize("hasRole('ADMIN') or hasRole('SUPERADMIN')") @@ -29,11 +43,18 @@ import java.util.Locale; public class UserController { private UserDao repo; + private RoleDao roleRepo; private MessageSource messageSource; + private Sanitizer sanitizer; + private PasswordEncoder passwordEncoder; - public UserController(UserDao repo, UserService userService, MessageSource messageSource) { + public UserController(UserDao repo, UserService userService, MessageSource messageSource, Sanitizer sanitizer, + PasswordEncoder passwordEncoder, RoleDao roleRepo) { this.repo = repo; this.messageSource = messageSource; + this.sanitizer = sanitizer; + this.roleRepo = roleRepo; + this.passwordEncoder = passwordEncoder; } @GetMapping @@ -52,31 +73,215 @@ public class UserController { // OJO: en la whitelist mete solo columnas "reales" y escalares (no relaciones). // Si 'role' es relación, sácalo de aquí: - List whitelist = List.of("fullName", "userName", "enabled"); + List searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de + // verdad + List orderable = List.of("fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas columnas Specification base = (root, query, cb) -> cb.conjunction(); long total = repo.count(); return DataTable - .of(repo, User.class, dt, whitelist) // 'searchable' en DataTable.java + .of(repo, User.class, dt, searchable) // 'searchable' en DataTable.java + // edita columnas "reales": + .orderable(orderable) .edit("enabled", (User u) -> { if (u.isEnabled()) { - return "" + messageSource.getMessage("usuarios.tabla.activo", null, locale) + ""; + return "" + + messageSource.getMessage("usuarios.tabla.activo", null, locale) + ""; } else { - return "" + messageSource.getMessage("usuarios.tabla.inactivo", null, locale) + ""; + return "" + + messageSource.getMessage("usuarios.tabla.inactivo", null, locale) + ""; } }) - // si 'role' es relación, crea una columna calculada “segura”: // acciones virtuales: - .add("roles", (User u) -> u.getRoles().stream().map(Role::getName).collect(Collectors.joining(", "))) + .add("roles", (User u) -> u.getRoles().stream() + .map(Role::getName) + .map(String::toLowerCase) + .map(rol -> "" + + messageSource.getMessage("usuarios.rol." + rol, null, locale) + "") + .collect(Collectors.joining(" "))) .add("actions", (user) -> { return "
\n" + - " \n" + - " \n" + + " \n" + + " \n" + "
"; }) .where(base) .toJson(total); } + @GetMapping("form") + public String getForm(@RequestParam(required = false) Long id, + @ModelAttribute("user") UserForm form, + BindingResult binding, + Model model, + HttpServletResponse response, + Locale locale) { + + if (id != null) { + var opt = repo.findById(id); + if (opt.isEmpty()) { + binding.reject("usuarios.error.noEncontrado", + messageSource.getMessage("usuarios.error.noEncontrado", null, locale)); + response.setStatus(404); + model.addAttribute("action", "/users/" + id); + return "imprimelibros/users/user-form :: userForm"; + } + + User u = opt.get(); + // map ENTIDAD -> DTO (¡no metas la entidad en "user"!) + form.setId(u.getId()); + form.setFullName(u.getFullName()); + form.setUserName(u.getUserName()); + form.setEnabled(u.isEnabled()); + form.setRoleName(u.getRoles().stream().findFirst().map(Role::getName).orElse("USER")); + form.setPassword(null); + form.setConfirmPassword(null); + + model.addAttribute("action", "/users/" + id); + } else { + // Crear: valores por defecto + form.setEnabled(true); + model.addAttribute("action", "/users"); + } + return "imprimelibros/users/user-form :: userForm"; + } + + @PostMapping + public String create( + @Validated(UserForm.Create.class) @ModelAttribute("user") UserForm form, + BindingResult binding, + Model model, + HttpServletResponse response, + Locale locale) { + + String normalized = sanitizer.plain(form.getUserName().trim()); + + if (repo.existsByUserNameIgnoreCase(normalized)) { + binding.rejectValue("userName", "validation.unique", + messageSource.getMessage("usuarios.error.duplicado", null, locale)); + } + + var optRole = roleRepo.findRoleByName(form.getRoleName()); + if (optRole.isEmpty()) { + binding.rejectValue("roleName", "usuarios.errores.rol.invalido", + messageSource.getMessage("usuarios.error.rol", null, locale)); + } + + if (binding.hasErrors()) { + response.setStatus(422); // <- clave + model.addAttribute("action", "/users"); + return "imprimelibros/users/user-form :: userForm"; + } + + User u = new User(); + u.setFullName(sanitizer.plain(form.getFullName())); + u.setUserName(normalized.toLowerCase()); + u.setPassword(passwordEncoder.encode(form.getPassword())); + java.util.Set roles = new java.util.HashSet<>(); + roles.add(optRole.get()); + u.setRoles(roles); + u.setEnabled(Boolean.TRUE.equals(form.getEnabled())); + try { + repo.save(u); + } catch (org.springframework.dao.DataIntegrityViolationException ex) { + // carrera contra otra inserción: vuelve como error de campo + binding.rejectValue("userName", "validation.unique", + messageSource.getMessage("usuarios.error.duplicado", null, locale)); + response.setStatus(422); + model.addAttribute("action", "/users"); + return "imprimelibros/users/user-form :: userForm"; + } + + response.setStatus(204); + return null; + } + + @PutMapping("/{id}") + public String edit( + @PathVariable Long id, + @Validated(UserForm.Update.class) @ModelAttribute("user") UserForm form, + BindingResult binding, + Model model, + HttpServletResponse response, + Locale locale) { + + var uOpt = repo.findById(id); + if (uOpt.isEmpty()) { + binding.reject("usuarios.error.noEncontrado", + messageSource.getMessage("usuarios.error.noEncontrado", null, locale)); + } + + String normalized = sanitizer.plain(form.getUserName()).trim(); + if (repo.existsByUserNameIgnoreCaseAndIdNot(normalized, id)) { + binding.rejectValue("userName", "validation.unique", + messageSource.getMessage("usuarios.error.duplicado", null, locale)); + } + + var optRole = roleRepo.findRoleByName(form.getRoleName()); + if (optRole.isEmpty()) { + binding.rejectValue("roleName", "usuarios.errores.rol.invalido", + messageSource.getMessage("usuarios.error.rol", null, locale)); + } + + if (binding.hasErrors()) { + response.setStatus(422); + model.addAttribute("action", "/users/" + id); + return "imprimelibros/users/user-form :: userForm"; + } + + var u = uOpt.get(); + u.setFullName(sanitizer.plain(form.getFullName()).trim()); + u.setUserName(normalized.toLowerCase()); + if (form.getPassword() != null && !form.getPassword().isBlank()) { + u.setPassword(passwordEncoder.encode(form.getPassword())); + } + u.setRoles(new java.util.HashSet<>(java.util.List.of(optRole.get()))); + u.setEnabled(Boolean.TRUE.equals(form.getEnabled())); + try { + repo.save(u); + } catch (org.springframework.dao.DataIntegrityViolationException ex) { + binding.rejectValue("userName", "validation.unique", + messageSource.getMessage("usuarios.error.duplicado", null, locale)); + response.setStatus(422); + model.addAttribute("action", "/users/" + id); + return "imprimelibros/users/user-form :: userForm"; + } + + response.setStatus(204); + return null; + } + + @DeleteMapping("/{id}") + @ResponseBody + public void delete(@PathVariable Long id, HttpServletResponse response, Authentication authentication) { + var uOpt = repo.findById(id); + if (uOpt.isEmpty()) { + response.setStatus(404); + return; + } + var u = uOpt.get(); + String currentUserName = authentication.getName(); + if (u.getUserName().equalsIgnoreCase(currentUserName)) { + response.setStatus(403); // no puede borrarse a sí mismo + return; + } + try { + repo.delete(u); + } catch (Exception ex) { + response.setStatus(500); + } + // Si llegamos aquí, la eliminación fue exitosa + /* + * response.setStatus(204); + * response.getWriter().flush(); + * response.getWriter().close(); + */ + + return; + + } + } diff --git a/src/main/java/com/imprimelibros/erp/users/UserDao.java b/src/main/java/com/imprimelibros/erp/users/UserDao.java index 5ab6648..3be9126 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserDao.java +++ b/src/main/java/com/imprimelibros/erp/users/UserDao.java @@ -9,4 +9,6 @@ public interface UserDao extends JpaRepository, JpaSpecificationExec User findByUserNameAndEnabledTrue(String userName); + boolean existsByUserNameIgnoreCase(String userName); + boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id); } diff --git a/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java b/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java index 8708373..b4bb1cb 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java +++ b/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java @@ -1,6 +1,5 @@ package com.imprimelibros.erp.users; -import com.imprimelibros.erp.users.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; diff --git a/src/main/java/com/imprimelibros/erp/users/UserService.java b/src/main/java/com/imprimelibros/erp/users/UserService.java index dbf43f9..9721c7a 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserService.java +++ b/src/main/java/com/imprimelibros/erp/users/UserService.java @@ -1,6 +1,5 @@ package com.imprimelibros.erp.users; -import com.imprimelibros.erp.users.User; import org.springframework.security.core.userdetails.UserDetailsService; public interface UserService extends UserDetailsService { diff --git a/src/main/java/com/imprimelibros/erp/users/validation/PasswordsMatch.java b/src/main/java/com/imprimelibros/erp/users/validation/PasswordsMatch.java new file mode 100644 index 0000000..e1667f2 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/users/validation/PasswordsMatch.java @@ -0,0 +1,21 @@ +package com.imprimelibros.erp.users.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target(TYPE) +@Retention(RUNTIME) +@Constraint(validatedBy = PasswordsMatchValidator.class) +public @interface PasswordsMatch { + String message() default "{usuarios.error.password-coinciden}"; + Class[] groups() default {}; + Class[] payload() default {}; + String password(); + String confirmPassword(); +} \ No newline at end of file diff --git a/src/main/java/com/imprimelibros/erp/users/validation/PasswordsMatchValidator.java b/src/main/java/com/imprimelibros/erp/users/validation/PasswordsMatchValidator.java new file mode 100644 index 0000000..a94050d --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/users/validation/PasswordsMatchValidator.java @@ -0,0 +1,30 @@ +package com.imprimelibros.erp.users.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.beans.PropertyDescriptor; + +public class PasswordsMatchValidator implements ConstraintValidator { + private String passwordField; + private String confirmPasswordField; + + @Override + public void initialize(PasswordsMatch constraint) { + this.passwordField = constraint.password(); + this.confirmPasswordField = constraint.confirmPassword(); + } + + @Override + public boolean isValid(Object value, ConstraintValidatorContext context) { + try { + Object password = new PropertyDescriptor(passwordField, value.getClass()) + .getReadMethod().invoke(value); + Object confirm = new PropertyDescriptor(confirmPasswordField, value.getClass()) + .getReadMethod().invoke(value); + if (password == null && confirm == null) return true; + return password != null && password.equals(confirm); + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/com/imprimelibros/erp/users/validation/UserForm.java b/src/main/java/com/imprimelibros/erp/users/validation/UserForm.java new file mode 100644 index 0000000..a811e8f --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/users/validation/UserForm.java @@ -0,0 +1,105 @@ +package com.imprimelibros.erp.users.validation; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * DTO del formulario de creación/edición de usuarios + * (No ensucia la entidad y permite validaciones específicas de UI) + */ +@PasswordsMatch(password = "password", confirmPassword = "confirmPassword", groups = UserForm.Create.class) +public class UserForm { + + /** Grupos de validación */ + public interface Create { + } + + public interface Update { + } + + private Long id; + + @NotBlank(message = "{usuarios.error.nombre}", groups = { Create.class, Update.class }) + private String fullName; + + @NotBlank(message = "{usuarios.error.email}", groups = { Create.class, Update.class }) + @Email(message = "{usuarios.error.email.formato}", groups = { Create.class, Update.class }) + private String userName; + + @NotBlank(message = "{usuarios.error.rol}", groups = { Create.class, Update.class }) + @Pattern(regexp = "USER|ADMIN|SUPERADMIN", message = "{usuarios.error.rol.invalido}", groups = { Create.class, + Update.class }) + private String roleName; + + // Obligatoria solo al crear + @NotBlank(message = "{usuarios.error.password.requerida}", groups = Create.class) + @Size(min = 6, message = "{usuarios.error.password.min}", groups = Create.class) + private String password; + + // Validada por @PasswordsMatch (y requerida al crear) + @NotBlank(message = "{usuarios.error.confirmPassword.requerida}", groups = Create.class) + private String confirmPassword; + + @NotNull(groups = { Create.class, Update.class }) + private Boolean enabled; + + // ===== Getters / Setters ===== + 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 getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getConfirmPassword() { + return confirmPassword; + } + + public void setConfirmPassword(String confirmPassword) { + this.confirmPassword = confirmPassword; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f893082..e1733be 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -53,4 +53,9 @@ spring.web.resources.chain.strategy.content.paths=/assets/** # server.servlet.session.timeout=30m -security.rememberme.key=N`BY^YRVO:/\H$hsKxNq \ No newline at end of file +security.rememberme.key=N`BY^YRVO:/\H$hsKxNq + +# +# Enable HiddenHttpMethodFilter to support PUT and DELETE methods in forms +# +spring.mvc.hiddenmethod.filter.enabled=true \ No newline at end of file diff --git a/src/main/resources/i18n/users_es.properties b/src/main/resources/i18n/users_es.properties index 1fccea9..b984f51 100644 --- a/src/main/resources/i18n/users_es.properties +++ b/src/main/resources/i18n/users_es.properties @@ -1,6 +1,12 @@ usuarios.titulo=Usuarios usuarios.nuevo=Nuevo usuario +usuarios.editar=Editar usuario +usuarios.add=Añadir usuario +usuarios.eliminar=Eliminar usuario +usuarios.confirmarEliminar=¿Está seguro de que desea eliminar este usuario? +usuarios.guardar=Guardar +usuarios.tabla.id=ID usuarios.tabla.nombre=Nombre usuarios.tabla.email=Correo electrónico usuarios.tabla.rol=Rol @@ -8,3 +14,31 @@ usuarios.tabla.estado=Estado usuarios.tabla.acciones=Acciones usuarios.tabla.activo=Activo usuarios.tabla.inactivo=Inactivo + +usuarios.form.nombre=Nombre completo +usuarios.form.email=Correo electrónico +usuarios.form.password=Contraseña +usuarios.form.confirmarPassword=Confirmar contraseña +usuarios.form.rol=Rol +usuarios.form.estado=Estado + +usuarios.rol.user=Usuario +usuarios.rol.admin=Administrador +usuarios.rol.superadmin=Super Administrador + +usuarios.error.duplicado=Ya existe un usuario con este correo electrónico. +usuarios.error.general=Se ha producido un error al procesar la solicitud. Por favor, inténtelo de nuevo más tarde. +usuarios.error.noEncontrado=Usuario no encontrado. + +usuarios.error.nombre=El nombre es obligatorio. +usuarios.error.email=El correo electrónico es obligatorio. +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.confirmPassword.requerida=La confirmación de la contraseña es obligatoria. +usuarios.error.password-coinciden=Las contraseñas no coinciden. + +usuarios.exito.creado=Usuario creado con éxito. +usuarios.exito.actualizado=Usuario actualizado con éxito. +usuarios.exito.eliminado=Usuario eliminado con éxito. \ No newline at end of file diff --git a/src/main/resources/i18n/validation_es.properties b/src/main/resources/i18n/validation_es.properties index 1cb659d..b120b54 100644 --- a/src/main/resources/i18n/validation_es.properties +++ b/src/main/resources/i18n/validation_es.properties @@ -4,5 +4,7 @@ validation.min=El valor mínimo es {value} validation.max=El valor máximo es {value} validation.typeMismatchMsg=Tipo de dato no válido validation.patternMsg=El formato no es válido +validation.unique=El valor ya existe y debe ser único +validation.email=El correo electrónico no es válido diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js b/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js index 1ff03ba..2389947 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js @@ -1,29 +1,84 @@ $(() => { const language = document.documentElement.lang || 'es-ES'; - + const table = new DataTable('#users-datatable', { - processing: true, - serverSide: true, - language: { - url: '/assets/libs/datatables/i18n/' + language + '.json' - }, + processing: true, serverSide: true, pageLength: 50, + language: { url: '/assets/libs/datatables/i18n/' + language + '.json' }, responsive: true, - ajax: { - url: '/users/datatable', - method: 'GET', - data: d => { /* extra params si quieres */ } - }, - order: [[0, 'asc']], + ajax: { url: '/users/datatable', method: 'GET' }, + order: [[0,'asc']], columns: [ - { data: 'fullName', name: 'fullname' }, - { data: 'userName', name: 'username' }, - { data: 'roles', name: 'roles' }, + { data: 'id', name: 'id' , orderable: true }, + { data: 'fullName', name: 'fullName' , orderable: true }, + { data: 'userName', name: 'userName' , orderable: true }, + { data: 'roles', name: 'roleRank' }, { data: 'enabled', name: 'enabled', searchable: false }, { data: 'actions', name: 'actions' } ], - columnDefs: [ - // Desactiva orden y búsqueda en la columna de acciones - { targets: -1, orderable: false, searchable: false } - ] + columnDefs: [{ targets: -1, orderable: false, searchable: false }] + }); + + const modalEl = document.getElementById('userFormModal'); + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + + // Abrir "Crear" + $('#addUserButton').on('click', (e) => { + e.preventDefault(); + $.get('/users/form', function (html) { + $('#userModalBody').html(html); + const title = $('#userModalBody #userForm').data('add'); + $('#userFormModal .modal-title').text(title); + modal.show(); + }); + }); + + // Abrir "Editar" + $(document).on('click', '.btn-edit-user', function (e) { + e.preventDefault(); + const id = $(this).data('id'); + $.get('/users/form', { id }, function (html) { + $('#userModalBody').html(html); + const title = $('#userModalBody #userForm').data('edit'); + $('#userFormModal .modal-title').text(title); + modal.show(); + }); + }); + + // Submit del form en el modal + $(document).on('submit', '#userForm', function (e) { + e.preventDefault(); + const $form = $(this); + + $.ajax({ + url: $form.attr('action'), + type: 'POST', // PUT simulado via _method + data: $form.serialize(), + dataType: 'html', + success: function (html) { + // Si por cualquier motivo llega 200 con fragmento, lo insertamos igual + if (typeof html === 'string' && html.indexOf('id="userForm"') !== -1 && html.indexOf(' 0; + const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add'); + $('#userFormModal .modal-title').text(title); + return; + } + // Éxito real: cerrar y recargar tabla + modal.hide(); + table.ajax.reload(null, false); + }, + error: function (xhr) { + // Con 422 devolvemos el fragmento con errores aquí + if (xhr.status === 422 && xhr.responseText) { + $('#userModalBody').html(xhr.responseText); + const isEdit = $('#userModalBody #userForm input[name="_method"][value="PUT"]').length > 0; + const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add'); + $('#userFormModal .modal-title').text(title); + return; + } + // Fallback + $('#userModalBody').html('
Error inesperado.
'); + } + }); }); }); diff --git a/src/main/resources/templates/imprimelibros/partials/modal-form.html b/src/main/resources/templates/imprimelibros/partials/modal-form.html index 17d1459..c7d9dc2 100644 --- a/src/main/resources/templates/imprimelibros/partials/modal-form.html +++ b/src/main/resources/templates/imprimelibros/partials/modal-form.html @@ -7,7 +7,6 @@ diff --git a/src/main/resources/templates/imprimelibros/users/user-form.html b/src/main/resources/templates/imprimelibros/users/user-form.html new file mode 100644 index 0000000..6082824 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/users/user-form.html @@ -0,0 +1,60 @@ +
+
+ + + +
+
+
+ +
+ + +
Error
+
+
+ + +
Error
+
+
+ + +
Error
+
+
+ + +
Error
+
+
+ + +
Error
+
+
+ + +
Error
+
+
+ +
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/users/users-list.html b/src/main/resources/templates/imprimelibros/users/users-list.html index f281a10..2a07dac 100644 --- a/src/main/resources/templates/imprimelibros/users/users-list.html +++ b/src/main/resources/templates/imprimelibros/users/users-list.html @@ -21,6 +21,11 @@
+ +
+
+