UDT-008: Gestión completa de usuarios #11

Merged
dmolinari merged 16 commits from feature/UDT-008 into main 2026-04-16 00:01:36 +00:00
8 changed files with 574 additions and 0 deletions
Showing only changes of commit 25ed0f6452 - Show all commits

View File

@@ -0,0 +1,10 @@
import { axiosClient } from '@/api/axiosClient'
export interface ChangeMyPasswordRequest {
oldPassword: string
newPassword: string
}
export async function changeMyPassword(payload: ChangeMyPasswordRequest): Promise<void> {
await axiosClient.put('/api/v1/users/me/password', payload)
}

View File

@@ -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),
})
}

View File

@@ -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<string | null>(null)
const [serverError, setServerError] = useState<string | null>(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 (
<div className="max-w-md mx-auto space-y-6">
<h1 className="text-xl font-semibold">Cambiar contraseña</h1>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
{clientError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{clientError}</AlertDescription>
</Alert>
)}
{serverError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{serverError}</AlertDescription>
</Alert>
)}
<div className="space-y-1">
<Label htmlFor="oldPassword">Contraseña actual</Label>
<Input
id="oldPassword"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
disabled={isPending}
autoComplete="current-password"
aria-label="Contraseña actual"
/>
</div>
<div className="space-y-1">
<Label htmlFor="newPassword">Nueva contraseña</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isPending}
autoComplete="new-password"
aria-label="Nueva contraseña"
/>
</div>
<div className="space-y-1">
<Label htmlFor="confirmPassword">Confirmar nueva contraseña</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isPending}
autoComplete="new-password"
aria-label="Confirmar contraseña"
/>
</div>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? 'Cambiando...' : 'Cambiar contraseña'}
</Button>
</form>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { axiosClient } from '@/api/axiosClient'
export interface ResetPasswordResponse {
tempPassword: string
mustChangeOnLogin: boolean
}
export async function resetUserPassword(userId: number): Promise<ResetPasswordResponse> {
const response = await axiosClient.post<ResetPasswordResponse>(
`/api/v1/users/${userId}/password/reset`,
)
return response.data
}

View File

@@ -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<ModalState>('idle')
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copyDone, setCopyDone] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<Button variant="outline" size="sm" onClick={handleOpen}>
Resetear contraseña
</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-40" />
<Dialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-lg shadow-xl p-6 z-50 w-full max-w-md space-y-4"
aria-describedby="reset-pwd-desc"
>
<div className="flex items-center justify-between">
<Dialog.Title className="text-base font-semibold">
Resetear contraseña
</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="sm" onClick={handleCancel} aria-label="Cerrar">
<X className="h-4 w-4" />
</Button>
</Dialog.Close>
</div>
<Dialog.Description id="reset-pwd-desc" className="sr-only">
Resetear contraseña del usuario
</Dialog.Description>
{modalState === 'confirming' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
¿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.
</p>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel} disabled={isPending}>
Cancelar
</Button>
<Button onClick={handleConfirm} disabled={isPending}>
{isPending ? 'Reseteando...' : 'Confirmar'}
</Button>
</div>
</div>
)}
{modalState === 'showing-password' && tempPassword && (
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Esta es la única vez que verás esta contraseña. Copiála ahora.
</AlertDescription>
</Alert>
<div className="rounded-md border border-border bg-muted p-3">
<p className="font-mono text-base tracking-widest select-all">{tempPassword}</p>
</div>
<Button
variant="outline"
className="w-full"
onClick={handleCopy}
>
<Copy className="h-4 w-4 mr-2" />
{copyDone ? '¡Copiado!' : 'Copiar al portapapeles'}
</Button>
<Button
className="w-full"
onClick={() => {
setOpen(false)
setModalState('idle')
}}
>
Cerrar
</Button>
</div>
)}
{modalState === 'error' && (
<div className="space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMsg}</AlertDescription>
</Alert>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}>
Cancelar
</Button>
<Button onClick={() => { setModalState('confirming'); setErrorMsg(null) }}>
Reintentar
</Button>
</div>
</div>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}

View File

@@ -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),
})
}

View File

@@ -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<typeof import('react-router-dom')>()
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(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/perfil/contrasena']}>
<Routes>
<Route path="/perfil/contrasena" element={<ChangeMyPasswordPage />} />
<Route path="/" element={<div>Home</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
// 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()
})
})

View File

@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ResetPasswordModal userId={userId} />
</MemoryRouter>
</QueryClientProvider>,
)
}
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')
})
})