diff --git a/API.md b/API.md index 058724c..bfd0469 100644 --- a/API.md +++ b/API.md @@ -32,6 +32,8 @@ in-app a los `admin`/`superadmin` activos. - `POST /api/auth/forgot-password` — público. `{ email }` → respuesta **siempre genérica**. - `POST /api/auth/reset-password` — público. `{ token, password }`. +- `PUT /api/auth/change-password` — autenticado. `{ currentPassword, newPassword }`, exige la + clave actual (distinto de `reset-password`, que usa un token de correo sin sesión). - `POST /api/auth/logout` - `GET /api/auth/me` — usuario actual diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa74ab..8f1c626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog - project.linkedcorp.net +## [1.2.0] - 2026-09-06 +### Added +- Cambiar clave desde la app: `PUT /api/auth/change-password` (exige la clave actual) + opción + "Cambiar clave" en el menú de usuario (popup), disponible para cualquier usuario logueado. + ## [1.1.0] - 2026-09-06 ### Added - Autorregistro público (`/registro`) con verificación de correo (enlace de un solo uso, 24h) y diff --git a/VERSION b/VERSION index 9084fa2..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/backend/package.json b/backend/package.json index 56dfa08..dfd263c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/auth/controller.js b/backend/src/modules/auth/controller.js index aa8715a..c649c7e 100644 --- a/backend/src/modules/auth/controller.js +++ b/backend/src/modules/auth/controller.js @@ -39,3 +39,9 @@ export const postResetPassword = asyncHandler(async (req, res) => { await authService.resetPassword(token, password); ok(res, { reset: true }); }); + +export const putChangePassword = asyncHandler(async (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + await authService.changePassword(req.currentUser.id, currentPassword, newPassword); + ok(res, { changed: true }); +}); diff --git a/backend/src/modules/auth/routes.js b/backend/src/modules/auth/routes.js index e50f65f..87ca64c 100644 --- a/backend/src/modules/auth/routes.js +++ b/backend/src/modules/auth/routes.js @@ -3,6 +3,7 @@ import rateLimit from 'express-rate-limit'; import { requireAuth } from '../../middleware/auth.js'; import { postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword, + putChangePassword, } from './controller.js'; const router = Router(); @@ -22,5 +23,6 @@ router.post('/forgot-password', loginLimiter, postForgotPassword); router.post('/reset-password', loginLimiter, postResetPassword); router.post('/logout', requireAuth, postLogout); router.get('/me', requireAuth, getMe); +router.put('/change-password', requireAuth, loginLimiter, putChangePassword); export default router; diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js index e3dcc1a..c79e797 100644 --- a/backend/src/modules/auth/service.js +++ b/backend/src/modules/auth/service.js @@ -170,3 +170,20 @@ export async function resetPassword(token, newPassword) { ); await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id }); } + +// Cambio de clave desde la app, con sesion activa (distinto del olvide-clave: exige la clave actual). +export async function changePassword(userId, currentPassword, newPassword) { + if (!currentPassword || !newPassword || newPassword.length < 8) { + throw new HttpError(400, 'La clave actual y una clave nueva de al menos 8 caracteres son obligatorias'); + } + const [rows] = await pool.query('SELECT password_hash FROM users WHERE id = ?', [userId]); + const user = rows[0]; + if (!user) throw new HttpError(404, 'Usuario no encontrado'); + + const validPassword = await bcrypt.compare(currentPassword, user.password_hash); + if (!validPassword) throw new HttpError(400, 'La clave actual no es correcta'); + + const passwordHash = await bcrypt.hash(newPassword, 12); + await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]); + await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId }); +} diff --git a/frontend/package.json b/frontend/package.json index bee2b6a..8e29444 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/AppShell.jsx b/frontend/src/app/AppShell.jsx index 088dc46..b9c29d8 100644 --- a/frontend/src/app/AppShell.jsx +++ b/frontend/src/app/AppShell.jsx @@ -1,22 +1,32 @@ import { useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; -import { Menu, Sun, Moon, Bell, LogOut, User, Globe } from 'lucide-react'; +import { Menu, Sun, Moon, Bell, LogOut, User, Globe, KeyRound, Save } from 'lucide-react'; import { NavigationView } from '../components/NavigationView.jsx'; import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx'; +import { DetailPopup } from '../components/DetailPopup.jsx'; +import { Field } from '../components/Field.jsx'; +import { Button } from '../components/Button.jsx'; import { useAuth } from '../context/auth-context.jsx'; import { useUi } from '../context/ui-context.jsx'; import { useAsync } from '../hooks/useAsync.js'; import { listNotifications, markAllNotificationsRead, markNotificationRead } from '../services/notifications.js'; +import { changePassword } from '../services/auth.js'; + +const EMPTY_PASSWORD_FORM = { currentPassword: '', newPassword: '', confirmPassword: '' }; export default function AppShell() { const [navMode, setNavMode] = useState('expanded'); const { user, logout } = useAuth(); - const { t, theme, setTheme, lang, setLang } = useUi(); + const { t, theme, setTheme, lang, setLang, pushToast } = useUi(); const navigate = useNavigate(); const { data: notifData, reload: reloadNotifications } = useAsync(listNotifications, []); const unread = notifData?.unread || 0; + const [changingPassword, setChangingPassword] = useState(false); + const [passwordForm, setPasswordForm] = useState(EMPTY_PASSWORD_FORM); + const [passwordError, setPasswordError] = useState(''); + function toggleNav() { setNavMode((m) => (m === 'expanded' ? 'compact' : 'expanded')); } @@ -28,6 +38,31 @@ export default function AppShell() { else if (notification.entity_type === 'activity') navigate(`/proyectos`); } + function openChangePassword() { + setPasswordForm(EMPTY_PASSWORD_FORM); + setPasswordError(''); + setChangingPassword(true); + } + + async function submitChangePassword() { + setPasswordError(''); + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + setPasswordError(t('register_password_mismatch')); + return; + } + if (passwordForm.newPassword.length < 8) { + setPasswordError(t('register_password_short')); + return; + } + try { + await changePassword(passwordForm.currentPassword, passwordForm.newPassword); + setChangingPassword(false); + pushToast({ tone: 'success', message: t('change_password_success') }); + } catch (err) { + setPasswordError(err.message || t('common_error')); + } + } + return (
@@ -79,6 +114,9 @@ export default function AppShell() {
{user?.roleName || user?.roleCode}
+ + {t('change_password_title')} + { logout(); navigate('/login'); }}> {t('logout')} @@ -89,6 +127,23 @@ export default function AppShell() { + + setChangingPassword(false)} width={420} height={380}> + {passwordError &&
{passwordError}
} + + setPasswordForm((f) => ({ ...f, currentPassword: e.target.value }))} autoFocus /> + + + setPasswordForm((f) => ({ ...f, newPassword: e.target.value }))} /> + + + setPasswordForm((f) => ({ ...f, confirmPassword: e.target.value }))} /> + +
+ + +
+
); } diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index a25f0a3..19aa996 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -32,6 +32,9 @@ export const messages = { reset_password_new: 'Clave nueva', reset_password_submit: 'Guardar clave', reset_password_success: 'Clave actualizada. Redirigiendo a iniciar sesion...', + change_password_title: 'Cambiar clave', + change_password_current: 'Clave actual', + change_password_success: 'Clave actualizada correctamente.', user_status_pending_verification: 'Pendiente de verificar correo', user_status_pending_approval: 'Pendiente de aprobacion', @@ -206,6 +209,9 @@ export const messages = { reset_password_new: 'New password', reset_password_submit: 'Save password', reset_password_success: 'Password updated. Redirecting to sign in...', + change_password_title: 'Change password', + change_password_current: 'Current password', + change_password_success: 'Password updated successfully.', user_status_pending_verification: 'Pending email verification', user_status_pending_approval: 'Pending approval', diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index 19f1afc..be1fbf3 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -4,3 +4,4 @@ export const register = (data) => api.post('/auth/register', data); export const verifyEmail = (token) => api.get(`/auth/verify-email?token=${encodeURIComponent(token)}`); export const forgotPassword = (email) => api.post('/auth/forgot-password', { email }); export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password }); +export const changePassword = (currentPassword, newPassword) => api.put('/auth/change-password', { currentPassword, newPassword });