feat: cambiar clave desde la app (usuario logueado)
Nuevo PUT /api/auth/change-password: exige la clave actual (a diferencia de reset-password, que usa un token de correo sin sesion). Opcion "Cambiar clave" agregada al menu de usuario (popup), disponible para cualquier rol. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,8 @@
|
|||||||
in-app a los `admin`/`superadmin` activos.
|
in-app a los `admin`/`superadmin` activos.
|
||||||
- `POST /api/auth/forgot-password` — público. `{ email }` → respuesta **siempre genérica**.
|
- `POST /api/auth/forgot-password` — público. `{ email }` → respuesta **siempre genérica**.
|
||||||
- `POST /api/auth/reset-password` — público. `{ token, password }`.
|
- `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`
|
- `POST /api/auth/logout`
|
||||||
- `GET /api/auth/me` — usuario actual
|
- `GET /api/auth/me` — usuario actual
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.1.0] - 2026-09-06
|
||||||
### Added
|
### Added
|
||||||
- Autorregistro público (`/registro`) con verificación de correo (enlace de un solo uso, 24h) y
|
- Autorregistro público (`/registro`) con verificación de correo (enlace de un solo uso, 24h) y
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -39,3 +39,9 @@ export const postResetPassword = asyncHandler(async (req, res) => {
|
|||||||
await authService.resetPassword(token, password);
|
await authService.resetPassword(token, password);
|
||||||
ok(res, { reset: true });
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
|
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
|
||||||
|
putChangePassword,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -22,5 +23,6 @@ router.post('/forgot-password', loginLimiter, postForgotPassword);
|
|||||||
router.post('/reset-password', loginLimiter, postResetPassword);
|
router.post('/reset-password', loginLimiter, postResetPassword);
|
||||||
router.post('/logout', requireAuth, postLogout);
|
router.post('/logout', requireAuth, postLogout);
|
||||||
router.get('/me', requireAuth, getMe);
|
router.get('/me', requireAuth, getMe);
|
||||||
|
router.put('/change-password', requireAuth, loginLimiter, putChangePassword);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -170,3 +170,20 @@ export async function resetPassword(token, newPassword) {
|
|||||||
);
|
);
|
||||||
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
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 { NavigationView } from '../components/NavigationView.jsx';
|
||||||
import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.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 { useAuth } from '../context/auth-context.jsx';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
import { useAsync } from '../hooks/useAsync.js';
|
import { useAsync } from '../hooks/useAsync.js';
|
||||||
import { listNotifications, markAllNotificationsRead, markNotificationRead } from '../services/notifications.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() {
|
export default function AppShell() {
|
||||||
const [navMode, setNavMode] = useState('expanded');
|
const [navMode, setNavMode] = useState('expanded');
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { t, theme, setTheme, lang, setLang } = useUi();
|
const { t, theme, setTheme, lang, setLang, pushToast } = useUi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: notifData, reload: reloadNotifications } = useAsync(listNotifications, []);
|
const { data: notifData, reload: reloadNotifications } = useAsync(listNotifications, []);
|
||||||
const unread = notifData?.unread || 0;
|
const unread = notifData?.unread || 0;
|
||||||
|
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
|
const [passwordForm, setPasswordForm] = useState(EMPTY_PASSWORD_FORM);
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
|
||||||
function toggleNav() {
|
function toggleNav() {
|
||||||
setNavMode((m) => (m === 'expanded' ? 'compact' : 'expanded'));
|
setNavMode((m) => (m === 'expanded' ? 'compact' : 'expanded'));
|
||||||
}
|
}
|
||||||
@@ -28,6 +38,31 @@ export default function AppShell() {
|
|||||||
else if (notification.entity_type === 'activity') navigate(`/proyectos`);
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<NavigationView mode={navMode} />
|
<NavigationView mode={navMode} />
|
||||||
@@ -79,6 +114,9 @@ export default function AppShell() {
|
|||||||
<div style={{ fontSize: '.7rem', color: 'rgb(var(--muted))' }}>{user?.roleName || user?.roleCode}</div>
|
<div style={{ fontSize: '.7rem', color: 'rgb(var(--muted))' }}>{user?.roleName || user?.roleCode}</div>
|
||||||
</div>
|
</div>
|
||||||
<FlyoutSeparator />
|
<FlyoutSeparator />
|
||||||
|
<FlyoutItem icon={KeyRound} onClick={openChangePassword}>
|
||||||
|
{t('change_password_title')}
|
||||||
|
</FlyoutItem>
|
||||||
<FlyoutItem icon={LogOut} danger onClick={() => { logout(); navigate('/login'); }}>
|
<FlyoutItem icon={LogOut} danger onClick={() => { logout(); navigate('/login'); }}>
|
||||||
{t('logout')}
|
{t('logout')}
|
||||||
</FlyoutItem>
|
</FlyoutItem>
|
||||||
@@ -89,6 +127,23 @@ export default function AppShell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DetailPopup open={changingPassword} title={t('change_password_title')} onClose={() => setChangingPassword(false)} width={420} height={380}>
|
||||||
|
{passwordError && <div className="login-error">{passwordError}</div>}
|
||||||
|
<Field label={t('change_password_current')}>
|
||||||
|
<input className="input" type="password" value={passwordForm.currentPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, currentPassword: e.target.value }))} autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('reset_password_new')}>
|
||||||
|
<input className="input" type="password" value={passwordForm.newPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, newPassword: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('register_password_confirm')}>
|
||||||
|
<input className="input" type="password" value={passwordForm.confirmPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, confirmPassword: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="accent" icon={Save} onClick={submitChangePassword}>{t('common_save')}</Button>
|
||||||
|
<Button variant="standard" onClick={() => setChangingPassword(false)}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</DetailPopup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export const messages = {
|
|||||||
reset_password_new: 'Clave nueva',
|
reset_password_new: 'Clave nueva',
|
||||||
reset_password_submit: 'Guardar clave',
|
reset_password_submit: 'Guardar clave',
|
||||||
reset_password_success: 'Clave actualizada. Redirigiendo a iniciar sesion...',
|
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_verification: 'Pendiente de verificar correo',
|
||||||
user_status_pending_approval: 'Pendiente de aprobacion',
|
user_status_pending_approval: 'Pendiente de aprobacion',
|
||||||
@@ -206,6 +209,9 @@ export const messages = {
|
|||||||
reset_password_new: 'New password',
|
reset_password_new: 'New password',
|
||||||
reset_password_submit: 'Save password',
|
reset_password_submit: 'Save password',
|
||||||
reset_password_success: 'Password updated. Redirecting to sign in...',
|
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_verification: 'Pending email verification',
|
||||||
user_status_pending_approval: 'Pending approval',
|
user_status_pending_approval: 'Pending approval',
|
||||||
|
|||||||
@@ -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 verifyEmail = (token) => api.get(`/auth/verify-email?token=${encodeURIComponent(token)}`);
|
||||||
export const forgotPassword = (email) => api.post('/auth/forgot-password', { email });
|
export const forgotPassword = (email) => api.post('/auth/forgot-password', { email });
|
||||||
export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password });
|
export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password });
|
||||||
|
export const changePassword = (currentPassword, newPassword) => api.put('/auth/change-password', { currentPassword, newPassword });
|
||||||
|
|||||||
Reference in New Issue
Block a user