Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
160
src/pages/auth/ChangePasswordPage.tsx
Normal file
160
src/pages/auth/ChangePasswordPage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../../modules/auth';
|
||||
import { Button } from '../../components/ui';
|
||||
import { useToast } from '../../components/ui/Toast';
|
||||
import { authService } from '../../modules/auth/auth.service';
|
||||
import { PasswordStrengthIndicator } from '../../components/shared/PasswordStrengthIndicator';
|
||||
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Senha atual é obrigatória'),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, 'A senha deve ter no mínimo 8 caracteres')
|
||||
.regex(/[A-Z]/, 'A senha deve conter ao menos uma letra maiúscula')
|
||||
.regex(/[a-z]/, 'A senha deve conter ao menos uma letra minúscula')
|
||||
.regex(/[0-9]/, 'A senha deve conter ao menos um número'),
|
||||
confirmPassword: z.string().min(1, 'Confirmação é obrigatória'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'As senhas não coincidem',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
export function ChangePasswordPage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const isFirstAccess = user?.mustChangePassword === true;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ChangePasswordFormData>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
});
|
||||
|
||||
const newPasswordValue = watch('newPassword', '');
|
||||
|
||||
async function onSubmit(data: ChangePasswordFormData) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await authService.changePassword({
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
updateUser({ mustChangePassword: false });
|
||||
showToast('Senha alterada com sucesso', 'success');
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
if (status === 401) {
|
||||
setApiError('Senha atual incorreta');
|
||||
} else {
|
||||
setApiError('Erro ao alterar senha. Tente novamente.');
|
||||
}
|
||||
} else {
|
||||
setApiError('Erro ao alterar senha. Tente novamente.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
|
||||
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
|
||||
<p className="mt-1 text-small text-text-secondary">
|
||||
{isFirstAccess ? 'Primeiro acesso — defina sua senha' : 'Trocar senha'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-small text-text-secondary">
|
||||
{isFirstAccess
|
||||
? 'Bem-vindo! Para continuar, você precisa definir uma nova senha.'
|
||||
: 'Informe sua senha atual e escolha uma nova senha.'}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="mb-1 block text-small text-text-secondary">
|
||||
{isFirstAccess ? 'Senha temporária' : 'Senha atual'}
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register('currentPassword')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-1 text-small text-red-400" role="alert">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="mb-1 block text-small text-text-secondary">
|
||||
Nova senha
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register('newPassword')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<p className="mt-1 text-small text-red-400" role="alert">
|
||||
{errors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
<PasswordStrengthIndicator password={newPasswordValue} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="mb-1 block text-small text-text-secondary">
|
||||
Confirmar nova senha
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register('confirmPassword')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-small text-red-400" role="alert">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<p className="text-small text-red-400" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" loading={isSubmitting} className="w-full">
|
||||
{isSubmitting ? 'Alterando...' : 'Alterar senha'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/pages/auth/ForgotPasswordPage.tsx
Normal file
110
src/pages/auth/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import axios from 'axios';
|
||||
import { authService } from '../../modules/auth/auth.service';
|
||||
import { Button } from '../../components/ui';
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.string().min(1, 'E-mail é obrigatório').email('E-mail inválido'),
|
||||
});
|
||||
|
||||
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ForgotPasswordFormData>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: ForgotPasswordFormData) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await authService.forgotPassword(data.email);
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
setApiError('Erro ao processar solicitação. Tente novamente.');
|
||||
} else {
|
||||
setApiError('Erro ao processar solicitação. Tente novamente.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
|
||||
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
|
||||
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<div role="alert" className="text-center">
|
||||
<p className="mb-4 text-body text-text-primary">
|
||||
Se o e-mail informado estiver cadastrado, você receberá um link de redefinição em
|
||||
breve.
|
||||
</p>
|
||||
<Link to="/login" className="text-small text-isis-blue hover:underline">
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="mb-2 text-center text-body font-semibold text-text-primary">
|
||||
Recuperar acesso
|
||||
</h1>
|
||||
<p className="mb-6 text-small text-text-secondary">
|
||||
Informe seu e-mail para receber um link de redefinição de senha.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-small text-text-secondary">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
{...register('email')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-small text-red-400" role="alert">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<p className="text-small text-red-400" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" loading={isSubmitting} className="w-full">
|
||||
{isSubmitting ? 'Enviando...' : 'Enviar link'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link to="/login" className="text-small text-isis-blue hover:underline">
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/pages/auth/LoginPage.tsx
Normal file
106
src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../../modules/auth';
|
||||
import { Button } from '../../components/ui';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().min(1, 'E-mail é obrigatório').email('E-mail inválido'),
|
||||
password: z.string().min(1, 'Senha é obrigatória'),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: LoginFormData) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await login(data.email, data.password);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
if (status === 401) {
|
||||
setApiError('E-mail ou senha incorretos');
|
||||
} else if (status === 403) {
|
||||
setApiError('Usuário inativo');
|
||||
} else {
|
||||
setApiError('Erro ao realizar login. Tente novamente.');
|
||||
}
|
||||
} else {
|
||||
setApiError('Erro ao realizar login. Tente novamente.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
|
||||
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
|
||||
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-small text-text-secondary">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
{...register('email')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-small text-red-400">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-small text-text-secondary">
|
||||
Senha
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register('password')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-small text-red-400">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiError && <p className="text-small text-red-400">{apiError}</p>}
|
||||
|
||||
<Button type="submit" loading={isSubmitting} className="w-full">
|
||||
{isSubmitting ? 'Entrando...' : 'Entrar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link to="/esqueci-senha" className="text-small text-isis-blue hover:underline">
|
||||
Esqueceu sua senha?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/pages/auth/ResetPasswordPage.tsx
Normal file
154
src/pages/auth/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import axios from 'axios';
|
||||
import { authService } from '../../modules/auth/auth.service';
|
||||
import { Button } from '../../components/ui';
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, 'A senha deve ter no mínimo 8 caracteres')
|
||||
.regex(/[A-Z]/, 'A senha deve conter ao menos uma letra maiúscula')
|
||||
.regex(/[a-z]/, 'A senha deve conter ao menos uma letra minúscula')
|
||||
.regex(/[0-9]/, 'A senha deve conter ao menos um número'),
|
||||
confirmPassword: z.string().min(1, 'Confirmação é obrigatória'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'As senhas não coincidem',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type ResetPasswordFormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ResetPasswordFormData>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: ResetPasswordFormData) {
|
||||
setApiError(null);
|
||||
setIsExpired(false);
|
||||
|
||||
try {
|
||||
await authService.resetPassword(token!, {
|
||||
newPassword: data.newPassword,
|
||||
confirmPassword: data.confirmPassword,
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const message: string = err.response?.data?.message ?? '';
|
||||
if (message.toLowerCase().includes('expirado')) {
|
||||
setIsExpired(true);
|
||||
setApiError('O link de redefinição expirou.');
|
||||
} else if (message) {
|
||||
setApiError(message);
|
||||
} else {
|
||||
setApiError('Erro ao redefinir senha. Tente novamente.');
|
||||
}
|
||||
} else {
|
||||
setApiError('Erro ao redefinir senha. Tente novamente.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0B1F2A]">
|
||||
<div className="w-full max-w-sm rounded-lg bg-[#0F2A38] p-8">
|
||||
<div className="mb-8 text-center">
|
||||
<img src="/logo/logo-full.png" alt="ISIS" className="mx-auto h-12" />
|
||||
<p className="mt-1 text-small text-text-secondary">Sistema de Gestão</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div role="alert" className="text-center">
|
||||
<p className="mb-4 text-body text-text-primary">
|
||||
Senha redefinida com sucesso! Você já pode fazer login.
|
||||
</p>
|
||||
<Link to="/login" className="text-small text-isis-blue hover:underline">
|
||||
Ir para o login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="mb-6 text-center text-body font-semibold text-text-primary">
|
||||
Redefinir senha
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="mb-1 block text-small text-text-secondary">
|
||||
Nova senha
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register('newPassword')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<p className="mt-1 text-small text-red-400" role="alert">
|
||||
{errors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="mb-1 block text-small text-text-secondary"
|
||||
>
|
||||
Confirmar nova senha
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register('confirmPassword')}
|
||||
className="w-full rounded border border-white/10 bg-[#0B1F2A] px-3 py-2 text-body text-text-primary placeholder:text-text-muted focus:border-isis-blue focus:outline-none"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-small text-red-400" role="alert">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div role="alert">
|
||||
<p className="text-small text-red-400">{apiError}</p>
|
||||
{isExpired && (
|
||||
<Link
|
||||
to="/esqueci-senha"
|
||||
className="mt-1 block text-small text-isis-blue hover:underline"
|
||||
>
|
||||
Solicitar novo link
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" loading={isSubmitting} className="w-full">
|
||||
{isSubmitting ? 'Redefinindo...' : 'Redefinir senha'}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/pages/auth/__tests__/ChangePasswordPage.test.tsx
Normal file
145
src/pages/auth/__tests__/ChangePasswordPage.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ChangePasswordPage } from '../ChangePasswordPage';
|
||||
import { AuthContext, type AuthContextData } from '../../../modules/auth/AuthContext';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
vi.mock('../../../modules/auth/auth.service', () => ({
|
||||
authService: {
|
||||
changePassword: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/ui/Toast', () => ({
|
||||
useToast: () => ({ showToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
const { authService } = await import('../../../modules/auth/auth.service');
|
||||
const mockChangePassword = vi.mocked(authService.changePassword);
|
||||
|
||||
function renderPage(userOverrides: Partial<AuthContextData['user']> = {}) {
|
||||
const updateUser = vi.fn();
|
||||
const user: AuthContextData['user'] = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
email: 'test@test.com',
|
||||
role: 'ADMIN',
|
||||
mustChangePassword: false,
|
||||
...userOverrides,
|
||||
};
|
||||
const authValue: AuthContextData = {
|
||||
user,
|
||||
token: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateUser,
|
||||
};
|
||||
|
||||
return {
|
||||
updateUser,
|
||||
...render(
|
||||
<MemoryRouter>
|
||||
<AuthContext.Provider value={authValue}>
|
||||
<ChangePasswordPage />
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockNavigate.mockReset();
|
||||
});
|
||||
|
||||
describe('ChangePasswordPage', () => {
|
||||
it('shows "Primeiro acesso" context when mustChangePassword=true', () => {
|
||||
renderPage({ mustChangePassword: true });
|
||||
|
||||
expect(screen.getByText('Primeiro acesso — defina sua senha')).toBeInTheDocument();
|
||||
expect(screen.getByText(/bem-vindo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Trocar senha" context when mustChangePassword=false', () => {
|
||||
renderPage({ mustChangePassword: false });
|
||||
|
||||
expect(screen.getByText('Trocar senha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows password complexity error for short password', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('Senha atual'), 'current');
|
||||
await user.type(screen.getByLabelText('Nova senha'), 'short');
|
||||
await user.type(screen.getByLabelText('Confirmar nova senha'), 'short');
|
||||
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('A senha deve ter no mínimo 8 caracteres')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows complexity error for missing uppercase', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('Senha atual'), 'current');
|
||||
await user.type(screen.getByLabelText('Nova senha'), 'nouppercase1');
|
||||
await user.type(screen.getByLabelText('Confirmar nova senha'), 'nouppercase1');
|
||||
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('A senha deve conter ao menos uma letra maiúscula'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders PasswordStrengthIndicator', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByRole('progressbar', { name: 'Força da senha' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates PasswordStrengthIndicator as user types', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const newPasswordInput = screen.getByLabelText('Nova senha');
|
||||
const progressBar = screen.getByRole('progressbar', { name: 'Força da senha' });
|
||||
|
||||
expect(progressBar).toHaveAttribute('aria-valuenow', '0');
|
||||
|
||||
await user.type(newPasswordInput, 'ValidPass1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(progressBar).toHaveAttribute('aria-valuenow', '4');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateUser with mustChangePassword=false on success', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockChangePassword.mockResolvedValue({ message: 'ok' });
|
||||
const { updateUser } = renderPage({ mustChangePassword: true });
|
||||
|
||||
await user.type(screen.getByLabelText('Senha temporária'), 'temp123');
|
||||
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
|
||||
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
|
||||
await user.click(screen.getByRole('button', { name: 'Alterar senha' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUser).toHaveBeenCalledWith({ mustChangePassword: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
89
src/pages/auth/__tests__/ForgotPasswordPage.test.tsx
Normal file
89
src/pages/auth/__tests__/ForgotPasswordPage.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ForgotPasswordPage } from '../ForgotPasswordPage';
|
||||
|
||||
vi.mock('../../../modules/auth/auth.service', () => ({
|
||||
authService: {
|
||||
forgotPassword: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { authService } = await import('../../../modules/auth/auth.service');
|
||||
const mockForgotPassword = vi.mocked(authService.forgotPassword);
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ForgotPasswordPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ForgotPasswordPage', () => {
|
||||
it('renders email input and submit button', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByLabelText('E-mail')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Enviar link' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'invalido');
|
||||
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('E-mail inválido')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockForgotPassword.mockImplementation(() => new Promise(() => {}));
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Enviando...' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides form and shows confirmation message on success', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockForgotPassword.mockResolvedValue({ message: 'link enviado' });
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('E-mail')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/se o e-mail informado estiver cadastrado/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on network failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockForgotPassword.mockRejectedValue(new Error('Network error'));
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'valid@test.com');
|
||||
await user.click(screen.getByRole('button', { name: 'Enviar link' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Erro ao processar solicitação. Tente novamente.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
171
src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { LoginPage } from '../LoginPage';
|
||||
import { AuthContext, type AuthContextData } from '../../../modules/auth/AuthContext';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
function renderLoginPage(authOverrides: Partial<AuthContextData> = {}) {
|
||||
const authValue: AuthContextData = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
...authOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
authValue,
|
||||
...render(
|
||||
<MemoryRouter>
|
||||
<AuthContext.Provider value={authValue}>
|
||||
<LoginPage />
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockReset();
|
||||
});
|
||||
|
||||
describe('LoginPage', () => {
|
||||
it('renders form with email, password and submit button', () => {
|
||||
renderLoginPage();
|
||||
|
||||
expect(screen.getByLabelText('E-mail')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Senha')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Entrar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation errors when submitting empty form', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLoginPage();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Entrar' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('E-mail é obrigatório')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Senha é obrigatória')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email validation error for invalid email', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLoginPage();
|
||||
|
||||
// jsdom doesn't enforce type="email" validation, so RHF + Zod validates
|
||||
await user.type(screen.getByLabelText('E-mail'), 'invalido');
|
||||
await user.type(screen.getByLabelText('Senha'), 'password');
|
||||
await user.click(screen.getByRole('button', { name: 'Entrar' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('E-mail inválido')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loginMock = vi.fn(() => new Promise<void>(() => {})); // never resolves
|
||||
renderLoginPage({ login: loginMock });
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
|
||||
await user.type(screen.getByLabelText('Senha'), 'password');
|
||||
await user.click(screen.getByRole('button', { name: 'Entrar' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Entrando...' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API error message on 401', async () => {
|
||||
const user = userEvent.setup();
|
||||
const axiosError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
};
|
||||
const loginMock = vi.fn().mockRejectedValue(axiosError);
|
||||
|
||||
// Mock axios.isAxiosError to recognize our error
|
||||
const axios = await import('axios');
|
||||
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
|
||||
|
||||
renderLoginPage({ login: loginMock });
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
|
||||
await user.type(screen.getByLabelText('Senha'), 'wrongpassword');
|
||||
await user.click(screen.getByRole('button', { name: 'Entrar' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('E-mail ou senha incorretos')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API error message on 403 (inactive user)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const axiosError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 403 },
|
||||
};
|
||||
const loginMock = vi.fn().mockRejectedValue(axiosError);
|
||||
|
||||
const axios = await import('axios');
|
||||
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
|
||||
|
||||
renderLoginPage({ login: loginMock });
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
|
||||
await user.type(screen.getByLabelText('Senha'), 'password');
|
||||
await user.click(screen.getByRole('button', { name: 'Entrar' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Usuário inativo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to / on successful login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loginMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderLoginPage({ login: loginMock });
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
|
||||
await user.type(screen.getByLabelText('Senha'), 'password');
|
||||
await user.click(screen.getByRole('button', { name: 'Entrar' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginMock).toHaveBeenCalledWith('admin@iasis.com.br', 'password');
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
|
||||
it('stores token in localStorage after successful login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loginMock = vi.fn().mockImplementation(async () => {
|
||||
// Simulate what AuthProvider.login does
|
||||
localStorage.setItem('@iasis:token', 'test-token');
|
||||
});
|
||||
renderLoginPage({ login: loginMock });
|
||||
|
||||
await user.type(screen.getByLabelText('E-mail'), 'admin@iasis.com.br');
|
||||
await user.type(screen.getByLabelText('Senha'), 'password');
|
||||
await user.click(screen.getByRole('button', { name: 'Entrar' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('@iasis:token')).toBe('test-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
103
src/pages/auth/__tests__/ResetPasswordPage.test.tsx
Normal file
103
src/pages/auth/__tests__/ResetPasswordPage.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { ResetPasswordPage } from '../ResetPasswordPage';
|
||||
|
||||
vi.mock('../../../modules/auth/auth.service', () => ({
|
||||
authService: {
|
||||
resetPassword: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { authService } = await import('../../../modules/auth/auth.service');
|
||||
const mockResetPassword = vi.mocked(authService.resetPassword);
|
||||
|
||||
function renderPage(token = 'test-token') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/redefinir-senha/${token}`]}>
|
||||
<Routes>
|
||||
<Route path="/redefinir-senha/:token" element={<ResetPasswordPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
it('renders password fields and submit button', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByLabelText('Nova senha')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Confirmar nova senha')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Redefinir senha' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows complexity error when password is too short', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('Nova senha'), 'short');
|
||||
await user.type(screen.getByLabelText('Confirmar nova senha'), 'short');
|
||||
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('A senha deve ter no mínimo 8 caracteres')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows uppercase error when password has no uppercase letter', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('Nova senha'), 'validpass1');
|
||||
await user.type(screen.getByLabelText('Confirmar nova senha'), 'validpass1');
|
||||
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('A senha deve conter ao menos uma letra maiúscula'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success message on 200 response', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockResetPassword.mockResolvedValue({ message: 'Senha redefinida com sucesso.' });
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
|
||||
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
|
||||
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/senha redefinida com sucesso/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows expired link message and link to /esqueci-senha on 400 with expired message', async () => {
|
||||
const user = userEvent.setup();
|
||||
const axiosError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 400, data: { message: 'Token de redefinição expirado' } },
|
||||
};
|
||||
mockResetPassword.mockRejectedValue(axiosError);
|
||||
|
||||
const axios = await import('axios');
|
||||
vi.spyOn(axios.default, 'isAxiosError').mockReturnValue(true);
|
||||
|
||||
renderPage();
|
||||
|
||||
await user.type(screen.getByLabelText('Nova senha'), 'ValidPass1');
|
||||
await user.type(screen.getByLabelText('Confirmar nova senha'), 'ValidPass1');
|
||||
await user.click(screen.getByRole('button', { name: 'Redefinir senha' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('O link de redefinição expirou.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Solicitar novo link' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user