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 extends Payload>[] 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 @@
+
\ 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 @@
+
+
+
+
@@ -30,9 +35,14 @@
+
+ Añadir usuario
+
+
+ ID
Nombre
Correo electrónico
Rol
diff --git a/src/test/java/com/imprimelibros/erp/presupuestoMarcapaginasTest.java b/src/test/java/com/imprimelibros/erp/presupuestoMarcapaginasTest.java
index bda773f..598c3e9 100644
--- a/src/test/java/com/imprimelibros/erp/presupuestoMarcapaginasTest.java
+++ b/src/test/java/com/imprimelibros/erp/presupuestoMarcapaginasTest.java
@@ -3,10 +3,7 @@ package com.imprimelibros.erp;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Map;
-import java.math.BigDecimal;
-import java.math.RoundingMode;
import java.util.HashMap;
-import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;