falta borrar y busqueda por columnas

This commit is contained in:
2025-09-27 17:07:24 +02:00
parent 88b43847f0
commit 847249d2de
25 changed files with 669 additions and 62 deletions

View File

@ -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<Role> findRoleByName(String theRoleName);
}

View File

@ -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<Role> findRoleByName(String theRoleName) {
// retrieve/read from database using name
TypedQuery<Role> 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);
}
}

View File

@ -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<Role> roles;
@JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> 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<Role> roles) {
Set<Role> roles) {
this.fullName = fullName;
this.userName = userName;
this.password = password;
@ -93,14 +125,22 @@ public class User {
this.enabled = enabled;
}
public Collection<Role> getRoles() {
public Set<Role> getRoles() {
return roles;
}
public void setRoles(Collection<Role> roles) {
public void setRoles(Set<Role> 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 +
'}';
}
}

View File

@ -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<String> whitelist = List.of("fullName", "userName", "enabled");
List<String> searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de
// verdad
List<String> orderable = List.of("fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas columnas
Specification<User> 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 "<span class=\"badge bg-success\" >" + messageSource.getMessage("usuarios.tabla.activo", null, locale) + "</span>";
return "<span class=\"badge bg-success\" >"
+ messageSource.getMessage("usuarios.tabla.activo", null, locale) + "</span>";
} else {
return "<span class=\"badge bg-danger\" >" + messageSource.getMessage("usuarios.tabla.inactivo", null, locale) + "</span>";
return "<span class=\"badge bg-danger\" >"
+ messageSource.getMessage("usuarios.tabla.inactivo", null, locale) + "</span>";
}
})
// 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 -> "<span class=\"badge bg-primary\">" +
messageSource.getMessage("usuarios.rol." + rol, null, locale) + "</span>")
.collect(Collectors.joining(" ")))
.add("actions", (user) -> {
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
" <a href=\"/users/" + user.getId() + "\" class=\"link-success fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId() + "\" class=\"link-danger fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
+ "\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n" +
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
+ "\" class=\"link-danger fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>\n" +
" </div>";
})
.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<Role> 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;
}
}

View File

@ -9,4 +9,6 @@ public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExec
User findByUserNameAndEnabledTrue(String userName);
boolean existsByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
}

View File

@ -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;

View File

@ -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 {

View File

@ -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();
}

View File

@ -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<PasswordsMatch, Object> {
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;
}
}
}

View File

@ -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;
}
}