diff --git a/.changeset/sour-penguins-smile.md b/.changeset/sour-penguins-smile.md new file mode 100644 index 000000000..696d6663e --- /dev/null +++ b/.changeset/sour-penguins-smile.md @@ -0,0 +1,5 @@ +--- +"nextjs-website": patch +--- + +Reset login form errors on user changes diff --git a/apps/nextjs-website/src/app/auth/login/page.tsx b/apps/nextjs-website/src/app/auth/login/page.tsx index 38b177e77..e8f85692d 100644 --- a/apps/nextjs-website/src/app/auth/login/page.tsx +++ b/apps/nextjs-website/src/app/auth/login/page.tsx @@ -10,35 +10,34 @@ import ConfirmSignUp from '@/components/organisms/Auth/ConfirmSignUp'; import { useRouter, useSearchParams } from 'next/navigation'; import PageBackgroundWrapper from '@/components/atoms/PageBackgroundWrapper/PageBackgroundWrapper'; import { SignInOpts } from '@aws-amplify/auth/lib/types'; +import AuthStatus from '@/components/organisms/Auth/AuthStatus'; const Login = () => { const router = useRouter(); const [logInStep, setLogInStep] = useState(LoginSteps.LOG_IN); const [user, setUser] = useState(null); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - + const [loginData, setLoginData] = useState({ username: '', password: '' }); + const [submitting, setSubmitting] = useState(false); const [noAccountError, setNoAccountError] = useState(false); const onLogin: LoginFunction = useCallback(async ({ username, password }) => { + setSubmitting(true); setNoAccountError(false); - setUsername(username); - setPassword(password); - - const user = await Auth.signIn({ - username, - password, - } as SignInOpts).catch((error) => { - if (error.code === 'UserNotConfirmedException') { - setUsername(username); - setLogInStep(LoginSteps.CONFIRM_ACCOUNT); - } else { - setNoAccountError(true); - } - return false; - }); + setLoginData({ username, password }); - setUsername(username); + const opts: SignInOpts = { username, password }; + const user = await Auth.signIn(opts) + .catch((error) => { + if (error.code === 'UserNotConfirmedException') { + setLogInStep(LoginSteps.CONFIRM_ACCOUNT); + } else { + setNoAccountError(true); + } + return false; + }) + .finally(() => { + setSubmitting(false); + }); if (user) { setUser(user); @@ -47,12 +46,12 @@ const Login = () => { }, []); const resendCode = useCallback(async () => { - const result = await onLogin({ username, password }) + const result = await onLogin(loginData) .then(() => true) .catch(() => false); return result; - }, [onLogin, password, username]); + }, [onLogin, loginData]); const searchParams = useSearchParams(); @@ -66,15 +65,9 @@ const Login = () => { [router, searchParams, user] ); - const onBackStep = useCallback(() => { - router.replace( - `/auth/login?email=${encodeURIComponent(username || '')}&step=${ - LoginSteps.LOG_IN - }` - ); + const onBackStep = () => { setLogInStep(LoginSteps.LOG_IN); - return null; - }, [router, username]); + }; return ( @@ -86,17 +79,23 @@ const Login = () => { spacing={6} > {logInStep === LoginSteps.LOG_IN && ( - + + + )} {logInStep === LoginSteps.MFA_CHALLENGE && ( )} {logInStep === LoginSteps.CONFIRM_ACCOUNT && ( - + )} diff --git a/apps/nextjs-website/src/components/organisms/Auth/AuthStatus.tsx b/apps/nextjs-website/src/components/organisms/Auth/AuthStatus.tsx new file mode 100644 index 000000000..f9349d718 --- /dev/null +++ b/apps/nextjs-website/src/components/organisms/Auth/AuthStatus.tsx @@ -0,0 +1,14 @@ +'use client'; +import { useAuthenticator } from '@aws-amplify/ui-react'; +import { redirect } from 'next/navigation'; +import { PropsWithChildren } from 'react'; + +export default function AuthStatus({ children }: PropsWithChildren) { + const { authStatus } = useAuthenticator((context) => [context.authStatus]); + + if (authStatus === 'authenticated') { + redirect('/'); + } + + return children; +} diff --git a/apps/nextjs-website/src/components/organisms/Auth/ConfirmSignUp.tsx b/apps/nextjs-website/src/components/organisms/Auth/ConfirmSignUp.tsx index 73b71bc89..e1b4fa6b9 100644 --- a/apps/nextjs-website/src/components/organisms/Auth/ConfirmSignUp.tsx +++ b/apps/nextjs-website/src/components/organisms/Auth/ConfirmSignUp.tsx @@ -14,7 +14,8 @@ import { useTranslations } from 'next-intl'; interface ConfirmSignUpProps { email: string; - onBack: () => null; + // eslint-disable-next-line functional/no-return-void + onBack: () => void; } const ConfirmSignUp = ({ email, onBack }: ConfirmSignUpProps) => { diff --git a/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx b/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx index 140a06e7e..71ff4d138 100644 --- a/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx +++ b/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx @@ -1,6 +1,4 @@ -'use client'; import { LoginFunction } from '@/lib/types/loginFunction'; -import { useAuthenticator } from '@aws-amplify/ui-react'; import { Visibility, VisibilityOff } from '@mui/icons-material'; import { Box, @@ -22,13 +20,19 @@ import { import { IllusLogin } from '@pagopa/mui-italia'; import { useTranslations } from 'next-intl'; import Link from 'next/link'; -import { redirect } from 'next/navigation'; -import { MouseEvent, useCallback, useEffect, useState } from 'react'; +import { + ChangeEvent, + MouseEvent, + useCallback, + useEffect, + useState, +} from 'react'; import { validateEmail, validateField } from '@/helpers/auth.helpers'; interface LoginFormProps { onLogin: LoginFunction; - noAccount: boolean; + noAccount?: boolean; + submitting?: boolean; } interface LoginFieldsError { @@ -36,29 +40,27 @@ interface LoginFieldsError { password: string | null; } -const LoginForm = ({ onLogin, noAccount = false }: LoginFormProps) => { +const LoginForm = ({ + onLogin, + noAccount = false, + submitting = false, +}: LoginFormProps) => { const signUp = useTranslations('auth.signUp'); const login = useTranslations('auth.login'); const shared = useTranslations('shared'); - const errors = useTranslations('errors'); const { palette } = useTheme(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [submitting, setSubmitting] = useState(false); - - const { authStatus } = useAuthenticator((context) => [context.authStatus]); + const [formData, setFormData] = useState({ + username: '', + password: '', + }); + const [showPassword, setShowPassword] = useState(false); const [fieldErrors, setFieldErrors] = useState({ email: null, password: null, }); - if (authStatus === 'authenticated') { - redirect('/'); - } - const handleClickShowPassword = useCallback( () => setShowPassword((show) => !show), [] @@ -71,17 +73,31 @@ const LoginForm = ({ onLogin, noAccount = false }: LoginFormProps) => { [] ); + const handleChangeInput = useCallback( + (e: ChangeEvent) => { + if (fieldErrors.email || fieldErrors.password) { + setFieldErrors({ + email: null, + password: null, + }); + } + + setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value })); + }, + [fieldErrors] + ); + const validateForm = useCallback(() => { - const emailError = validateEmail(username); + const emailError = validateEmail(formData.username); + const passwordError = validateField(formData.password); - const passwordError = validateField(password); setFieldErrors({ email: emailError ? shared(emailError) : null, password: passwordError ? shared(passwordError) : null, }); return !emailError && !passwordError; - }, [username, shared, password]); + }, [shared, formData]); const setNotloggedOnError = useCallback(() => { if (noAccount) { @@ -99,14 +115,11 @@ const LoginForm = ({ onLogin, noAccount = false }: LoginFormProps) => { const onLoginHandler = useCallback(() => { const valid = validateForm(); - if (!valid) { - return; - } - setSubmitting(true); - onLogin({ username, password }).finally(() => { - setSubmitting(false); - }); - }, [validateForm, onLogin, username, password, errors]); + + if (!valid) return; + + onLogin(formData); + }, [validateForm, onLogin, formData]); return ( { setUsername(e.target.value)} + autoComplete={'username'} + error={!!fieldErrors.email} helperText={fieldErrors.email} - error={!!fieldErrors.email || noAccount} + inputProps={{ + 'aria-label': 'email', + name: 'username', + }} + label={shared('emailAddress')} required + size='small' sx={{ width: '100%', backgroundColor: palette.background.paper, }} - autoComplete={'username'} + value={formData.username} + variant='outlined' + onChange={handleChangeInput} /> setPassword(e.target.value)} - label={`${shared('password')}`} - variant='outlined' - size='small' - error={!!fieldErrors.password || noAccount} - required + inputProps={{ + 'aria-label': 'password', + name: 'password', + }} InputProps={{ endAdornment: ( @@ -170,9 +186,14 @@ const LoginForm = ({ onLogin, noAccount = false }: LoginFormProps) => { ), }} - autoComplete={'current-password'} + label={`${shared('password')}`} + required + size='small' + type={showPassword ? 'text' : 'password'} + variant='outlined' + onChange={handleChangeInput} /> - {(fieldErrors.password || noAccount) && ( + {fieldErrors.password && ( {fieldErrors.password} diff --git a/apps/nextjs-website/src/components/organisms/Auth/__tests__/LoginForm.test.tsx b/apps/nextjs-website/src/components/organisms/Auth/__tests__/LoginForm.test.tsx new file mode 100644 index 000000000..f0518eb27 --- /dev/null +++ b/apps/nextjs-website/src/components/organisms/Auth/__tests__/LoginForm.test.tsx @@ -0,0 +1,142 @@ +import LoginForm from '@/components/organisms/Auth/LoginForm'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; + +import Wrapper from '../../../../__tests__/components/Wrapper'; +import labels from '@/messages/it.json'; + +const errorsRegex = RegExp(labels.shared.requiredFieldError, 'i'); +const emailErrorRegex = RegExp(labels.shared.emailFieldError, 'i'); +const fieldsErrorsRegex = RegExp(labels.auth.login.noAccountError, 'i'); +const actionRegex = RegExp(labels.auth.login.action, 'i'); + +describe('LoginForm', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const mockOnLogin = jest.fn(async () => {}); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render without crashing', () => { + render( + + + + ); + }); + + it('should handle username and password input', () => { + const { getByLabelText } = render( + + + + ); + + const usernameInput = getByLabelText(/^email$/i) as HTMLInputElement; + const passwordInput = getByLabelText(/^password$/i) as HTMLInputElement; + + fireEvent.change(usernameInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + + expect(usernameInput.value).toBe('test@example.com'); + expect(passwordInput.value).toBe('password123'); + }); + + it('should call onLogin when form is submitted', async () => { + const { getByLabelText, getByRole } = render( + + + + ); + + const usernameInput = getByLabelText(/^email$/i) as HTMLInputElement; + const passwordInput = getByLabelText(/^password$/i) as HTMLInputElement; + const submitButton = getByRole('button', { name: actionRegex }); + + fireEvent.change(usernameInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(submitButton); + + expect(mockOnLogin).toHaveBeenCalledWith({ + username: 'test@example.com', + password: 'password123', + }); + }); + + it('should display error messages when inputs are invalid', async () => { + const { + getByLabelText, + findByText, + getByRole, + findAllByText, + queryByText, + queryAllByText, + } = render( + + + + ); + + const usernameInput = getByLabelText(/^email$/i) as HTMLInputElement; + const passwordInput = getByLabelText(/^password$/i) as HTMLInputElement; + const submitButton = getByRole('button', { name: actionRegex }); + + fireEvent.change(usernameInput, { target: { value: '' } }); + fireEvent.change(passwordInput, { target: { value: '' } }); + fireEvent.click(submitButton); + + const errors = await findAllByText(errorsRegex); + + expect(usernameInput.attributes.getNamedItem('aria-invalid')?.value).toBe( + 'true' + ); + expect(passwordInput.attributes.getNamedItem('aria-invalid')?.value).toBe( + 'true' + ); + expect(errors).toHaveLength(2); + + fireEvent.change(usernameInput, { target: { value: 'invalid email' } }); + fireEvent.click(submitButton); + + const emailError = await findByText(emailErrorRegex); + const passwordError = await findByText(errorsRegex); + + expect(emailError).toBeDefined(); + expect(passwordError).toBeDefined(); + expect(usernameInput.attributes.getNamedItem('aria-invalid')?.value).toBe( + 'true' + ); + expect(passwordInput.attributes.getNamedItem('aria-invalid')?.value).toBe( + 'true' + ); + + fireEvent.change(usernameInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(submitButton); + + const emailError1 = await queryByText(emailErrorRegex); + const errorsRegex1 = await queryAllByText(errorsRegex); + + expect(emailError1).toBeNull(); + expect(errorsRegex1).toHaveLength(0); + expect(usernameInput.attributes.getNamedItem('aria-invalid')?.value).toBe( + 'false' + ); + expect(passwordInput.attributes.getNamedItem('aria-invalid')?.value).toBe( + 'false' + ); + }); + + it('should display error messages when account not exists', async () => { + const { findAllByText } = render( + + + + ); + + const errors = await findAllByText(fieldsErrorsRegex); + + expect(errors).toHaveLength(2); + }); +});