From 6ecaef9b9d06e4054847af8bd950d275ef435503 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Wed, 23 Sep 2026 15:38:48 -0300 Subject: [PATCH] =?UTF-8?q?fix(auth/users):=20corregir=20edicion=20de=20co?= =?UTF-8?q?ntrase=C3=B1as=20de=20usuarios,=20cambio=20de=20contrase=C3=B1a?= =?UTF-8?q?=20y=20evitar=20pantalla=20en=20blanco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routes/api/admin.py | 32 ++++++++ backend/app/routes/api/auth.py | 49 ++++++++++++ frontend/src/app.js | 78 +++++++++++++++++++ frontend/src/routes/admin.js | 32 +++++++- frontend/src/routes/auth.js | 61 +++++++++++++++ frontend/views/admin/users/form.html | 28 ++++++- frontend/views/admin/users/list.html | 95 +++++++++++++++++++++++- frontend/views/auth/change_password.html | 74 ++++++++++++++---- 8 files changed, 428 insertions(+), 21 deletions(-) diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index a298b58..1acbbea 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -444,6 +444,38 @@ def get_document_types(): 'document_types': DOCUMENT_TYPES }), 200 +@api_admin_bp.route('/users//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//toggle', methods=['POST']) @jwt_required def toggle_user(id): diff --git a/backend/app/routes/api/auth.py b/backend/app/routes/api/auth.py index 47dc1e4..c03e495 100644 --- a/backend/app/routes/api/auth.py +++ b/backend/app/routes/api/auth.py @@ -340,6 +340,54 @@ def logout(): 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']) @jwt_required def get_current_user(): @@ -348,3 +396,4 @@ def get_current_user(): """ profile = user_service.get_profile_data(g.jwt_user) return jsonify(profile), 200 + diff --git a/frontend/src/app.js b/frontend/src/app.js index 4994027..d9eb846 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -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(` + + + + + + Página No Encontrada — Edu-Space + + + + + +
+
+
+ +
+

Página o Acción No Encontrada

+

+ La ruta solicitada ${req.originalUrl} no existe o no admite el método HTTP ejecutado. +

+
+ + Volver al Panel + + +
+
+
+ + + `); +}); + +// Middleware Global de Error 500 +app.use((err, req, res, next) => { + console.error('Error no capturado en BFF:', err); + res.status(500).send(` + + + + + Error del Sistema — Edu-Space + + + + + +
+
+
+ +
+

Error en la Aplicación

+

+ Ocurrió una eventualidad procesando la solicitud. Nuestro equipo técnico ha sido notificado. +

+ + Volver al Panel + +
+
+ + + `); +}); + app.listen(port, () => { console.log(`BFF Frontend corriendo en http://localhost:${port}`); }); + diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index b023c8b..ae619d5 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -428,6 +428,10 @@ const handleUserSave = async (req, res) => { is_active: is_active === 'on' || is_active === 'true' || is_active === true }; 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(); } try { @@ -440,6 +444,30 @@ const handleUserSave = async (req, res) => { } catch (e) { console.warn('User save API notice:', e.response?.data || e.message); 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}`); } }; @@ -447,8 +475,10 @@ const handleUserSave = async (req, res) => { router.get(['/users_list', '/users', '/user_list'], handleUsersList); router.get(['/user_add', '/users/new'], handleUserAdd); 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(['/users/:id/password', '/users/password', '/user_password', '/user_password/:id', '/user_reset_password'], handleUserPasswordReset); + // ─── 4. ROLES ───────────────────────────────────────────────────────────────── diff --git a/frontend/src/routes/auth.js b/frontend/src/routes/auth.js index 1963030..ddd1007 100644 --- a/frontend/src/routes/auth.js +++ b/frontend/src/routes/auth.js @@ -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; + diff --git a/frontend/views/admin/users/form.html b/frontend/views/admin/users/form.html index 0cd689f..d61116c 100644 --- a/frontend/views/admin/users/form.html +++ b/frontend/views/admin/users/form.html @@ -38,11 +38,19 @@
-
+ {% if error %} + + {% endif %} + + {% if user %} {% endif %} +
@@ -164,12 +172,15 @@
+
{% if user %} - 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). {% endif %}
@@ -250,6 +261,17 @@ document.addEventListener('DOMContentLoaded', function() { docTypeSelect.addEventListener('change', 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'; + }); + } }); {% endblock %} diff --git a/frontend/views/admin/users/list.html b/frontend/views/admin/users/list.html index a1b9c8e..c52f4b4 100644 --- a/frontend/views/admin/users/list.html +++ b/frontend/views/admin/users/list.html @@ -27,6 +27,11 @@ Los datos del usuario han sido guardados exitosamente.
+ {% elif msg == 'password_updated' %} + {% elif error %} + + + {% endblock %} {% block extra_js %} @@ -295,7 +355,6 @@ document.addEventListener('DOMContentLoaded', function() { if (emailEl) emailEl.textContent = email; if (formEl) { formEl.action = actionUrl; - // Add hidden input with id var hiddenId = formEl.querySelector('input[name="id"]'); if (!hiddenId) { hiddenId = document.createElement('input'); @@ -308,6 +367,40 @@ document.addEventListener('DOMContentLoaded', function() { 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'; + }); + } }); {% endblock %} + diff --git a/frontend/views/auth/change_password.html b/frontend/views/auth/change_password.html index 0bd01dc..b29f0fa 100644 --- a/frontend/views/auth/change_password.html +++ b/frontend/views/auth/change_password.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %} +{% block title %}Cambiar Contraseña — Edu-Space{% endblock %} {% block content %}
@@ -8,49 +8,76 @@
+ + {% if success %} + + {% endif %} + + {% if error %} + + {% endif %} + -
+
- +
- + +
- +
- + +
-
Debe tener al menos 6 caracteres.
+
La nueva clave debe tener al menos 6 caracteres.
- +
- + +
- - - Cancelar + + Cancelar y volver
@@ -60,3 +87,18 @@
{% endblock %} + +{% block extra_js %} + +{% endblock %} +