diff --git a/src/web/src/components/routing/MustChangePasswordGate.tsx b/src/web/src/components/routing/MustChangePasswordGate.tsx new file mode 100644 index 0000000..e6e6f09 --- /dev/null +++ b/src/web/src/components/routing/MustChangePasswordGate.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuthStore } from '@/stores/authStore' + +interface MustChangePasswordGateProps { + children: ReactNode +} + +/** + * Router guard for the "must change password" flow (UDT-008). + * + * If the authenticated user has mustChangePassword=true and is NOT already + * on /perfil/contrasena, redirects them there. + * + * Place this INSIDE ProtectedRoute so it only fires for authenticated users. + * The /perfil/contrasena route itself must NOT be wrapped with this gate + * to avoid redirect loops. + */ +export function MustChangePasswordGate({ children }: MustChangePasswordGateProps) { + const user = useAuthStore((s) => s.user) + const location = useLocation() + + if (user?.mustChangePassword && location.pathname !== '/perfil/contrasena') { + return + } + + return <>{children}> +} diff --git a/src/web/src/features/auth/api/authApi.ts b/src/web/src/features/auth/api/authApi.ts index 0955bfc..db42e53 100644 --- a/src/web/src/features/auth/api/authApi.ts +++ b/src/web/src/features/auth/api/authApi.ts @@ -10,6 +10,7 @@ export interface LoginResponseDto { nombre: string rol: string permisos: string[] + mustChangePassword: boolean // UDT-008 } } diff --git a/src/web/src/features/auth/hooks/useLogin.ts b/src/web/src/features/auth/hooks/useLogin.ts index b4d1f1b..aefee7f 100644 --- a/src/web/src/features/auth/hooks/useLogin.ts +++ b/src/web/src/features/auth/hooks/useLogin.ts @@ -20,6 +20,7 @@ export function useLogin() { nombre: data.usuario.nombre, rol: data.usuario.rol, permisos: data.usuario.permisos ?? [], + mustChangePassword: data.usuario.mustChangePassword ?? false, // UDT-008 }, accessToken: data.accessToken, refreshToken: data.refreshToken, diff --git a/src/web/src/stores/authStore.ts b/src/web/src/stores/authStore.ts index 7e7ebec..1158f04 100644 --- a/src/web/src/stores/authStore.ts +++ b/src/web/src/stores/authStore.ts @@ -7,6 +7,7 @@ export interface AuthUser { nombre: string rol: string permisos: string[] + mustChangePassword: boolean // UDT-008 } interface SetAuthPayload { @@ -22,6 +23,7 @@ interface AuthState { refreshToken: string | null expiresAt: number | null // ms epoch UTC setAuth: (payload: SetAuthPayload) => void + updateUser: (patch: Partial) => void // UDT-008 updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void clearAuth: () => void logout: () => Promise @@ -43,6 +45,11 @@ export const useAuthStore = create()( expiresAt: Date.now() + payload.expiresIn * 1000, }), + updateUser: (patch) => + set((s) => ({ + user: s.user ? { ...s.user, ...patch } : null, + })), + updateAccess: (accessToken, refreshToken, expiresAt) => set({ accessToken, refreshToken, expiresAt }), diff --git a/src/web/src/tests/api/axiosClient.test.ts b/src/web/src/tests/api/axiosClient.test.ts index bfd176a..23ffdf2 100644 --- a/src/web/src/tests/api/axiosClient.test.ts +++ b/src/web/src/tests/api/axiosClient.test.ts @@ -49,7 +49,7 @@ afterEach(() => { function setAuth(accessToken: string, refreshToken: string) { useAuthStore.setState({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [], mustChangePassword: false }, accessToken, refreshToken, expiresAt: Date.now() + 3600 * 1000, diff --git a/src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx b/src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx new file mode 100644 index 0000000..f8ea526 --- /dev/null +++ b/src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { useAuthStore } from '../../../stores/authStore' +import { MustChangePasswordGate } from '../../../components/routing/MustChangePasswordGate' + +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, +} + +beforeEach(() => { + useAuthStore.setState({ user: null, accessToken: null, refreshToken: null, expiresAt: null }) +}) + +function renderGate(initialPath: string, mustChangePassword: boolean | null) { + if (mustChangePassword !== null) { + useAuthStore.setState({ user: { ...adminUser, mustChangePassword } }) + } + + return render( + + + Change Password Page} /> + + Protected Content + + } + /> + + , + ) +} + +describe('MustChangePasswordGate', () => { + it('redirects to /perfil/contrasena when mustChangePassword=true and on different route', () => { + renderGate('/usuarios', true) + + expect(screen.getByText('Change Password Page')).toBeInTheDocument() + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('redirects to /perfil/contrasena when mustChangePassword=true on root', () => { + renderGate('/', true) + + expect(screen.getByText('Change Password Page')).toBeInTheDocument() + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('renders children when mustChangePassword=false', () => { + renderGate('/usuarios', false) + + expect(screen.getByText('Protected Content')).toBeInTheDocument() + expect(screen.queryByText('Change Password Page')).not.toBeInTheDocument() + }) + + it('renders children when user is null (let ProtectedRoute handle auth)', () => { + // user is null — gate should pass through, ProtectedRoute will handle it + renderGate('/usuarios', null) + + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + + it('allows render on /perfil/contrasena when mustChangePassword=true (no redirect loop)', () => { + useAuthStore.setState({ user: { ...adminUser, mustChangePassword: true } }) + + render( + + + + Change Password Page Content + + } + /> + + , + ) + + expect(screen.getByText('Change Password Page Content')).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/auth/CanPerform.test.tsx b/src/web/src/tests/features/auth/CanPerform.test.tsx index d6f6b5e..bc41afb 100644 --- a/src/web/src/tests/features/auth/CanPerform.test.tsx +++ b/src/web/src/tests/features/auth/CanPerform.test.tsx @@ -16,6 +16,7 @@ describe('CanPerform', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -36,6 +37,7 @@ describe('CanPerform', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -68,6 +70,7 @@ describe('CanPerform', () => { nombre: 'Reportes', rol: 'reportes', permisos: [], + mustChangePassword: false, }, }) @@ -89,6 +92,7 @@ describe('CanPerform', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) diff --git a/src/web/src/tests/features/auth/LoginPage.test.tsx b/src/web/src/tests/features/auth/LoginPage.test.tsx index d99c99b..f06ff04 100644 --- a/src/web/src/tests/features/auth/LoginPage.test.tsx +++ b/src/web/src/tests/features/auth/LoginPage.test.tsx @@ -21,7 +21,7 @@ const mockLoginResponse = { accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', refreshToken: 'refresh-token-abc', expiresIn: 3600, - usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] }, + usuario: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false }, } const server = setupServer( diff --git a/src/web/src/tests/features/auth/ProtectedRoute.test.tsx b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx index 8231c5a..59aeb99 100644 --- a/src/web/src/tests/features/auth/ProtectedRoute.test.tsx +++ b/src/web/src/tests/features/auth/ProtectedRoute.test.tsx @@ -71,7 +71,7 @@ describe('ProtectedRoute', () => { it('F-03-02: user autenticado sin restricciones → renderiza children', () => { useAuthStore.setState({ - user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false }, }) render( @@ -101,6 +101,7 @@ describe('ProtectedRoute', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -126,7 +127,7 @@ describe('ProtectedRoute', () => { it('F-03-04: requiredRoles no coincide → redirect a /', () => { useAuthStore.setState({ - user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [], mustChangePassword: false }, }) render( @@ -158,6 +159,7 @@ describe('ProtectedRoute', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -191,6 +193,7 @@ describe('ProtectedRoute', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -223,6 +226,7 @@ describe('ProtectedRoute', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -254,6 +258,7 @@ describe('ProtectedRoute', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) diff --git a/src/web/src/tests/features/auth/useLogin.test.ts b/src/web/src/tests/features/auth/useLogin.test.ts index e8ac80c..540c6a4 100644 --- a/src/web/src/tests/features/auth/useLogin.test.ts +++ b/src/web/src/tests/features/auth/useLogin.test.ts @@ -19,6 +19,7 @@ const mockLoginResponseWithPermisos = { nombre: 'Admin Sistema', rol: 'admin', permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'], + mustChangePassword: false, }, } @@ -32,6 +33,21 @@ const mockLoginResponseEmptyPermisos = { nombre: 'Cajero Test', rol: 'cajero', permisos: [], + mustChangePassword: false, + }, +} + +const mockLoginResponseMustChange = { + accessToken: 'eyJhbGciOiJSUzI1NiJ9.payload.sig', + refreshToken: 'refresh-token-abc', + expiresIn: 3600, + usuario: { + id: 3, + username: 'newuser', + nombre: 'New User', + rol: 'cajero', + permisos: [], + mustChangePassword: true, }, } @@ -94,3 +110,44 @@ describe('useLogin — permisos propagation', () => { expect(state.user?.permisos).not.toBeNull() }) }) + +describe('useLogin — mustChangePassword propagation', () => { + it('F-login-03: persists mustChangePassword=false from login response', async () => { + server.use( + http.post(`${API_URL}/api/v1/auth/login`, () => + HttpResponse.json(mockLoginResponseWithPermisos, { status: 200 }), + ), + ) + + const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() }) + + act(() => { + result.current.mutate({ username: 'admin', password: 'password' }) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + }) + + it('F-login-04: persists mustChangePassword=true from login response', async () => { + server.use( + http.post(`${API_URL}/api/v1/auth/login`, () => + HttpResponse.json(mockLoginResponseMustChange, { status: 200 }), + ), + ) + + const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() }) + + act(() => { + result.current.mutate({ username: 'newuser', password: 'password' }) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(true) + expect(state.user?.username).toBe('newuser') + }) +}) diff --git a/src/web/src/tests/features/auth/usePermission.test.ts b/src/web/src/tests/features/auth/usePermission.test.ts index e5bfdb8..30ebd73 100644 --- a/src/web/src/tests/features/auth/usePermission.test.ts +++ b/src/web/src/tests/features/auth/usePermission.test.ts @@ -16,6 +16,7 @@ describe('usePermission', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], + mustChangePassword: false, }, }) @@ -31,6 +32,7 @@ describe('usePermission', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -46,6 +48,7 @@ describe('usePermission', () => { nombre: 'Reportes', rol: 'reportes', permisos: [], + mustChangePassword: false, }, }) @@ -68,6 +71,7 @@ describe('usePermission', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) @@ -85,6 +89,7 @@ describe('usePermission', () => { nombre: 'Cajero', rol: 'cajero', permisos: ['ventas:contado:crear'], + mustChangePassword: false, }, }) diff --git a/src/web/src/tests/stores/authStore.test.ts b/src/web/src/tests/stores/authStore.test.ts index ba6b45f..942b333 100644 --- a/src/web/src/tests/stores/authStore.test.ts +++ b/src/web/src/tests/stores/authStore.test.ts @@ -1,6 +1,25 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { useAuthStore } from '../../stores/authStore' +// Canonical test user fixtures +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: [] as string[], + mustChangePassword: false, +} + +const cajeroUser = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [] as string[], + mustChangePassword: false, +} + describe('authStore', () => { beforeEach(() => { // Reset store state before each test @@ -28,7 +47,7 @@ describe('authStore', () => { describe('setAuth', () => { it('stores user and accessToken in state', () => { const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -43,7 +62,7 @@ describe('authStore', () => { it('persists auth data to localStorage under auth-storage key', () => { const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'eyJhbGciOiJSUzI1NiJ9.test.signature', refreshToken: 'opaque-refresh-token', expiresIn: 3600, @@ -61,7 +80,7 @@ describe('authStore', () => { it('setAuth_persistsRefreshTokenAndExpiresAt', () => { const before = Date.now() const payload = { - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token-abc', refreshToken: 'opaque-refresh-xyz', expiresIn: 3600, @@ -92,6 +111,7 @@ describe('authStore', () => { nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar', 'administracion:roles:gestionar'], + mustChangePassword: false, }, accessToken: 'access-token', refreshToken: 'refresh-token', @@ -108,7 +128,7 @@ describe('authStore', () => { it('F-04-02: setAuth con permisos vacíos → user.permisos es [] (no null)', () => { const payload = { - user: { id: 2, username: 'cajero', nombre: 'Cajero', rol: 'cajero', permisos: [] }, + user: cajeroUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -120,12 +140,83 @@ describe('authStore', () => { expect(state.user?.permisos).toEqual([]) expect(state.user?.permisos).not.toBeNull() }) + + it('persists mustChangePassword=true in state and localStorage', () => { + const payload = { + user: { ...adminUser, mustChangePassword: true }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(true) + + const stored = localStorage.getItem('auth-storage') + const parsed = JSON.parse(stored!) + expect(parsed.state.user.mustChangePassword).toBe(true) + }) + + it('persists mustChangePassword=false in state', () => { + const payload = { + user: { ...adminUser, mustChangePassword: false }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + } + + useAuthStore.getState().setAuth(payload) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + }) + }) + + describe('updateUser', () => { + it('updateUser_patches_mustChangePassword_preserves_rest', () => { + useAuthStore.getState().setAuth({ + user: { ...adminUser, mustChangePassword: true }, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + }) + + useAuthStore.getState().updateUser({ mustChangePassword: false }) + + const state = useAuthStore.getState() + expect(state.user?.mustChangePassword).toBe(false) + // Other fields preserved + expect(state.user?.username).toBe('admin') + expect(state.user?.rol).toBe('admin') + expect(state.user?.id).toBe(1) + }) + + it('updateUser_noops_when_user_null', () => { + // user is null — should not throw + expect(() => useAuthStore.getState().updateUser({ mustChangePassword: false })).not.toThrow() + expect(useAuthStore.getState().user).toBeNull() + }) + + it('updateUser_can_patch_username', () => { + useAuthStore.getState().setAuth({ + user: adminUser, + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + }) + + useAuthStore.getState().updateUser({ username: 'new-admin' }) + + expect(useAuthStore.getState().user?.username).toBe('new-admin') + }) }) describe('clearAuth', () => { it('F-04-03: clearAuth → user = null (permisos se limpian con el user)', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'] }, + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: ['administracion:usuarios:gestionar'], mustChangePassword: false }, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -139,7 +230,7 @@ describe('authStore', () => { it('clearAuth_removesAllFields', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -157,9 +248,8 @@ describe('authStore', () => { describe('updateAccess', () => { it('updateAccess_updatesOnlyTokens_preservesUser', () => { - const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] } useAuthStore.getState().setAuth({ - user: originalUser, + user: adminUser, accessToken: 'old-access', refreshToken: 'old-refresh', expiresIn: 3600, @@ -173,7 +263,7 @@ describe('authStore', () => { expect(state.refreshToken).toBe('new-refresh') expect(state.expiresAt).toBe(newExpiresAt) // user should be preserved - expect(state.user).toEqual(originalUser) + expect(state.user).toEqual(adminUser) }) }) @@ -181,7 +271,7 @@ describe('authStore', () => { it('logout_callsApi_thenClearsAuth', async () => { // Set up auth state with a token so logout() will try to call the API useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, @@ -201,14 +291,13 @@ describe('authStore', () => { it('logout_apiFails_stillClearsAuth', async () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'access-token', refreshToken: 'refresh-token', expiresIn: 3600, }) // Should NOT throw even if the dynamic import fails - // (We test this by verifying clearAuth is always called) let threw = false try { await useAuthStore.getState().logout() @@ -226,7 +315,7 @@ describe('authStore', () => { describe('legacy logout compatibility (via clearAuth)', () => { it('clearAuth clears user and accessToken from state', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'some-token', refreshToken: 'some-refresh', expiresIn: 3600, @@ -241,7 +330,7 @@ describe('authStore', () => { it('clearAuth removes auth-storage from localStorage', () => { useAuthStore.getState().setAuth({ - user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin', permisos: [] }, + user: adminUser, accessToken: 'some-token', refreshToken: 'some-refresh', expiresIn: 3600,