diff --git a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java index de20891..5408faa 100644 --- a/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java +++ b/src/main/java/com/imprimelibros/erp/config/SecurityConfig.java @@ -1,10 +1,16 @@ package com.imprimelibros.erp.config; +import java.util.Locale; + import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -15,11 +21,19 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; +import org.springframework.security.web.util.matcher.NegatedRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; -import com.imprimelibros.erp.users.User; import com.imprimelibros.erp.users.UserDao; import com.imprimelibros.erp.users.UserDetailsImpl; +import jakarta.servlet.http.HttpServletRequest; + @Configuration public class SecurityConfig { @@ -32,12 +46,16 @@ public class SecurityConfig { // ========== Beans base ========== @Bean - public UserDetailsService userDetailsService(UserDao repo) { - return username -> { - User u = repo.findByUserNameAndEnabledTrue(username); - if (u == null) throw new UsernameNotFoundException("No existe: " + username); - return new UserDetailsImpl(u); - }; + public UserDetailsService userDetailsService(UserDao repo, MessageSource messages) { + return username -> repo + .findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(username) // <-- @EntityGraph aplicado + .map(UserDetailsImpl::new) // dentro del ctor precalculamos authorities + .orElseThrow(() -> { + var locale = org.springframework.context.i18n.LocaleContextHolder.getLocale(); + String msg = messages.getMessage("usuarios.error.no-activo", null, + "Usuario no activo o no existe", locale); + return new org.springframework.security.core.userdetails.UsernameNotFoundException(msg); + }); } @Bean @@ -45,84 +63,131 @@ public class SecurityConfig { return new BCryptPasswordEncoder(); } + // Remember-me (tabla persistent_logins) + @Bean + public PersistentTokenRepository persistentTokenRepository() { + JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl(); + repo.setDataSource(dataSource); + // repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la + // tabla + return repo; + } + // Provider que soporta UsernamePasswordAuthenticationToken @Bean public AuthenticationProvider daoAuthenticationProvider( UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { - DaoAuthenticationProvider p = new DaoAuthenticationProvider(); - p.setUserDetailsService(userDetailsService); - p.setPasswordEncoder(passwordEncoder); - return p; + // ✅ constructor recomendado (sin deprecations) + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); // este setter NO está deprecado + return provider; } - // Remember-me (tabla persistent_logins) - @Bean - public PersistentTokenRepository persistentTokenRepository() { - JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl(); - repo.setDataSource(dataSource); - // repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la tabla - return repo; + // Provider que soporta UsernamePasswordAuthenticationToken + private static RequestMatcher pathStartsWith(String... prefixes) { + return new RequestMatcher() { + @Override + public boolean matches(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null) + return false; + for (String p : prefixes) { + if (uri.startsWith(p)) + return true; + } + return false; + } + }; } - // ========== Filtro de seguridad ========== @Bean public SecurityFilterChain securityFilterChain( HttpSecurity http, @Value("${security.rememberme.key}") String keyRememberMe, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepo, - AuthenticationProvider daoAuthenticationProvider - ) throws Exception { + AuthenticationProvider daoAuthenticationProvider) throws Exception { http - // Registra explícitamente el provider para Username/Password - .authenticationProvider(daoAuthenticationProvider) + .authenticationProvider(daoAuthenticationProvider) - .sessionManagement(session -> session - .invalidSessionUrl("/login?expired") - .maximumSessions(1) - ) + .sessionManagement(session -> session + .invalidSessionUrl("/login?expired") + .maximumSessions(1)) - .csrf(csrf -> csrf - .ignoringRequestMatchers("/presupuesto/public/**") - ) + // Ignora CSRF para tu recurso público (sin Ant/Mvc matchers) + .csrf(csrf -> csrf + .ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"))) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login", - "/assets/**", "/css/**", "/js/**", "/images/**", - "/public/**", "/presupuesto/public/**", - "/error", "/favicon.ico").permitAll() - .requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN") - .anyRequest().authenticated() - ) + // ====== RequestCache: sólo navegaciones HTML reales ====== + .requestCache(rc -> { + HttpSessionRequestCache cache = new HttpSessionRequestCache(); - .formLogin(login -> login - .loginPage("/login").permitAll() - .loginProcessingUrl("/login") - .usernameParameter("username") - .passwordParameter("password") - .defaultSuccessUrl("/", false) - .failureUrl("/login?error") // útil para diagnosticar - ) + // Navegación HTML (por tipo o por cabecera Accept) + RequestMatcher htmlPage = new OrRequestMatcher( + new MediaTypeRequestMatcher(MediaType.TEXT_HTML), + new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML), + new RequestHeaderRequestMatcher("Accept", "text/html")); - .rememberMe(rm -> rm - .key(keyRememberMe) - .rememberMeParameter("remember-me") - .rememberMeCookieName("IMPRIMELIBROS_REMEMBER") - .tokenValiditySeconds(60 * 60 * 24 * 2) - .userDetailsService(userDetailsService) - .tokenRepository(tokenRepo) - ) + // No AJAX + RequestMatcher nonAjax = new NegatedRequestMatcher( + new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest")); - .logout(logout -> logout - .logoutUrl("/logout") - .logoutSuccessUrl("/") - .invalidateHttpSession(true) - .deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER") - .permitAll() - ); + // Excluir sondas .well-known + RequestMatcher notWellKnown = new NegatedRequestMatcher(pathStartsWith("/.well-known/")); + + // Excluir estáticos: comunes + tu /assets/** + RequestMatcher notStatic = new AndRequestMatcher( + new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()), + new NegatedRequestMatcher(pathStartsWith("/assets/"))); + + cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown)); + rc.requestCache(cache); + }) + // ======================================================== + + .authorizeHttpRequests(auth -> auth + // Aquí usa patrones String (no deprecados) + .requestMatchers( + "/", + "/login", + "/assets/**", + "/css/**", + "/js/**", + "/images/**", + "/public/**", + "/presupuesto/public/**", + "/error", + "/favicon.ico", + "/.well-known/**" // opcional + ).permitAll() + .requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN") + .anyRequest().authenticated()) + + .formLogin(login -> login + .loginPage("/login").permitAll() + .loginProcessingUrl("/login") + .usernameParameter("username") + .passwordParameter("password") + .defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada) + .failureUrl("/login?error")) + + .rememberMe(rm -> rm + .key(keyRememberMe) + .rememberMeParameter("remember-me") + .rememberMeCookieName("IMPRIMELIBROS_REMEMBER") + .tokenValiditySeconds(60 * 60 * 24 * 2) + .userDetailsService(userDetailsService) + .tokenRepository(tokenRepo)) + + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/") + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER") + .permitAll()); return http.build(); } diff --git a/src/main/java/com/imprimelibros/erp/datatables/DataTablesService.java b/src/main/java/com/imprimelibros/erp/datatables/DataTablesService.java deleted file mode 100644 index 5a0659e..0000000 --- a/src/main/java/com/imprimelibros/erp/datatables/DataTablesService.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.imprimelibros.erp.datatables; - -import org.springframework.data.domain.*; -import org.springframework.data.jpa.domain.Specification; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; - -import java.util.ArrayList; -import java.util.List; - -public class DataTablesService { - - public static DataTablesResponse handle( - DataTablesRequest dt, - JpaSpecificationExecutor repo, - long totalCount, // count sin filtros (cacheable) - List searchableFields, - Class entityClass) { - // Spec (filtros) - Specification spec = DataTablesSpecification.build(dt, searchableFields); - - // Sort - Sort sort = Sort.unsorted(); - if (!dt.order.isEmpty() && !dt.columns.isEmpty()) { - List orders = new ArrayList<>(); - for (var o : dt.order) { - var col = dt.columns.get(o.column); - String field = col != null ? col.name : null; - - // Acepta solo columnas válidas: no vacías, marcadas como orderable y en la - // whitelist "searchable" - if (field == null || field.isBlank()) - continue; - if (!col.orderable) - continue; - if (!searchableFields.contains(field)) - continue; - - orders.add(new Sort.Order( - "desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC, - field)); - } - if (!orders.isEmpty()) { - sort = Sort.by(orders); - } else { - // Fallback: primera columna de dt.columns que sea orderable y esté en la - // whitelist - for (var c : dt.columns) { - if (c != null && c.orderable && c.name != null && !c.name.isBlank() - && searchableFields.contains(c.name)) { - sort = Sort.by(c.name); - break; - } - } - // Si no hay ninguna válida, sort se queda UNSORTED - } - } - - // Page - int page = dt.length > 0 ? dt.start / dt.length : 0; - Pageable pageable = dt.length > 0 ? PageRequest.of(page, dt.length, sort) : Pageable.unpaged(); - - // Query - Page result = repo.findAll(spec, pageable); - long filtered = result.getTotalElements(); - - return new DataTablesResponse<>( - dt.draw, - totalCount, - filtered, - result.getContent()); - } -} diff --git a/src/main/java/com/imprimelibros/erp/users/Role.java b/src/main/java/com/imprimelibros/erp/users/Role.java index 5ae2cf2..315a844 100644 --- a/src/main/java/com/imprimelibros/erp/users/Role.java +++ b/src/main/java/com/imprimelibros/erp/users/Role.java @@ -7,6 +7,14 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import java.util.HashSet; +import java.util.Set; +import org.hibernate.annotations.SQLRestriction; +import jakarta.persistence.OneToMany; +import jakarta.persistence.FetchType; + +import com.fasterxml.jackson.annotation.JsonIgnore; + @Entity @Table(name = "roles") public class Role { @@ -15,10 +23,15 @@ public class Role { @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; - + @Column(name = "name") private String name; + @JsonIgnore + @SQLRestriction("deleted = false") + @OneToMany(mappedBy = "role", fetch = FetchType.LAZY) + private Set usersLink = new HashSet<>(); + public Role() { } @@ -42,6 +55,14 @@ public class Role { this.name = name; } + public Set getUsersLink() { + return usersLink; + } + + public void setUsersLink(Set usersLink) { + this.usersLink = usersLink; + } + @Override public String toString() { return "Role{" + "id=" + id + ", name='" + name + '\'' + '}'; diff --git a/src/main/java/com/imprimelibros/erp/users/User.java b/src/main/java/com/imprimelibros/erp/users/User.java index eb6f2e6..3576e4f 100644 --- a/src/main/java/com/imprimelibros/erp/users/User.java +++ b/src/main/java/com/imprimelibros/erp/users/User.java @@ -8,10 +8,23 @@ import java.util.Set; import org.hibernate.annotations.Formula; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.hibernate.annotations.SQLRestriction; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + @Entity @Table(name = "users", uniqueConstraints = { - @UniqueConstraint(name = "uk_users_username", columnNames = "username") + @UniqueConstraint(name = "uk_users_username", columnNames = "username") }) +@SQLRestriction("deleted = false") public class User { @Id @@ -35,32 +48,42 @@ public class User { @Column(name = "enabled") private boolean enabled; - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) - private Set roles = new java.util.HashSet<>(); + @Column(name = "deleted", nullable = false) + private boolean deleted = false; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "deleted_by") + private Long deletedBy; + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true) + @SQLRestriction("deleted = false") + @JsonIgnore + private Set rolesLink = new 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 - ) - """) + ( + 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) - """) + (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 */ @@ -75,12 +98,12 @@ public class User { } public User(String fullName, String userName, String password, boolean enabled, - Set roles) { + Set roles) { this.fullName = fullName; this.userName = userName; this.password = password; this.enabled = enabled; - this.roles = roles; + this.rolesLink = roles; } /* Getters and Setters */ @@ -125,12 +148,63 @@ public class User { this.enabled = enabled; } + @Transient public Set getRoles() { - return roles; + return rolesLink.stream() + .filter(ur -> !ur.isDeleted()) + .map(UserRole::getRole) + .collect(Collectors.toSet()); } - public void setRoles(Set roles) { - this.roles = roles; + @JsonProperty("roles") + public List getRoleNames() { + return this.getRoles().stream() + .map(Role::getName) + .filter(java.util.Objects::nonNull) + .map(String::trim) + .toList(); + } + + public void setRoles(Set desired) { + if (desired == null) + desired = Collections.emptySet(); + + // 1) ids deseados + Set desiredIds = desired.stream() + .map(Role::getId) + .collect(Collectors.toSet()); + + // 2) Soft-delete de vínculos activos que ya no se desean + this.rolesLink.stream() + .filter(ur -> !ur.isDeleted() && !desiredIds.contains(ur.getRole().getId())) + .forEach(UserRole::softDelete); + + // 3) Para cada rol deseado: si hay vínculo borrado => reactivar; si no existe + // => crear + for (Role role : desired) { + // ya activo + boolean activeExists = this.rolesLink.stream() + .anyMatch(ur -> !ur.isDeleted() && ur.getRole().getId().equals(role.getId())); + if (activeExists) + continue; + + // existe borrado => reactivar + Optional deletedLink = this.rolesLink.stream() + .filter(ur -> ur.isDeleted() && ur.getRole().getId().equals(role.getId())) + .findFirst(); + + if (deletedLink.isPresent()) { + UserRole ur = deletedLink.get(); + ur.setDeleted(false); + ur.setDeletedAt(null); + } else { + // crear nuevo vínculo + UserRole ur = new UserRole(this, role); + this.rolesLink.add(ur); + // si tienes la colección inversa en Role: + role.getUsersLink().add(ur); + } + } } public Integer getRoleRank() { @@ -141,15 +215,46 @@ public class User { return rolesConcat; } + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + public Long getDeletedBy() { + return deletedBy; + } + + public void setDeletedBy(Long deletedBy) { + this.deletedBy = deletedBy; + } + + public Set getRolesLink() { + return rolesLink; + } + + public void setRolesLink(Set rolesLink) { + this.rolesLink = rolesLink; + } + @Override public String toString() { return "User{" + "id=" + id + ", fullName='" + fullName + '\'' + ", userName='" + userName + '\'' + - ", password='" + password + '\'' + ", enabled=" + enabled + - ", roles=" + roles + + ", roles=" + getRoles() + '}'; } diff --git a/src/main/java/com/imprimelibros/erp/users/UserController.java b/src/main/java/com/imprimelibros/erp/users/UserController.java index 41dc15a..b4ab09e 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserController.java +++ b/src/main/java/com/imprimelibros/erp/users/UserController.java @@ -13,6 +13,7 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; @@ -33,6 +34,7 @@ import com.imprimelibros.erp.datatables.DataTable; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import java.time.LocalDateTime; import java.util.List; import java.util.Locale; import org.springframework.web.bind.annotation.RequestParam; @@ -66,18 +68,17 @@ public class UserController { public String list(Model model, Authentication authentication, Locale locale) { List keys = List.of( - "usuarios.delete.title", - "usuarios.delete.text", - "usuarios.eliminar", - "usuarios.delete.button", - "app.yes", - "app.cancelar", - "usuarios.delete.ok.title", - "usuarios.delete.ok.text" - ); + "usuarios.delete.title", + "usuarios.delete.text", + "usuarios.eliminar", + "usuarios.delete.button", + "app.yes", + "app.cancelar", + "usuarios.delete.ok.title", + "usuarios.delete.ok.text"); - Map translations = translationService.getTranslations(locale, keys); - model.addAttribute("languageBundle", translations); + Map translations = translationService.getTranslations(locale, keys); + model.addAttribute("languageBundle", translations); return "imprimelibros/users/users-list"; } @@ -86,7 +87,8 @@ public class UserController { // método con @ResponseBody. @GetMapping(value = "/datatable", produces = "application/json") @ResponseBody - public DataTablesResponse> datatable(HttpServletRequest request, Locale locale) { + public DataTablesResponse> datatable(HttpServletRequest request, Authentication authentication, + Locale locale) { DataTablesRequest dt = DataTablesParser.from(request); // @@ -94,8 +96,9 @@ public class UserController { // Si 'role' es relación, sácalo de aquí: List searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de // verdad - List orderable = List.of("id", "fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas - // columnas + List orderable = List.of("id", "fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por + // estas + // columnas Specification base = (root, query, cb) -> cb.conjunction(); long total = repo.count(); @@ -121,12 +124,27 @@ public class UserController { messageSource.getMessage("usuarios.rol." + rol, null, locale) + "") .collect(Collectors.joining(" "))) .add("actions", (user) -> { - return "
\n" + - " \n" + - " \n" + - "
"; + + boolean isSuperAdmin = authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_SUPERADMIN")); + + if (!isSuperAdmin) { + return "
\n" + + " \n" + + + "
"; + } else { + // Admin editando otro admin o usuario normal: puede editarse y eliminarse + return "
\n" + + " \n" + + + " \n" + + + "
"; + } }) .where(base) // Filtros custom: @@ -295,28 +313,38 @@ public class UserController { } @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable Long id, Authentication authentication, Locale locale) { + @Transactional + public ResponseEntity delete(@PathVariable Long id, Authentication auth, Locale locale) { return repo.findById(id).map(u -> { - - if (authentication != null && u.getUserName().equalsIgnoreCase(authentication.getName())) { + if (auth != null && u.getUserName().equalsIgnoreCase(auth.getName())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-self", null, locale))); } try { - repo.delete(u); - return ResponseEntity.status(HttpStatus.OK).body( - Map.of("message", messageSource.getMessage("usuarios.exito.eliminado", null, locale)) - ); - } catch (DataIntegrityViolationException dive) { - // Restricción FK / dependencias - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-relational-data", null, locale))); + Long currentUserId = null; + if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) { + currentUserId = udi.getId(); + } else if (auth != null) { + currentUserId = repo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); // fallback + } + + u.setDeleted(true); + u.setDeletedAt(LocalDateTime.now()); + u.setDeletedBy(currentUserId); + + // Soft-delete de los vínculos (si usas cascade REMOVE + @SQLDelete en UserRole, + // podrías omitir este foreach y dejar que JPA lo haga) + u.getRolesLink().forEach(UserRole::softDelete); + + repo.save(u); // ← NO delete(); guardamos el soft delete con deleted_by relleno + return ResponseEntity.ok(Map.of("message", + messageSource.getMessage("usuarios.exito.eliminado", null, locale))); } catch (Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-internal-error", null, locale))); + .body(Map.of("message", + messageSource.getMessage("usuarios.error.delete-internal-error", null, locale))); } }).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-not-found", null, locale)))); + .body(Map.of("message", messageSource.getMessage("usuarios.error.not-found", null, locale)))); } - } diff --git a/src/main/java/com/imprimelibros/erp/users/UserDao.java b/src/main/java/com/imprimelibros/erp/users/UserDao.java index e4eaf1b..3afd2e9 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserDao.java +++ b/src/main/java/com/imprimelibros/erp/users/UserDao.java @@ -2,16 +2,47 @@ package com.imprimelibros.erp.users; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; @Repository public interface UserDao extends JpaRepository, JpaSpecificationExecutor { - User findByUserNameAndEnabledTrue(String userName); + // Aplicamos EntityGraph a la versión con Specification+Pageable + @Override + @EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" }) + @NonNull + Page findAll(@Nullable Specification spec, @NonNull Pageable pageable); + Optional findByUserNameIgnoreCase(String userName); + boolean existsByUserNameIgnoreCase(String userName); + boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id); + + // Nuevo: para login/negocio "activo" + @EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" }) + Optional findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName); + + // Para poder restaurar, necesitas leer ignorando @Where (native): + @Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true) + Optional findByIdIncludingDeleted(@Param("id") Long id); + + @Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true) + List findAllDeleted(); + + @Query("select u.id from User u where lower(u.userName) = lower(:userName)") + Optional findIdByUserNameIgnoreCase(@Param("userName") String userName); + } diff --git a/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java b/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java index b4bb1cb..3be36f6 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java +++ b/src/main/java/com/imprimelibros/erp/users/UserDetailsImpl.java @@ -4,10 +4,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.Collection; import java.util.LinkedHashSet; -import java.util.Set; -import java.util.stream.Collectors; /** * Adaptador de la entidad User a Spring Security. @@ -15,21 +12,22 @@ import java.util.stream.Collectors; public class UserDetailsImpl implements UserDetails { private final User user; + private final java.util.Collection authorities; public UserDetailsImpl(User user) { this.user = user; + this.authorities = user.getRoles().stream() + .map(Role::getName) + .filter(java.util.Objects::nonNull) + .map(String::trim) + .map(String::toUpperCase) + .map(name -> new SimpleGrantedAuthority("ROLE_" + name)) + .toList(); } @Override - public Collection getAuthorities() { - // Si tu User tiene un Set: - Set roles = user.getRoles().stream() - .map(r -> r.getName()) // ejemplo: "ADMIN", "USER" - .collect(Collectors.toSet()); - - return roles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .collect(Collectors.toSet()); + public java.util.Collection getAuthorities() { + return authorities; // no volvemos a tocar user.getRoles() fuera de sesión } @Override @@ -43,11 +41,10 @@ public class UserDetailsImpl implements UserDetails { } public String getFullname() { - - return user.getFullName(); + + return user.getFullName(); } - // 👇 si en la vista usas principal.role, añade este también public String getRole() { return user.getRoles().stream() .map(r -> r.getName()) // "ADMIN", "USER", ... @@ -79,10 +76,15 @@ public class UserDetailsImpl implements UserDetails { @Override public boolean isEnabled() { - return user.isEnabled(); + return user.isEnabled() && !user.isDeleted(); } public User getUser() { return user; } + + public Long getId() { + return user.getId(); + } + } diff --git a/src/main/java/com/imprimelibros/erp/users/UserRole.java b/src/main/java/com/imprimelibros/erp/users/UserRole.java new file mode 100644 index 0000000..4a4e8f7 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/users/UserRole.java @@ -0,0 +1,121 @@ +package com.imprimelibros.erp.users; + +import java.time.LocalDateTime; + +import jakarta.persistence.*; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "users_roles", uniqueConstraints = { + @UniqueConstraint(name = "ux_users_roles_active", columnNames = { "user_id", "role_id", "deleted" }) +}) +@SQLDelete(sql = "UPDATE users_roles SET deleted = TRUE, deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted = false") +public class UserRole { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // FK a users + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // FK a roles + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "role_id", nullable = false) + private Role role; + + @Column(nullable = false) + private boolean deleted = false; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + protected UserRole() { + } + + public UserRole(User user, Role role) { + this.user = user; + this.role = role; + } + + /* ---- helpers ---- */ + public void softDelete() { + this.deleted = true; + this.deletedAt = LocalDateTime.now(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof UserRole other)) + return false; + + Long u1 = this.getUser() != null ? this.getUser().getId() : null; + Long r1 = this.getRole() != null ? this.getRole().getId() : null; + Long u2 = other.getUser() != null ? other.getUser().getId() : null; + Long r2 = other.getRole() != null ? other.getRole().getId() : null; + + // igualdad por clave lógica (user_id, role_id) cuando existen + if (u1 != null && r1 != null && u2 != null && r2 != null) { + return u1.equals(u2) && r1.equals(r2); + } + // fallback: identidad por id si está asignado + if (this.getId() != null && other.getId() != null) { + return this.getId().equals(other.getId()); + } + return false; + } + + @Override + public int hashCode() { + Long u = this.getUser() != null ? this.getUser().getId() : null; + Long r = this.getRole() != null ? this.getRole().getId() : null; + if (u != null && r != null) { + return java.util.Objects.hash(u, r); + } + return java.util.Objects.hash(getId()); + } + + /* ---- getters/setters ---- */ + public Long getId() { + return id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Role getRole() { + return role; + } + + public void setRole(Role role) { + this.role = role; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } +} diff --git a/src/main/java/com/imprimelibros/erp/users/UserService.java b/src/main/java/com/imprimelibros/erp/users/UserService.java index 9721c7a..1160d12 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserService.java +++ b/src/main/java/com/imprimelibros/erp/users/UserService.java @@ -3,5 +3,5 @@ package com.imprimelibros.erp.users; import org.springframework.security.core.userdetails.UserDetailsService; public interface UserService extends UserDetailsService { - public User findByUserName(String userName); + } diff --git a/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java b/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java index 47727b4..569deea 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java +++ b/src/main/java/com/imprimelibros/erp/users/UserServiceImpl.java @@ -1,44 +1,22 @@ package com.imprimelibros.erp.users; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; -import java.util.Collection; -import java.util.stream.Collectors; @Service public class UserServiceImpl implements UserService { private UserDao userDao; - private RoleDao roleDao; - public UserServiceImpl(UserDao userDao, RoleDao roleDao) { + public UserServiceImpl(UserDao userDao) { this.userDao = userDao; - this.roleDao = roleDao; } - @Override - public User findByUserName(String userName) { - // check the database if the user already exists - return userDao.findByUserNameAndEnabledTrue(userName); - } - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - User user = userDao.findByUserNameAndEnabledTrue(username); - if (user == null) { - throw new UsernameNotFoundException("No existe usuario: " + username); - } + @Override + public UserDetails loadUserByUsername(String username) { + User user = userDao.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(username) + .orElseThrow(() -> new UsernameNotFoundException("No existe usuario activo: " + username)); return new UserDetailsImpl(user); } - - /*public List getUsersList() { - return userDao.findAll(); - }*/ - - private Collection mapRolesToAuthorities(Collection roles) { - return roles.stream().map(role -> new SimpleGrantedAuthority(role.getName())).collect(Collectors.toList()); - } } diff --git a/src/main/resources/i18n/users_es.properties b/src/main/resources/i18n/users_es.properties index 15db8e9..6dfc178 100644 --- a/src/main/resources/i18n/users_es.properties +++ b/src/main/resources/i18n/users_es.properties @@ -44,6 +44,8 @@ usuarios.error.delete-internal-error=No se puede eliminar: error interno. usuarios.error.delete-not-found=No se puede eliminar: usuario no encontrado. usuarios.error.delete-self=No se puede eliminar a sí mismo. +usuarios.error.no-activo=No existe un usuario activo con este correo electrónico. + usuarios.exito.creado=Usuario creado con éxito. usuarios.exito.actualizado=Usuario actualizado con éxito. usuarios.exito.eliminado=Usuario eliminado con éxito.