Compare commits

..

6 Commits

Author SHA1 Message Date
06a3521f6b Merge branch 'feat/perfil' into 'main'
añadido perfil a IL

See merge request jjimenez/erp-imprimelibros!35
2026-02-04 18:27:18 +00:00
ecf1472f58 añadido perfil a IL 2026-02-04 19:26:44 +01:00
48993a34c4 Merge branch 'feat/impersonate' into 'main'
impersonation implementado

See merge request jjimenez/erp-imprimelibros!34
2026-02-04 18:05:44 +00:00
a0bf8552f1 impersonation implementado 2026-02-04 19:05:10 +01:00
562dc2b231 Merge branch 'feat/log_save_presupuesto' into 'main'
añadido log en guardar presupuesto

See merge request jjimenez/erp-imprimelibros!33
2026-01-09 16:55:09 +00:00
9a49ccf6b8 añadido log en guardar presupuesto 2026-01-09 17:54:06 +01:00
15 changed files with 1557 additions and 12161 deletions

13107
logs/erp.log

File diff suppressed because one or more lines are too long

View File

@ -149,6 +149,10 @@ public class SecurityConfig {
"/pagos/redsys/**"
)
.permitAll()
.requestMatchers("/impersonate/exit")
.hasRole("PREVIOUS_ADMINISTRATOR")
.requestMatchers("/impersonate")
.hasAnyRole("SUPERADMIN", "ADMIN")
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
.anyRequest().authenticated())

View File

@ -14,6 +14,8 @@ import java.util.Optional;
import java.util.List;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.ResponseEntity;
@ -63,6 +65,8 @@ import jakarta.validation.Valid;
@RequestMapping("/presupuesto")
public class PresupuestoController {
private static final Logger log = LoggerFactory.getLogger(PresupuestoController.class);
private final PresupuestoRepository presupuestoRepository;
@Autowired
@ -824,6 +828,7 @@ public class PresupuestoController {
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
} catch (Exception ex) {
log.error("Error al guardar el presupuesto", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.save-internal-error", null, locale),

View File

@ -0,0 +1,115 @@
package com.imprimelibros.erp.users;
import java.util.ArrayList;
import java.util.List;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import com.imprimelibros.erp.config.Sanitizer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@Controller
public class ImpersonationController {
private static final String PREVIOUS_ADMIN_ROLE = "ROLE_PREVIOUS_ADMINISTRATOR";
private static final String SESSION_ATTR = "IMPERSONATOR_AUTH";
private final UserService userService;
private final Sanitizer sanitizer;
public ImpersonationController(UserService userService, Sanitizer sanitizer) {
this.userService = userService;
this.sanitizer = sanitizer;
}
@PostMapping("/impersonate")
@PreAuthorize("hasRole('ADMIN') or hasRole('SUPERADMIN')")
public String impersonate(
@RequestParam("username") String username,
Authentication authentication,
HttpServletRequest request) {
if (authentication == null) {
return "redirect:/login";
}
if (hasRole(authentication, PREVIOUS_ADMIN_ROLE)) {
return "redirect:/";
}
String normalized = sanitizer.plain(username);
if (normalized == null || normalized.isBlank()) {
return "redirect:/users";
}
normalized = normalized.trim().toLowerCase();
if (authentication.getName() != null
&& authentication.getName().equalsIgnoreCase(normalized)) {
return "redirect:/users";
}
UserDetails target;
try {
target = userService.loadUserByUsername(normalized);
} catch (UsernameNotFoundException ex) {
throw new AccessDeniedException("No autorizado");
}
boolean currentIsSuperAdmin = hasRole(authentication, "ROLE_SUPERADMIN");
boolean targetIsSuperAdmin = target.getAuthorities().stream()
.anyMatch(a -> "ROLE_SUPERADMIN".equals(a.getAuthority()));
if (targetIsSuperAdmin && !currentIsSuperAdmin) {
throw new AccessDeniedException("No autorizado");
}
HttpSession session = request.getSession(true);
if (session.getAttribute(SESSION_ATTR) == null) {
session.setAttribute(SESSION_ATTR, authentication);
}
List<GrantedAuthority> authorities = new ArrayList<>(target.getAuthorities());
authorities.add(new SimpleGrantedAuthority(PREVIOUS_ADMIN_ROLE));
UsernamePasswordAuthenticationToken newAuth = new UsernamePasswordAuthenticationToken(
target, target.getPassword(), authorities);
newAuth.setDetails(authentication.getDetails());
SecurityContextHolder.getContext().setAuthentication(newAuth);
return "redirect:/";
}
@PostMapping("/impersonate/exit")
@PreAuthorize("hasRole('PREVIOUS_ADMINISTRATOR')")
public String exit(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
Object previous = session.getAttribute(SESSION_ATTR);
if (previous instanceof Authentication previousAuth) {
SecurityContextHolder.getContext().setAuthentication(previousAuth);
} else {
SecurityContextHolder.clearContext();
}
session.removeAttribute(SESSION_ATTR);
}
return "redirect:/";
}
private static boolean hasRole(Authentication auth, String role) {
return auth != null
&& auth.getAuthorities().stream()
.anyMatch(a -> role.equals(a.getAuthority()));
}
}

View File

@ -0,0 +1,155 @@
package com.imprimelibros.erp.users;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.imprimelibros.erp.config.Sanitizer;
import com.imprimelibros.erp.users.validation.ProfileForm;
@Controller
@RequestMapping("/pages-profile")
@PreAuthorize("isAuthenticated()")
public class ProfileController {
private final UserDao userDao;
private final PasswordEncoder passwordEncoder;
private final MessageSource messageSource;
private final Sanitizer sanitizer;
public ProfileController(UserDao userDao, PasswordEncoder passwordEncoder,
MessageSource messageSource, Sanitizer sanitizer) {
this.userDao = userDao;
this.passwordEncoder = passwordEncoder;
this.messageSource = messageSource;
this.sanitizer = sanitizer;
}
@GetMapping
public String view(
Authentication authentication,
@RequestParam(name = "success", required = false) String success,
Model model,
Locale locale) {
if (authentication == null) {
return "redirect:/login";
}
User user = userDao.findByUserNameIgnoreCase(authentication.getName()).orElse(null);
if (user == null) {
return "redirect:/login";
}
ProfileForm form = new ProfileForm();
form.setId(user.getId());
form.setFullName(user.getFullName());
form.setUserName(user.getUserName());
model.addAttribute("user", form);
model.addAttribute("success", success != null);
return "imprimelibros/users/profile";
}
@PostMapping
public String update(
Authentication authentication,
@Validated @ModelAttribute("user") ProfileForm form,
BindingResult binding,
Model model,
RedirectAttributes redirectAttributes,
Locale locale) {
if (authentication == null) {
return "redirect:/login";
}
User user = userDao.findByUserNameIgnoreCase(authentication.getName()).orElse(null);
if (user == null) {
return "redirect:/login";
}
String normalized = sanitizer.plain(form.getUserName());
if (normalized != null) {
normalized = normalized.trim().toLowerCase();
}
if (normalized == null || normalized.isBlank()) {
binding.rejectValue("userName", "usuarios.error.email",
messageSource.getMessage("usuarios.error.email", null, locale));
} else if (userDao.existsByUserNameIgnoreCaseAndIdNot(normalized, user.getId())) {
binding.rejectValue("userName", "usuarios.error.duplicado",
messageSource.getMessage("usuarios.error.duplicado", null, locale));
}
String cleanName = sanitizer.plain(form.getFullName());
if (cleanName == null || cleanName.isBlank()) {
binding.rejectValue("fullName", "usuarios.error.nombre",
messageSource.getMessage("usuarios.error.nombre", null, locale));
}
boolean wantsPasswordChange = hasText(form.getCurrentPassword())
|| hasText(form.getNewPassword())
|| hasText(form.getConfirmPassword());
if (wantsPasswordChange) {
if (!hasText(form.getCurrentPassword())) {
binding.rejectValue("currentPassword", "usuarios.error.password.actual",
messageSource.getMessage("usuarios.error.password.actual", null, locale));
} else if (!passwordEncoder.matches(form.getCurrentPassword(), user.getPassword())) {
binding.rejectValue("currentPassword", "usuarios.error.password.actual.incorrecta",
messageSource.getMessage("usuarios.error.password.actual.incorrecta", null, locale));
}
if (!hasText(form.getNewPassword())) {
binding.rejectValue("newPassword", "usuarios.error.password.nueva.requerida",
messageSource.getMessage("usuarios.error.password.nueva.requerida", null, locale));
} else if (form.getNewPassword().length() < 6) {
binding.rejectValue("newPassword", "usuarios.error.password.min",
messageSource.getMessage("usuarios.error.password.min", null, locale));
}
if (!hasText(form.getConfirmPassword())) {
binding.rejectValue("confirmPassword", "usuarios.error.confirmPassword.requerida",
messageSource.getMessage("usuarios.error.confirmPassword.requerida", null, locale));
} else if (hasText(form.getNewPassword()) && !form.getNewPassword().equals(form.getConfirmPassword())) {
binding.rejectValue("confirmPassword", "usuarios.error.password-coinciden",
messageSource.getMessage("usuarios.error.password-coinciden", null, locale));
}
}
if (binding.hasErrors()) {
model.addAttribute("success", false);
return "imprimelibros/users/profile";
}
user.setFullName(cleanName.trim());
user.setUserName(normalized);
if (wantsPasswordChange) {
user.setPassword(passwordEncoder.encode(form.getNewPassword()));
}
userDao.save(user);
redirectAttributes.addAttribute("success", "1");
return "redirect:/pages-profile";
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@ -81,6 +81,9 @@ public class UserController {
"usuarios.delete.button",
"app.yes",
"app.cancelar",
"usuarios.impersonate.title",
"usuarios.impersonate.text",
"usuarios.impersonate.button",
"usuarios.delete.ok.title",
"usuarios.delete.ok.text");
@ -132,26 +135,36 @@ public class UserController {
.collect(Collectors.joining(" ")))
.add("actions", (user) -> {
boolean isSuperAdmin = authentication.getAuthorities().stream()
boolean isSuperAdmin = authentication != null && authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_SUPERADMIN"));
if (!isSuperAdmin) {
return "<div class=\"hstack gap-3 flex-wrap\">\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"
+
" </div>";
} else {
// Admin editando otro admin o usuario normal: puede editarse y eliminarse
return "<div class=\"hstack gap-3 flex-wrap\">\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 btn-delete-user fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>\n"
+
" </div>";
boolean isSelf = authentication != null
&& authentication.getName() != null
&& authentication.getName().equalsIgnoreCase(user.getUserName());
boolean targetIsSuperAdmin = user.getRoles().stream()
.anyMatch(r -> "SUPERADMIN".equalsIgnoreCase(r.getName()));
StringBuilder actions = new StringBuilder();
actions.append("<div class=\"hstack gap-3 flex-wrap\">");
actions.append("<a href=\"javascript:void(0);\" data-id=\"")
.append(user.getId())
.append("\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>");
if (!isSelf && (isSuperAdmin || !targetIsSuperAdmin)) {
actions.append("<a href=\"javascript:void(0);\" data-username=\"")
.append(user.getUserName())
.append("\" class=\"link-info btn-impersonate-user fs-15\"><i class=\"ri-user-shared-line\"></i></a>");
}
if (isSuperAdmin) {
actions.append("<a href=\"javascript:void(0);\" data-id=\"")
.append(user.getId())
.append("\" class=\"link-danger btn-delete-user fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>");
}
actions.append("</div>");
return actions.toString();
})
.where(base)
// Filtros custom:

View File

@ -0,0 +1,68 @@
package com.imprimelibros.erp.users.validation;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public class ProfileForm {
private Long id;
@NotBlank(message = "{usuarios.error.nombre}")
private String fullName;
@NotBlank(message = "{usuarios.error.email}")
@Email(message = "{usuarios.error.email.formato}")
private String userName;
private String currentPassword;
private String newPassword;
private String confirmPassword;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getCurrentPassword() {
return currentPassword;
}
public void setCurrentPassword(String currentPassword) {
this.currentPassword = currentPassword;
}
public String getNewPassword() {
return newPassword;
}
public void setNewPassword(String newPassword) {
this.newPassword = newPassword;
}
public String getConfirmPassword() {
return confirmPassword;
}
public void setConfirmPassword(String confirmPassword) {
this.confirmPassword = confirmPassword;
}
}

View File

@ -1,7 +1,7 @@
spring.application.name=erp
# Active profile
#spring.profiles.active=dev
spring.profiles.active=test
spring.profiles.active=dev
#spring.profiles.active=test
#spring.profiles.active=prod

View File

@ -7,3 +7,4 @@ app.guardar=Save
app.editar=Edit
app.eliminar=Delete
app.imprimir=Print
app.impersonate.exit=Return to my user

View File

@ -33,3 +33,4 @@ app.sidebar.gestion-pagos=Gestión de Pagos
app.errors.403=No tienes permiso para acceder a esta página.
app.validation.required=Campo obligatorio
app.impersonate.exit=Volver a mi usuario

View File

@ -1 +1,23 @@
usuarios.form.nombre=Full name
usuarios.form.email=Email
usuarios.form.confirmarPassword=Confirm password
usuarios.form.password.actual=Current password
usuarios.form.password.nueva=New password
usuarios.form.password.nota=You can only change the password if you provide the current one.
usuarios.error.nombre=Name is required.
usuarios.error.email=Email is required.
usuarios.error.email.formato=Email is not valid.
usuarios.error.password.min=Password must be at least 6 characters.
usuarios.error.password.actual=Current password is required.
usuarios.error.password.actual.incorrecta=Current password is not correct.
usuarios.error.password.nueva.requerida=New password is required.
usuarios.error.confirmPassword.requerida=Password confirmation is required.
usuarios.error.password-coinciden=Passwords do not match.
usuarios.error.duplicado=There is already a user with that email.
usuarios.impersonate.title=Sign in as user
usuarios.impersonate.text=You are about to sign in as <b>{0}</b>. You can return to your user from the menu.
usuarios.impersonate.button=Continue
usuarios.profile.title=Edit profile
usuarios.profile.success=Profile updated successfully.

View File

@ -20,6 +20,9 @@ usuarios.form.nombre=Nombre completo
usuarios.form.email=Correo electrónico
usuarios.form.password=Contraseña
usuarios.form.confirmarPassword=Confirmar contraseña
usuarios.form.password.actual=Contraseña actual
usuarios.form.password.nueva=Nueva contraseña
usuarios.form.password.nota=Solo podrás cambiar la contraseña si indicas la actual.
usuarios.form.rol=Rol
usuarios.form.estado=Estado
@ -37,6 +40,9 @@ usuarios.error.email.formato=El correo electrónico no es válido.
usuarios.error.rol=El rol seleccionado no es válido.
usuarios.error.password.requerida=La contraseña es obligatoria.
usuarios.error.password.min=La contraseña debe tener al menos 6 caracteres.
usuarios.error.password.actual=La contraseña actual es obligatoria.
usuarios.error.password.actual.incorrecta=La contraseña actual no es correcta.
usuarios.error.password.nueva.requerida=La nueva contraseña es obligatoria.
usuarios.error.confirmPassword.requerida=La confirmación de la contraseña es obligatoria.
usuarios.error.password-coinciden=Las contraseñas no coinciden.
usuarios.error.delete-relational-data=No se puede eliminar el usuario porque tiene datos relacionados.
@ -54,3 +60,8 @@ usuarios.delete.button=Si, ELIMINAR
usuarios.delete.text=¿Está seguro de que desea eliminar al usuario?<br>Esta acción no se puede deshacer.
usuarios.delete.ok.title=Usuario eliminado
usuarios.delete.ok.text=El usuario ha sido eliminado con éxito.
usuarios.impersonate.title=Entrar como usuario
usuarios.impersonate.text=Vas a iniciar sesión como <b>{0}</b>. Podrás volver a tu usuario desde el menú.
usuarios.impersonate.button=Entrar
usuarios.profile.title=Editar perfil
usuarios.profile.success=Perfil actualizado correctamente.

View File

@ -147,6 +147,55 @@ $(() => {
});
});
// Botón "Entrar como"
$(document).on('click', '.btn-impersonate-user', function (e) {
e.preventDefault();
const username = $(this).data('username');
const title = window.languageBundle.get(['usuarios.impersonate.title']) || 'Entrar como usuario';
const textTpl = window.languageBundle.get(['usuarios.impersonate.text'])
|| 'Vas a iniciar sesión como <b>{0}</b>.';
const confirmText = window.languageBundle.get(['usuarios.impersonate.button']) || 'Entrar';
Swal.fire({
title,
html: textTpl.replace('{0}', username),
icon: 'warning',
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-info w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: confirmText,
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: '/impersonate',
type: 'POST',
data: { username },
success: function () {
window.location.href = '/';
},
error: function (xhr) {
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'No se pudo iniciar sesión como ese usuario.';
Swal.fire({
icon: 'error',
title: 'No se pudo suplantar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2',
},
});
}
});
});
});
// Submit del form en el modal
$(document).on('submit', '#userForm', function (e) {

View File

@ -100,9 +100,14 @@
<a class="dropdown-item" href="/pages-profile"><i
class="mdi mdi-account-circle text-muted fs-16 align-middle me-1"></i> <span
class="align-middle" th:text="#{app.perfil}">Perfil</span></a>
<a class="dropdown-item" href="/apps-chat"><i
class="mdi mdi-message-text-outline text-muted fs-16 align-middle me-1"></i>
<span class="align-middle" th:text="#{app.mensajes}">Mensajes</span></a>
<div sec:authorize="hasRole('PREVIOUS_ADMINISTRATOR')">
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#"
onclick="document.getElementById('exitImpersonationForm').submit(); return false;">
<i class="mdi mdi-account-switch text-muted fs-16 align-middle me-1"></i>
<span class="align-middle" th:text="#{app.impersonate.exit}">Volver a mi usuario</span>
</a>
</div>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#"
onclick="document.getElementById('logoutForm').submit(); return false;">
@ -127,6 +132,9 @@
<form id="logoutForm" th:action="@{/logout}" method="post" class="d-none">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
<form id="exitImpersonationForm" th:action="@{/impersonate/exit}" method="post" class="d-none">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</header>

View File

@ -0,0 +1,107 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{app.perfil}">Perfil</li>
</ol>
</nav>
<div class="container-fluid">
<div class="row">
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0" th:text="#{usuarios.profile.title}">Editar perfil</h5>
</div>
<div class="card-body">
<div th:if="${success}" class="alert alert-success"
th:text="#{usuarios.profile.success}">Perfil actualizado.</div>
<form id="profileForm" novalidate th:action="@{/pages-profile}" th:object="${user}"
method="post">
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
<div th:each="e : ${#fields.globalErrors()}" th:text="${e}"></div>
</div>
<div class="mb-3">
<label th:text="#{usuarios.form.nombre}" for="fullName">Nombre</label>
<input type="text" class="form-control" id="fullName" th:field="*{fullName}"
th:classappend="${#fields.hasErrors('fullName')} ? ' is-invalid'" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('fullName')}"
th:errors="*{fullName}">Error</div>
</div>
<div class="mb-3">
<label th:text="#{usuarios.form.email}" for="userName">Correo electrónico</label>
<input type="email" class="form-control" id="userName" th:field="*{userName}"
th:classappend="${#fields.hasErrors('userName')} ? ' is-invalid'" required>
<div class="invalid-feedback" th:if="${#fields.hasErrors('userName')}"
th:errors="*{userName}">Error</div>
</div>
<hr class="my-4">
<div class="mb-3">
<label th:text="#{usuarios.form.password.actual}" for="currentPassword">Contraseña actual</label>
<input type="password" class="form-control" id="currentPassword"
th:field="*{currentPassword}"
th:classappend="${#fields.hasErrors('currentPassword')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('currentPassword')}"
th:errors="*{currentPassword}">Error</div>
</div>
<div class="mb-3">
<label th:text="#{usuarios.form.password.nueva}" for="newPassword">Nueva contraseña</label>
<input type="password" class="form-control" id="newPassword"
th:field="*{newPassword}"
th:classappend="${#fields.hasErrors('newPassword')} ? ' is-invalid'">
<div class="text-muted" th:text="#{usuarios.form.password.nota}">
Solo podrás cambiar la contraseña si indicas la actual.
</div>
<div class="invalid-feedback" th:if="${#fields.hasErrors('newPassword')}"
th:errors="*{newPassword}">Error</div>
</div>
<div class="mb-3">
<label th:text="#{usuarios.form.confirmarPassword}" for="confirmPassword">Confirmar contraseña</label>
<input type="password" class="form-control" id="confirmPassword"
th:field="*{confirmPassword}"
th:classappend="${#fields.hasErrors('confirmPassword')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('confirmPassword')}"
th:errors="*{confirmPassword}">Error</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-secondary" th:text="#{app.guardar}">Guardar</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
</body>
</html>