fix(auth/users): corregir edicion de contraseñas de usuarios, cambio de contraseña y evitar pantalla en blanco
This commit is contained in:
@@ -444,6 +444,38 @@ def get_document_types():
|
|||||||
'document_types': DOCUMENT_TYPES
|
'document_types': DOCUMENT_TYPES
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
@api_admin_bp.route('/users/<int:id>/password', methods=['POST', 'PUT'])
|
||||||
|
@jwt_required
|
||||||
|
def reset_user_password(id):
|
||||||
|
"""
|
||||||
|
Permite a los administradores restablecer la contraseña de acceso de cualquier usuario.
|
||||||
|
"""
|
||||||
|
if not g.jwt_user.is_admin():
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Forbidden',
|
||||||
|
'message': 'Solo los administradores institucionales pueden restablecer contraseñas.'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
user = User.query.get_or_404(id)
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
new_password = (data.get('password') or data.get('new_password') or '').strip()
|
||||||
|
|
||||||
|
if not new_password or len(new_password) < 6:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'La nueva contraseña debe contener al menos 6 caracteres.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Contraseña restablecida exitosamente para {user.name}.',
|
||||||
|
'user_id': user.id
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
|
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def toggle_user(id):
|
def toggle_user(id):
|
||||||
|
|||||||
@@ -340,6 +340,54 @@ def logout():
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route('/change_password', methods=['POST'])
|
||||||
|
@api_auth_bp.route('/change-password', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def change_password():
|
||||||
|
"""
|
||||||
|
Permite al usuario autenticado cambiar su propia contraseña institucional.
|
||||||
|
Valida la contraseña actual y la confirmación de la nueva clave.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
current_password = data.get('current_password', '').strip()
|
||||||
|
new_password = data.get('new_password', '').strip()
|
||||||
|
confirm_password = data.get('confirm_password', '').strip()
|
||||||
|
|
||||||
|
if not current_password or not new_password:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'Debe ingresar la contraseña actual y la nueva contraseña.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if new_password != confirm_password:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'La nueva contraseña y su confirmación no coinciden.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'La nueva contraseña debe tener al menos 6 caracteres.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
user = g.jwt_user
|
||||||
|
if not user.check_password(current_password):
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Unauthorized',
|
||||||
|
'message': 'La contraseña actual ingresada es incorrecta.'
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"[Auth] Password successfully updated for user {user.email}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Contraseña actualizada correctamente.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_auth_bp.route('/me', methods=['GET'])
|
@api_auth_bp.route('/me', methods=['GET'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def get_current_user():
|
def get_current_user():
|
||||||
@@ -348,3 +396,4 @@ def get_current_user():
|
|||||||
"""
|
"""
|
||||||
profile = user_service.get_profile_data(g.jwt_user)
|
profile = user_service.get_profile_data(g.jwt_user)
|
||||||
return jsonify(profile), 200
|
return jsonify(profile), 200
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,84 @@ app.get('/:blueprint/:route', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Middleware 404 para cualquier ruta no encontrada (evita pantallas en blanco y errores Cannot POST/GET)
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Página No Encontrada — Edu-Space</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #f8fafc; font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; }
|
||||||
|
.card-error { background: #1e293b; border: 1px solid #334155; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container text-center p-4" style="max-width: 520px;">
|
||||||
|
<div class="card card-error p-4 p-md-5">
|
||||||
|
<div class="mb-3 text-warning">
|
||||||
|
<i class="bi bi-compass fs-1"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="h3 fw-bold mb-2">Página o Acción No Encontrada</h1>
|
||||||
|
<p class="text-secondary small mb-4">
|
||||||
|
La ruta solicitada <code class="text-light">${req.originalUrl}</code> no existe o no admite el método HTTP ejecutado.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex justify-content-center gap-2">
|
||||||
|
<a href="/dashboard" class="btn btn-primary px-4 fw-semibold">
|
||||||
|
<i class="bi bi-house-door me-1"></i>Volver al Panel
|
||||||
|
</a>
|
||||||
|
<button onclick="window.history.back()" class="btn btn-outline-light px-4">
|
||||||
|
Volver Atrás
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware Global de Error 500
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Error no capturado en BFF:', err);
|
||||||
|
res.status(500).send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Error del Sistema — Edu-Space</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #f8fafc; font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; }
|
||||||
|
.card-error { background: #1e293b; border: 1px solid #334155; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container text-center p-4" style="max-width: 520px;">
|
||||||
|
<div class="card card-error p-4 p-md-5">
|
||||||
|
<div class="mb-3 text-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill fs-1"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="h3 fw-bold mb-2">Error en la Aplicación</h1>
|
||||||
|
<p class="text-secondary small mb-4">
|
||||||
|
Ocurrió una eventualidad procesando la solicitud. Nuestro equipo técnico ha sido notificado.
|
||||||
|
</p>
|
||||||
|
<a href="/dashboard" class="btn btn-primary px-4 fw-semibold">
|
||||||
|
<i class="bi bi-house-door me-1"></i>Volver al Panel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`BFF Frontend corriendo en http://localhost:${port}`);
|
console.log(`BFF Frontend corriendo en http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -428,6 +428,10 @@ const handleUserSave = async (req, res) => {
|
|||||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||||
};
|
};
|
||||||
if (password && password.trim()) {
|
if (password && password.trim()) {
|
||||||
|
if (password.trim().length < 6) {
|
||||||
|
const errMsg = encodeURIComponent('La contraseña debe tener al menos 6 caracteres.');
|
||||||
|
return res.redirect(userId ? `/admin/users/edit/${userId}?error=${errMsg}` : `/admin/user_add?error=${errMsg}`);
|
||||||
|
}
|
||||||
payload.password = password.trim();
|
payload.password = password.trim();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -440,6 +444,30 @@ const handleUserSave = async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('User save API notice:', e.response?.data || e.message);
|
console.warn('User save API notice:', e.response?.data || e.message);
|
||||||
const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al guardar el usuario');
|
const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al guardar el usuario');
|
||||||
|
return res.redirect(userId ? `/admin/users/edit/${userId}?error=${errMsg}` : `/admin/users_list?error=${errMsg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserPasswordReset = async (req, res) => {
|
||||||
|
const userId = req.params.id || req.body.id || req.body.user_id;
|
||||||
|
const password = (req.body.password || req.body.new_password || '').trim();
|
||||||
|
|
||||||
|
if (!userId || !password) {
|
||||||
|
const errMsg = encodeURIComponent('Debe seleccionar un usuario e ingresar la nueva contraseña.');
|
||||||
|
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
const errMsg = encodeURIComponent('La nueva contraseña debe tener al menos 6 caracteres.');
|
||||||
|
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/users/${userId}/password`, { password });
|
||||||
|
return res.redirect('/admin/users_list?msg=password_updated');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Password reset notice:', e.response?.data || e.message);
|
||||||
|
const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al restablecer la contraseña.');
|
||||||
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -447,8 +475,10 @@ const handleUserSave = async (req, res) => {
|
|||||||
router.get(['/users_list', '/users', '/user_list'], handleUsersList);
|
router.get(['/users_list', '/users', '/user_list'], handleUsersList);
|
||||||
router.get(['/user_add', '/users/new'], handleUserAdd);
|
router.get(['/user_add', '/users/new'], handleUserAdd);
|
||||||
router.get(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserEdit);
|
router.get(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserEdit);
|
||||||
router.post(['/user_add', '/users/new', '/user_save'], handleUserSave);
|
router.post(['/user_add', '/users/new', '/user_save', '/users/save'], handleUserSave);
|
||||||
router.post(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserSave);
|
router.post(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserSave);
|
||||||
|
router.post(['/users/:id/password', '/users/password', '/user_password', '/user_password/:id', '/user_reset_password'], handleUserPasswordReset);
|
||||||
|
|
||||||
|
|
||||||
// ─── 4. ROLES ─────────────────────────────────────────────────────────────────
|
// ─── 4. ROLES ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -165,4 +165,65 @@ router.get('/stop-impersonating', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cambiar Contraseña Propia (GET)
|
||||||
|
router.get(['/change_password', '/change-password'], requireAuth, (req, res) => {
|
||||||
|
res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: req.query.error || null,
|
||||||
|
success: req.query.success || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cambiar Contraseña Propia (POST)
|
||||||
|
router.post(['/change_password', '/change-password'], requireAuth, async (req, res) => {
|
||||||
|
const { current_password, new_password, confirm_password } = req.body;
|
||||||
|
|
||||||
|
if (!current_password || !new_password || !confirm_password) {
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: 'Todos los campos son obligatorios.',
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password !== confirm_password) {
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: 'La nueva contraseña y su confirmación no coinciden.',
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password.length < 6) {
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: 'La nueva contraseña debe tener al menos 6 caracteres.',
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await req.apiClient.post('/auth/change_password', {
|
||||||
|
current_password,
|
||||||
|
new_password,
|
||||||
|
confirm_password
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
success: response.data?.message || 'Contraseña actualizada correctamente.',
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Change password error:', error.response?.data || error.message);
|
||||||
|
const errMsg = error.response?.data?.message || 'Error al actualizar la contraseña. Verifique su clave actual.';
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: errMsg,
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form method="POST">
|
{% if error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{% if user and user.id %}/admin/users/edit/{{ user.id }}{% else %}/admin/users/new{% endif %}">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<input type="hidden" name="id" value="{{ user.id }}">
|
<input type="hidden" name="id" value="{{ user.id }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<!-- SECCIÓN 1: IDENTIDAD Y DOCUMENTACIÓN -->
|
<!-- SECCIÓN 1: IDENTIDAD Y DOCUMENTACIÓN -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
|
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
|
||||||
@@ -164,12 +172,15 @@
|
|||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key"></i></span>
|
||||||
<input type="password" class="form-control" id="password" name="password"
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
{% if not user %}required{% endif %}
|
{% if not user %}required minlength="6"{% else %}minlength="6"{% endif %}
|
||||||
placeholder="{% if user %}Dejar en blanco para mantener{% else %}Mínimo 6 caracteres{% endif %}">
|
placeholder="{% if user %}Dejar en blanco para mantener{% else %}Mínimo 6 caracteres{% endif %}">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="togglePasswordBtn" title="Mostrar u ocultar contraseña">
|
||||||
|
<i class="bi bi-eye" id="togglePasswordIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<small class="text-muted d-block mt-1">
|
<small class="text-muted d-block mt-1">
|
||||||
Solo complete este campo si desea restablecer la contraseña del usuario.
|
Solo complete este campo si desea restablecer la contraseña del usuario (mínimo 6 caracteres).
|
||||||
</small>
|
</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,6 +261,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
docTypeSelect.addEventListener('change', updateDocHint);
|
docTypeSelect.addEventListener('change', updateDocHint);
|
||||||
updateDocHint();
|
updateDocHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var togglePasswordBtn = document.getElementById('togglePasswordBtn');
|
||||||
|
var passwordInput = document.getElementById('password');
|
||||||
|
var togglePasswordIcon = document.getElementById('togglePasswordIcon');
|
||||||
|
if (togglePasswordBtn && passwordInput && togglePasswordIcon) {
|
||||||
|
togglePasswordBtn.addEventListener('click', function() {
|
||||||
|
var isPass = passwordInput.getAttribute('type') === 'password';
|
||||||
|
passwordInput.setAttribute('type', isPass ? 'text' : 'password');
|
||||||
|
togglePasswordIcon.className = isPass ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<i class="bi bi-check-circle-fill me-2"></i>Los datos del usuario han sido guardados exitosamente.
|
<i class="bi bi-check-circle-fill me-2"></i>Los datos del usuario han sido guardados exitosamente.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif msg == 'password_updated' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-shield-check me-2"></i>La contraseña del usuario ha sido restablecida exitosamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% elif error %}
|
{% elif error %}
|
||||||
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||||
@@ -182,6 +187,13 @@
|
|||||||
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Editar Ficha de Usuario">
|
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Editar Ficha de Usuario">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-reset-password"
|
||||||
|
data-user-id="{{ u.id }}"
|
||||||
|
data-user-name="{{ u.name }}"
|
||||||
|
data-user-email="{{ u.email }}"
|
||||||
|
title="Restablecer Contraseña">
|
||||||
|
<i class="bi bi-key"></i>
|
||||||
|
</button>
|
||||||
{% if u.id != user.id %}
|
{% if u.id != user.id %}
|
||||||
<a href="/auth/impersonate/{{ u.id }}" class="btn btn-outline-info" title="Impersonar usuario (Iniciar sesión como {{ u.name }})">
|
<a href="/auth/impersonate/{{ u.id }}" class="btn btn-outline-info" title="Impersonar usuario (Iniciar sesión como {{ u.name }})">
|
||||||
<i class="bi bi-incognito"></i>
|
<i class="bi bi-incognito"></i>
|
||||||
@@ -274,6 +286,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Restablecimiento Directo de Contraseña -->
|
||||||
|
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="badge bg-warning-subtle text-warning p-2 rounded-circle me-2">
|
||||||
|
<i class="bi bi-key-fill fs-5"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="modal-title fw-bold mb-0">Restablecer Contraseña</h5>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="resetPasswordForm" method="POST" action="/admin/users/password">
|
||||||
|
<input type="hidden" name="id" id="resetPasswordUserId">
|
||||||
|
<div class="modal-body text-start py-3">
|
||||||
|
<p class="mb-2 text-muted small">
|
||||||
|
Ingrese la nueva contraseña de acceso institucional para el siguiente usuario:
|
||||||
|
</p>
|
||||||
|
<div class="p-2 rounded bg-body-tertiary mb-3">
|
||||||
|
<div class="fw-bold text-primary" id="resetPasswordUserName"></div>
|
||||||
|
<div class="small text-muted font-monospace" id="resetPasswordUserEmail"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="modalNewPassword" class="form-label small fw-semibold">Nueva Contraseña <span class="text-danger">*</span></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-lock"></i></span>
|
||||||
|
<input type="password" class="form-control" id="modalNewPassword" name="password" required minlength="6" placeholder="Mínimo 6 caracteres">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="toggleModalPasswordBtn" title="Mostrar/Ocultar clave">
|
||||||
|
<i class="bi bi-eye" id="toggleModalPasswordIcon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text small">
|
||||||
|
La contraseña debe contener al menos 6 caracteres.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-warning px-3 fw-semibold">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Guardar Contraseña
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@@ -295,7 +355,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (emailEl) emailEl.textContent = email;
|
if (emailEl) emailEl.textContent = email;
|
||||||
if (formEl) {
|
if (formEl) {
|
||||||
formEl.action = actionUrl;
|
formEl.action = actionUrl;
|
||||||
// Add hidden input with id
|
|
||||||
var hiddenId = formEl.querySelector('input[name="id"]');
|
var hiddenId = formEl.querySelector('input[name="id"]');
|
||||||
if (!hiddenId) {
|
if (!hiddenId) {
|
||||||
hiddenId = document.createElement('input');
|
hiddenId = document.createElement('input');
|
||||||
@@ -308,6 +367,40 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (deleteModal) deleteModal.show();
|
if (deleteModal) deleteModal.show();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset Password Modal
|
||||||
|
var resetModalEl = document.getElementById('resetPasswordModal');
|
||||||
|
var resetModal = resetModalEl ? new bootstrap.Modal(resetModalEl) : null;
|
||||||
|
var resetUserIdInput = document.getElementById('resetPasswordUserId');
|
||||||
|
var resetUserName = document.getElementById('resetPasswordUserName');
|
||||||
|
var resetUserEmail = document.getElementById('resetPasswordUserEmail');
|
||||||
|
var resetPasswordForm = document.getElementById('resetPasswordForm');
|
||||||
|
var modalNewPassword = document.getElementById('modalNewPassword');
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-reset-password').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var userId = this.getAttribute('data-user-id');
|
||||||
|
var name = this.getAttribute('data-user-name');
|
||||||
|
var email = this.getAttribute('data-user-email');
|
||||||
|
if (resetUserIdInput) resetUserIdInput.value = userId;
|
||||||
|
if (resetUserName) resetUserName.textContent = name;
|
||||||
|
if (resetUserEmail) resetUserEmail.textContent = email;
|
||||||
|
if (resetPasswordForm) resetPasswordForm.action = '/admin/users/' + userId + '/password';
|
||||||
|
if (modalNewPassword) modalNewPassword.value = '';
|
||||||
|
if (resetModal) resetModal.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var toggleModalPasswordBtn = document.getElementById('toggleModalPasswordBtn');
|
||||||
|
var toggleModalPasswordIcon = document.getElementById('toggleModalPasswordIcon');
|
||||||
|
if (toggleModalPasswordBtn && modalNewPassword && toggleModalPasswordIcon) {
|
||||||
|
toggleModalPasswordBtn.addEventListener('click', function() {
|
||||||
|
var isPass = modalNewPassword.getAttribute('type') === 'password';
|
||||||
|
modalNewPassword.setAttribute('type', isPass ? 'text' : 'password');
|
||||||
|
toggleModalPasswordIcon.className = isPass ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %}
|
{% block title %}Cambiar Contraseña — Edu-Space{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container pt-5 mt-4">
|
<div class="container pt-5 mt-4">
|
||||||
@@ -8,49 +8,76 @@
|
|||||||
<div class="col-lg-6 col-md-8">
|
<div class="col-lg-6 col-md-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<h1 class="h3 mb-0">
|
<div>
|
||||||
|
<h1 class="h3 mb-1 fw-bold">
|
||||||
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
|
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">Actualización segura de credencial de acceso institucional</p>
|
||||||
|
</div>
|
||||||
<a href="/auth/profile" class="btn btn-outline-secondary btn-sm">
|
<a href="/auth/profile" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
|
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback Alerts -->
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Form Card -->
|
<!-- Form Card -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form method="POST" action="/auth/change_password">
|
<form method="POST" action="/auth/change_password">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="current_password" class="form-label">Contraseña Actual</label>
|
<label for="current_password" class="form-label small fw-semibold">Contraseña Actual <span class="text-danger">*</span></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-lock"></i></span>
|
||||||
<input type="password" class="form-control" id="current_password" name="current_password" required placeholder="Ingresa tu contraseña actual">
|
<input type="password" class="form-control" id="current_password" name="current_password" required placeholder="Ingresa tu contraseña actual">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('current_password', 'currentPassIcon')" title="Ver/Ocultar">
|
||||||
|
<i class="bi bi-eye" id="currentPassIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="new_password" class="form-label">Nueva Contraseña</label>
|
<label for="new_password" class="form-label small fw-semibold">Nueva Contraseña <span class="text-danger">*</span></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key"></i></span>
|
||||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6" placeholder="Mínimo 6 caracteres">
|
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6" placeholder="Mínimo 6 caracteres">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('new_password', 'newPassIcon')" title="Ver/Ocultar">
|
||||||
|
<i class="bi bi-eye" id="newPassIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Debe tener al menos 6 caracteres.</div>
|
<div class="form-text small">La nueva clave debe tener al menos 6 caracteres.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="confirm_password" class="form-label">Confirmar Nueva Contraseña</label>
|
<label for="confirm_password" class="form-label small fw-semibold">Confirmar Nueva Contraseña <span class="text-danger">*</span></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-check2-circle"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-check2-circle"></i></span>
|
||||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="6" placeholder="Repite la nueva contraseña">
|
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="6" placeholder="Repite la nueva contraseña">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('confirm_password', 'confirmPassIcon')" title="Ver/Ocultar">
|
||||||
|
<i class="bi bi-eye" id="confirmPassIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary py-2 fw-semibold shadow-sm">
|
||||||
<i class="bi bi-save me-1"></i>Actualizar Contraseña
|
<i class="bi bi-shield-check me-1"></i>Actualizar Contraseña
|
||||||
</button>
|
</button>
|
||||||
<a href="/auth/profile" class="btn btn-link text-muted">
|
<a href="/auth/profile" class="btn btn-link text-muted text-decoration-none">
|
||||||
Cancelar
|
Cancelar y volver
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -60,3 +87,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function togglePass(inputId, iconId) {
|
||||||
|
var input = document.getElementById(inputId);
|
||||||
|
var icon = document.getElementById(iconId);
|
||||||
|
if (input && icon) {
|
||||||
|
var isPass = input.getAttribute('type') === 'password';
|
||||||
|
input.setAttribute('type', isPass ? 'text' : 'password');
|
||||||
|
icon.className = isPass ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user