From 25ed0f64521b9a7824d28abd1036c3e730d18f84 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 15 Apr 2026 18:09:59 -0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20ChangeMyPasswordPage=20+=20ResetPa?= =?UTF-8?q?sswordModal=20=E2=80=94=20hooks,=20pages,=20modal=20[UDT-008]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/profile/api/changeMyPassword.ts | 10 ++ .../profile/hooks/useChangeMyPassword.ts | 9 + .../profile/pages/ChangeMyPasswordPage.tsx | 121 ++++++++++++++ .../features/users/api/resetUserPassword.ts | 13 ++ .../users/components/ResetPasswordModal.tsx | 157 ++++++++++++++++++ .../users/hooks/useResetUserPassword.ts | 8 + .../profile/ChangeMyPasswordPage.test.tsx | 139 ++++++++++++++++ .../users/ResetPasswordModal.test.tsx | 117 +++++++++++++ 8 files changed, 574 insertions(+) create mode 100644 src/web/src/features/profile/api/changeMyPassword.ts create mode 100644 src/web/src/features/profile/hooks/useChangeMyPassword.ts create mode 100644 src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx create mode 100644 src/web/src/features/users/api/resetUserPassword.ts create mode 100644 src/web/src/features/users/components/ResetPasswordModal.tsx create mode 100644 src/web/src/features/users/hooks/useResetUserPassword.ts create mode 100644 src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx create mode 100644 src/web/src/tests/features/users/ResetPasswordModal.test.tsx diff --git a/src/web/src/features/profile/api/changeMyPassword.ts b/src/web/src/features/profile/api/changeMyPassword.ts new file mode 100644 index 0000000..fa777fd --- /dev/null +++ b/src/web/src/features/profile/api/changeMyPassword.ts @@ -0,0 +1,10 @@ +import { axiosClient } from '@/api/axiosClient' + +export interface ChangeMyPasswordRequest { + oldPassword: string + newPassword: string +} + +export async function changeMyPassword(payload: ChangeMyPasswordRequest): Promise { + await axiosClient.put('/api/v1/users/me/password', payload) +} diff --git a/src/web/src/features/profile/hooks/useChangeMyPassword.ts b/src/web/src/features/profile/hooks/useChangeMyPassword.ts new file mode 100644 index 0000000..fdafe73 --- /dev/null +++ b/src/web/src/features/profile/hooks/useChangeMyPassword.ts @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query' +import { changeMyPassword } from '../api/changeMyPassword' +import type { ChangeMyPasswordRequest } from '../api/changeMyPassword' + +export function useChangeMyPassword() { + return useMutation({ + mutationFn: (payload: ChangeMyPasswordRequest) => changeMyPassword(payload), + }) +} diff --git a/src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx b/src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx new file mode 100644 index 0000000..70d8e2c --- /dev/null +++ b/src/web/src/features/profile/pages/ChangeMyPasswordPage.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { isAxiosError } from 'axios' +import { toast } from 'sonner' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { useChangeMyPassword } from '../hooks/useChangeMyPassword' +import { useAuthStore } from '@/stores/authStore' + +export function ChangeMyPasswordPage() { + const navigate = useNavigate() + const { mutate, isPending } = useChangeMyPassword() + const updateUser = useAuthStore((s) => s.updateUser) + + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [clientError, setClientError] = useState(null) + const [serverError, setServerError] = useState(null) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setClientError(null) + setServerError(null) + + if (newPassword !== confirmPassword) { + setClientError('Las contraseñas no coinciden') + return + } + + mutate( + { oldPassword, newPassword }, + { + onSuccess: () => { + // Clear mustChangePassword flag in store + updateUser({ mustChangePassword: false }) + toast.success('Contraseña actualizada correctamente') + navigate('/') + }, + onError: (err) => { + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; title?: string } + if (data.error === 'invalid-old-password' || data.title === 'invalid-old-password') { + setServerError('La contraseña actual es incorrecta') + return + } + } + setServerError('Error al cambiar la contraseña. Intentá nuevamente.') + }, + }, + ) + } + + return ( +
+

Cambiar contraseña

+ +
+ {clientError && ( + + + {clientError} + + )} + + {serverError && ( + + + {serverError} + + )} + +
+ + setOldPassword(e.target.value)} + disabled={isPending} + autoComplete="current-password" + aria-label="Contraseña actual" + /> +
+ +
+ + setNewPassword(e.target.value)} + disabled={isPending} + autoComplete="new-password" + aria-label="Nueva contraseña" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isPending} + autoComplete="new-password" + aria-label="Confirmar contraseña" + /> +
+ + +
+
+ ) +} diff --git a/src/web/src/features/users/api/resetUserPassword.ts b/src/web/src/features/users/api/resetUserPassword.ts new file mode 100644 index 0000000..a436b99 --- /dev/null +++ b/src/web/src/features/users/api/resetUserPassword.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' + +export interface ResetPasswordResponse { + tempPassword: string + mustChangeOnLogin: boolean +} + +export async function resetUserPassword(userId: number): Promise { + const response = await axiosClient.post( + `/api/v1/users/${userId}/password/reset`, + ) + return response.data +} diff --git a/src/web/src/features/users/components/ResetPasswordModal.tsx b/src/web/src/features/users/components/ResetPasswordModal.tsx new file mode 100644 index 0000000..3ef4823 --- /dev/null +++ b/src/web/src/features/users/components/ResetPasswordModal.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { Copy, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertCircle } from 'lucide-react' +import { useResetUserPassword } from '../hooks/useResetUserPassword' + +interface ResetPasswordModalProps { + userId: number +} + +type ModalState = 'idle' | 'confirming' | 'showing-password' | 'error' + +export function ResetPasswordModal({ userId }: ResetPasswordModalProps) { + const [open, setOpen] = useState(false) + const [modalState, setModalState] = useState('idle') + const [tempPassword, setTempPassword] = useState(null) + const [copyDone, setCopyDone] = useState(false) + const [errorMsg, setErrorMsg] = useState(null) + + const { mutate, isPending } = useResetUserPassword() + + function handleOpen() { + setModalState('confirming') + setTempPassword(null) + setCopyDone(false) + setErrorMsg(null) + setOpen(true) + } + + function handleCancel() { + setOpen(false) + setModalState('idle') + } + + function handleConfirm() { + mutate(userId, { + onSuccess: (data) => { + setTempPassword(data.tempPassword) + setModalState('showing-password') + }, + onError: () => { + setErrorMsg('Error al resetear la contraseña. Intentá de nuevo.') + setModalState('error') + }, + }) + } + + async function handleCopy() { + if (tempPassword) { + await navigator.clipboard.writeText(tempPassword) + setCopyDone(true) + } + } + + return ( + + + + + + + + +
+ + Resetear contraseña + + + + +
+ + + Resetear contraseña del usuario + + + {modalState === 'confirming' && ( +
+

+ ¿Estás seguro que querés resetear la contraseña de este usuario? + Se generará una contraseña temporal y se invalidarán todas sus sesiones activas. +

+
+ + +
+
+ )} + + {modalState === 'showing-password' && tempPassword && ( +
+ + + + Esta es la única vez que verás esta contraseña. Copiála ahora. + + + +
+

{tempPassword}

+
+ + + + +
+ )} + + {modalState === 'error' && ( +
+ + + {errorMsg} + +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/src/web/src/features/users/hooks/useResetUserPassword.ts b/src/web/src/features/users/hooks/useResetUserPassword.ts new file mode 100644 index 0000000..7a05cdf --- /dev/null +++ b/src/web/src/features/users/hooks/useResetUserPassword.ts @@ -0,0 +1,8 @@ +import { useMutation } from '@tanstack/react-query' +import { resetUserPassword } from '../api/resetUserPassword' + +export function useResetUserPassword() { + return useMutation({ + mutationFn: (userId: number) => resetUserPassword(userId), + }) +} diff --git a/src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx b/src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx new file mode 100644 index 0000000..4ac6336 --- /dev/null +++ b/src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { ChangeMyPasswordPage } from '../../../features/profile/pages/ChangeMyPasswordPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const authUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: [], mustChangePassword: true, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage() { + useAuthStore.setState({ user: authUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + } /> + Home} /> + + + , + ) +} + +// Helper: get form fields by their input id +function getOldPasswordInput() { return screen.getByLabelText('Contraseña actual') } +function getNewPasswordInput() { return screen.getByLabelText('Nueva contraseña') } +function getConfirmPasswordInput() { return screen.getByLabelText('Confirmar nueva contraseña') } +function getSubmitButton() { return screen.getByRole('button', { name: /cambiar contraseña/i }) } + +describe('ChangeMyPasswordPage', () => { + it('shows validation error when passwords do not match', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => { + throw new Error('Should not be called') + }), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'current123') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'DifferentPass456') + + await userEvent.click(getSubmitButton()) + + await waitFor(() => + expect(screen.getByText(/no coinciden/i)).toBeInTheDocument(), + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('no HTTP call when passwords do not match', async () => { + let httpCalled = false + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => { + httpCalled = true + return HttpResponse.json({}, { status: 204 }) + }), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'current123') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'WrongConfirm') + + await userEvent.click(getSubmitButton()) + + await new Promise((r) => setTimeout(r, 100)) + expect(httpCalled).toBe(false) + }) + + it('submit success → updates authStore mustChangePassword to false + navigate home', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => + new HttpResponse(null, { status: 204 }), + ), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'current123') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'NewPass123') + + await userEvent.click(getSubmitButton()) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/')) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + }) + + it('shows invalid-old-password error message on 400', async () => { + server.use( + http.put(`${API_URL}/api/v1/users/me/password`, () => + HttpResponse.json({ error: 'invalid-old-password' }, { status: 400 }), + ), + ) + + renderPage() + + await userEvent.type(getOldPasswordInput(), 'wrongpassword') + await userEvent.type(getNewPasswordInput(), 'NewPass123') + await userEvent.type(getConfirmPasswordInput(), 'NewPass123') + + await userEvent.click(getSubmitButton()) + + await waitFor(() => + expect(screen.getByText(/contraseña actual es incorrecta/i)).toBeInTheDocument(), + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/tests/features/users/ResetPasswordModal.test.tsx b/src/web/src/tests/features/users/ResetPasswordModal.test.tsx new file mode 100644 index 0000000..5997ce1 --- /dev/null +++ b/src/web/src/tests/features/users/ResetPasswordModal.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { ResetPasswordModal } from '../../../features/users/components/ResetPasswordModal' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +const adminUser = { + id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(userId = 5) { + useAuthStore.setState({ user: adminUser }) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('ResetPasswordModal', () => { + it('shows trigger button and modal closed by default', () => { + renderModal() + expect(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })).toBeInTheDocument() + expect(screen.queryByText(/confirmar|advertencia|única vez/i)).not.toBeInTheDocument() + }) + + it('trigger button → modal opens with confirmation', async () => { + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear contraseña/i })) + + // Modal should now show the confirm button + expect(screen.getByRole('button', { name: /confirmar/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /cancelar/i })).toBeInTheDocument() + }) + + it('cancel → modal closes without HTTP call', async () => { + let httpCalled = false + server.use( + http.post(`${API_URL}/api/v1/users/5/password/reset`, () => { + httpCalled = true + return HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }) + }), + ) + + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })) + await userEvent.click(screen.getByRole('button', { name: /cancelar|cancel/i })) + + await new Promise((r) => setTimeout(r, 100)) + expect(httpCalled).toBe(false) + expect(screen.queryByText(/contraseña temporal|tempPassword/i)).not.toBeInTheDocument() + }) + + it('confirm → calls POST and shows tempPassword + warning', async () => { + server.use( + http.post(`${API_URL}/api/v1/users/5/password/reset`, () => + HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }), + ), + ) + + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })) + await userEvent.click(screen.getByRole('button', { name: /confirmar|confirm/i })) + + await waitFor(() => expect(screen.getByText('Ax!k9mQ3@rT2')).toBeInTheDocument()) + expect(screen.getByText(/única vez|solo una vez|this is the only time/i)).toBeInTheDocument() + }) + + it('copy button calls clipboard.writeText with tempPassword', async () => { + const clipboardWriteText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: clipboardWriteText }, + writable: true, + }) + + server.use( + http.post(`${API_URL}/api/v1/users/5/password/reset`, () => + HttpResponse.json({ tempPassword: 'Ax!k9mQ3@rT2', mustChangeOnLogin: true }), + ), + ) + + renderModal() + + await userEvent.click(screen.getByRole('button', { name: /resetear|reset.*contraseña/i })) + await userEvent.click(screen.getByRole('button', { name: /confirmar|confirm/i })) + + await waitFor(() => expect(screen.getByText('Ax!k9mQ3@rT2')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /copiar|copy/i })) + + expect(clipboardWriteText).toHaveBeenCalledWith('Ax!k9mQ3@rT2') + }) +})