diff --git a/.changeset/twenty-drinks-talk.md b/.changeset/twenty-drinks-talk.md new file mode 100644 index 0000000000..fb08adcac1 --- /dev/null +++ b/.changeset/twenty-drinks-talk.md @@ -0,0 +1,5 @@ +--- +"nextjs-website": patch +--- + +Fix sign-in and confirm sign-in UI and UX diff --git a/apps/nextjs-website/src/__tests__/helpers/auth.helpers.test.ts b/apps/nextjs-website/src/__tests__/helpers/auth.helpers.test.ts index 578267c373..ae528c4709 100644 --- a/apps/nextjs-website/src/__tests__/helpers/auth.helpers.test.ts +++ b/apps/nextjs-website/src/__tests__/helpers/auth.helpers.test.ts @@ -1,24 +1,31 @@ -import { passwordMatcher } from '../../helpers/auth.helpers'; +import { + validateEmail, + validateField, + validatePassword, +} from '../../helpers/auth.helpers'; -describe('passwordMatch', () => { - it('returns true if the passwords match', () => { - const validPasswords = ['Password1!', 'P4ssw0rd@!']; - const invalidPasswords = [ - 'password', - 'password1', - 'password!', - 'password1!', - 'Password', - 'Password1', - 'Password!', - ]; +describe('validateField', () => { + it('returns error if the value is empty', () => { + expect(validateField('')).toBe('requiredFieldError'); + }); + + it('returns null if the value is not empty', () => { + expect(validateField('test')).toBe(null); + }); +}); - validPasswords.forEach((password) => { - expect(passwordMatcher.test(password)).toBe(true); - }); +describe('validateEmail', () => { + it('returns error if the email is invalid', () => { + expect(validateEmail('')).toBe('requiredFieldError'); + expect(validateEmail('test')).toBe('emailFieldError'); + expect(validateEmail('mail@example.com')).toBe(null); + }); +}); - invalidPasswords.forEach((password) => { - expect(passwordMatcher.test(password)).toBe(false); - }); +describe('validatePassword', () => { + it('returns error if the password is invalid', () => { + expect(validatePassword('')).toBe('requiredFieldError'); + expect(validatePassword('password')).toBe('passwordError'); + expect(validatePassword('Password1!')).toBe(null); }); }); diff --git a/apps/nextjs-website/src/app/auth/login/page.tsx b/apps/nextjs-website/src/app/auth/login/page.tsx index b6aba87673..38b177e777 100644 --- a/apps/nextjs-website/src/app/auth/login/page.tsx +++ b/apps/nextjs-website/src/app/auth/login/page.tsx @@ -1,29 +1,59 @@ 'use client'; import LoginForm from '@/components/organisms/Auth/LoginForm'; import ConfirmLogIn from '@/components/organisms/Auth/ConfirmLogin'; -import { Box, Grid } from '@mui/material'; +import { Grid } from '@mui/material'; import { useCallback, useState } from 'react'; import { Auth } from 'aws-amplify'; import { LoginSteps } from '@/lib/types/loginSteps'; import { LoginFunction } from '@/lib/types/loginFunction'; +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'; const Login = () => { const router = useRouter(); const [logInStep, setLogInStep] = useState(LoginSteps.LOG_IN); const [user, setUser] = useState(null); - const [userName, setUserName] = useState(null); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const [noAccountError, setNoAccountError] = useState(false); const onLogin: LoginFunction = useCallback(async ({ username, password }) => { + 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; }); - setUserName(username); - setUser(user); - setLogInStep(LoginSteps.MFA_CHALLENGE); + setUsername(username); + + if (user) { + setUser(user); + setLogInStep(LoginSteps.MFA_CHALLENGE); + } }, []); + + const resendCode = useCallback(async () => { + const result = await onLogin({ username, password }) + .then(() => true) + .catch(() => false); + + return result; + }, [onLogin, password, username]); + const searchParams = useSearchParams(); const confirmLogin = useCallback( @@ -33,23 +63,21 @@ const Login = () => { const redirect = searchParams.get('redirect'); router.replace(redirect ? redirect : '/'); }, - [router, user] + [router, searchParams, user] ); + const onBackStep = useCallback(() => { + router.replace( + `/auth/login?email=${encodeURIComponent(username || '')}&step=${ + LoginSteps.LOG_IN + }` + ); + setLogInStep(LoginSteps.LOG_IN); + return null; + }, [router, username]); + return ( - + { my={6} spacing={6} > - {logInStep === LoginSteps.LOG_IN && } + {logInStep === LoginSteps.LOG_IN && ( + + )} {logInStep === LoginSteps.MFA_CHALLENGE && ( - + + )} + {logInStep === LoginSteps.CONFIRM_ACCOUNT && ( + )} - + ); }; diff --git a/apps/nextjs-website/src/components/molecules/ResendEmail/ResendEmail.tsx b/apps/nextjs-website/src/components/molecules/ResendEmail/ResendEmail.tsx index 8edf929b4b..01c74dcfbe 100644 --- a/apps/nextjs-website/src/components/molecules/ResendEmail/ResendEmail.tsx +++ b/apps/nextjs-website/src/components/molecules/ResendEmail/ResendEmail.tsx @@ -3,16 +3,25 @@ import { useTranslations } from 'next-intl'; import { LoaderPhase } from '@/lib/types/loader'; import DoneIcon from '@mui/icons-material/Done'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import { useCallback, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; import { Auth } from 'aws-amplify'; import { resetResendEmailAfterMs } from '@/config'; type ResendEmailProps = { text: string; email: string; + isLoginCTA?: boolean; + setSubmitting?: Dispatch>; + resendCode?: () => Promise; }; -const ResendEmail = ({ text, email }: ResendEmailProps) => { +const ResendEmail = ({ + text, + email, + setSubmitting, + isLoginCTA = false, + resendCode, +}: ResendEmailProps) => { const t = useTranslations('auth.resendEmail'); const { palette } = useTheme(); @@ -23,19 +32,25 @@ const ResendEmail = ({ text, email }: ResendEmailProps) => { const handleResendEmail = useCallback(async () => { setLoader(LoaderPhase.LOADING); - const result = await Auth.resendSignUp(email).catch(() => { - setLoader(LoaderPhase.ERROR); - return false; - }); + const result = + isLoginCTA && resendCode + ? await resendCode() + : await Auth.resendSignUp(email).catch(() => { + setLoader(LoaderPhase.ERROR); + return false; + }); if (result) { setLoader(LoaderPhase.SUCCESS); + if (setSubmitting) { + setSubmitting(false); + } } setTimeout(() => { setLoader(undefined); }, resetResendEmailAfterMs); - }, [email]); + }, [email, isLoginCTA, resendCode, setSubmitting]); const buildLoader = () => { switch (loader) { diff --git a/apps/nextjs-website/src/components/organisms/Auth/ConfirmLogin.tsx b/apps/nextjs-website/src/components/organisms/Auth/ConfirmLogin.tsx index 78616f996a..910436c76c 100644 --- a/apps/nextjs-website/src/components/organisms/Auth/ConfirmLogin.tsx +++ b/apps/nextjs-website/src/components/organisms/Auth/ConfirmLogin.tsx @@ -1,42 +1,49 @@ 'use client'; -import { translations } from '@/_contents/translations'; import { Box, - Grid, + Button, Card, - Snackbar, - Alert, + Grid, Stack, - Typography, TextField, - Button, + Typography, } from '@mui/material'; import { IllusEmailValidation } from '@pagopa/mui-italia'; import { useCallback, useState } from 'react'; import ResendEmail from '@/components/molecules/ResendEmail/ResendEmail'; -import { snackbarAutoHideDurationMs } from '@/config'; +import { useTranslations } from 'next-intl'; import { useTheme } from '@mui/material'; interface confirmLoginProps { email: string | null; onConfirmLogin: (code: string) => Promise; + resendCode: () => Promise; } -const ConfirmLogin = ({ email, onConfirmLogin }: confirmLoginProps) => { - const { - auth: { confirmLogin }, - } = translations; +const ConfirmLogin = ({ + email, + onConfirmLogin, + resendCode, +}: confirmLoginProps) => { + const confirmLogin = useTranslations('auth.confirmLogin'); const { palette } = useTheme(); - const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [code, setCode] = useState(''); + const [codeError, setCodeError] = useState(false); + const [emptyCode, setEmptyCode] = useState(false); const onConfirmLoginHandler = useCallback(() => { + setCodeError(false); + setEmptyCode(false); setSubmitting(true); + onConfirmLogin(code).catch((e) => { - setError(e.message); - setSubmitting(false); + if (e.name === 'AuthError') { + setEmptyCode(true); + } else if (e.name === 'NotAuthorizedException') { + setCodeError(true); + } }); }, [onConfirmLogin, code]); @@ -57,53 +64,65 @@ const ConfirmLogin = ({ email, onConfirmLogin }: confirmLoginProps) => { - {confirmLogin.title} + {confirmLogin('title')} {email && ( - {confirmLogin.body(email)} + {confirmLogin('confirmationCodeSent')} + + {email} + +
+ {confirmLogin('confirmationCodeExpires')}
)} - {confirmLogin.code} + {confirmLogin('code')} setCode(e.target.value)} + helperText={ + codeError + ? confirmLogin('invalidCode') + : emptyCode + ? confirmLogin('emptyCode') + : '' + } + error={codeError || emptyCode} sx={{ backgroundColor: palette.background.paper, }} /> - {email && ( - - )} - + + {email && ( + + )} - setError(null)} - > - {error} - ); }; diff --git a/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx b/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx index 9a721c8dec..140a06e7e1 100644 --- a/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx +++ b/apps/nextjs-website/src/components/organisms/Auth/LoginForm.tsx @@ -1,55 +1,60 @@ 'use client'; -import { translations } from '@/_contents/translations'; import { LoginFunction } from '@/lib/types/loginFunction'; import { useAuthenticator } from '@aws-amplify/ui-react'; import { Visibility, VisibilityOff } from '@mui/icons-material'; import { Box, - Typography, - Stack, - Checkbox, - Grid, - TextField, - Divider, Button, Card, - FormControlLabel, + Checkbox, + Divider, FormControl, - InputLabel, - OutlinedInput, - InputAdornment, + FormControlLabel, + FormHelperText, + Grid, IconButton, - Snackbar, - Alert, + InputAdornment, + Stack, + TextField, + Typography, useTheme, } from '@mui/material'; import { IllusLogin } from '@pagopa/mui-italia'; import { useTranslations } from 'next-intl'; import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { MouseEvent, useCallback, useState } from 'react'; -import { snackbarAutoHideDurationMs } from '@/config'; +import { MouseEvent, useCallback, useEffect, useState } from 'react'; +import { validateEmail, validateField } from '@/helpers/auth.helpers'; interface LoginFormProps { onLogin: LoginFunction; + noAccount: boolean; } -const LoginForm = ({ onLogin }: LoginFormProps) => { - const { - auth: { login, signUp }, - shared, - } = translations; - const t = useTranslations('errors'); +interface LoginFieldsError { + email: string | null; + password: string | null; +} + +const LoginForm = ({ onLogin, noAccount = 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 [error, setError] = useState(null); const { authStatus } = useAuthenticator((context) => [context.authStatus]); + const [fieldErrors, setFieldErrors] = useState({ + email: null, + password: null, + }); + if (authStatus === 'authenticated') { redirect('/'); } @@ -66,14 +71,42 @@ const LoginForm = ({ onLogin }: LoginFormProps) => { [] ); + const validateForm = useCallback(() => { + const emailError = validateEmail(username); + + const passwordError = validateField(password); + setFieldErrors({ + email: emailError ? shared(emailError) : null, + password: passwordError ? shared(passwordError) : null, + }); + + return !emailError && !passwordError; + }, [username, shared, password]); + + const setNotloggedOnError = useCallback(() => { + if (noAccount) { + setFieldErrors((prevFieldErrors) => ({ + ...prevFieldErrors, + email: login('noAccountError'), + password: login('noAccountError'), + })); + } + }, [noAccount, login]); + + useEffect(() => { + setNotloggedOnError(); + }, [noAccount, setNotloggedOnError]); + const onLoginHandler = useCallback(() => { + const valid = validateForm(); + if (!valid) { + return; + } setSubmitting(true); - onLogin({ username, password }) - .catch((e) => setError(t(e.code))) - .finally(() => { - setSubmitting(false); - }); - }, [onLogin, username, password, t]); + onLogin({ username, password }).finally(() => { + setSubmitting(false); + }); + }, [validateForm, onLogin, username, password, errors]); return ( { - {login.loginToYourAccount} + {login('loginToYourAccount')} setUsername(e.target.value)} + helperText={fieldErrors.email} + error={!!fieldErrors.email || noAccount} + required sx={{ + width: '100%', backgroundColor: palette.background.paper, }} + autoComplete={'username'} /> - - - {shared.password} - - + setPassword(e.target.value)} - endAdornment={ - - - {showPassword ? : } - - - } - label={shared.password} - inputProps={{ - sx: { - padding: '8.5px 14px', - }, + label={`${shared('password')}`} + variant='outlined' + size='small' + error={!!fieldErrors.password || noAccount} + required + InputProps={{ + endAdornment: ( + + + {showPassword ? : } + + + ), }} + autoComplete={'current-password'} /> + {(fieldErrors.password || noAccount) && ( + + {fieldErrors.password} + + )} } - label={login.rememberMe} + label={login('rememberMe')} /> @@ -167,7 +210,7 @@ const LoginForm = ({ onLogin }: LoginFormProps) => { variant='caption-semibold' color={palette.primary.main} > - {login.forgotPassword} + {login('forgotPassword')} @@ -179,7 +222,7 @@ const LoginForm = ({ onLogin }: LoginFormProps) => { alignItems='center' > - {login.noAccount} + {login('noAccount')} { variant='caption-semibold' color={palette.primary.main} > - {signUp.action} + {signUp('action')} - setError(null)} - > - {error} - ); }; diff --git a/apps/nextjs-website/src/helpers/auth.helpers.ts b/apps/nextjs-website/src/helpers/auth.helpers.ts index 2fa104a6a9..32ca00855e 100644 --- a/apps/nextjs-website/src/helpers/auth.helpers.ts +++ b/apps/nextjs-website/src/helpers/auth.helpers.ts @@ -2,3 +2,24 @@ export const passwordMatcher = /(?=(.*[0-9]))(?=.*[!@#$%^&*()\\[\]{}\-_+=~`|:;"'<>,./?])(?=.*[a-z])(?=(.*[A-Z]))(?=(.*)).{8,}/; export const emailMatcher = /^[\w-\\.\\+]+@([\w-]+\.)+[\w-]{2,4}$/; + +export const validateField = (value: string): string | null => + !value || value.trim().length === 0 ? 'requiredFieldError' : null; + +export const validateEmail = (value: string): string | null => { + const isNotValid = validateField(value); + if (isNotValid) { + return isNotValid; + } + + return !emailMatcher.test(value) ? 'emailFieldError' : null; +}; + +export const validatePassword = (value: string): string | null => { + const isNotValid = validateField(value); + if (isNotValid) { + return isNotValid; + } + + return !passwordMatcher.test(value) ? 'passwordError' : null; +}; diff --git a/apps/nextjs-website/src/lib/types/loginSteps.ts b/apps/nextjs-website/src/lib/types/loginSteps.ts index fae7bbda48..3dc26a389d 100644 --- a/apps/nextjs-website/src/lib/types/loginSteps.ts +++ b/apps/nextjs-website/src/lib/types/loginSteps.ts @@ -1,4 +1,5 @@ export enum LoginSteps { LOG_IN = 'LOG_IN', MFA_CHALLENGE = 'MFA_CHALLENGE', + CONFIRM_ACCOUNT = 'CONFIRM_ACCOUNT', } diff --git a/apps/nextjs-website/src/messages/it.json b/apps/nextjs-website/src/messages/it.json index c993af4734..3d75fdfabd 100644 --- a/apps/nextjs-website/src/messages/it.json +++ b/apps/nextjs-website/src/messages/it.json @@ -141,6 +141,7 @@ "rememberMe": "Ricordami", "forgotPassword": "Hai dimenticato la password?", "noAccount": "Non hai un account?", + "noAccountError": "Nome utente o password non corretti.", "accountAlreadyConfirmed": { "yourAccountIsAlreadyConfirmed": "Hai già validato questo indirizzo email", "goToLogin": "Vai al Login" @@ -161,10 +162,12 @@ "signUp": { "action": "Iscriviti", "createYourAccount": "Crea il tuo account", - "confirmComunications": "Inviami e-mail relative alle risorse e agli aggiornamenti sui prodotti. Se questa casella è selezionata, PagoPA ti invierà di tanto in tanto delle e-mail utili e pertinenti. Puoi annullare l'iscrizione in qualsiasi momento.", - "acceptPolicy": "Cliccando su “Iscriviti” accetti la nostra informativa sul trattamento dei dati personali per la Privacy Policy.", + "passwordMismatchError": "Le password non combaciano", + "confirmComunications": "Inviami email relative alle risorse e agli aggiornamenti sui prodotti. Se questa casella è selezionata, PagoPA ti invierà di tanto in tanto delle email utili e pertinenti. Puoi annullare l'iscrizione in qualsiasi momento.", + "acceptPolicy": "Cliccando su iscriviti dichiari di aver preso visione della nostra informativa privacy e dei nostri termini e condizioni d'uso.", "alreadyHaveAnAccount": "Hai già un account?", "whyCreateAccount": "Perché iscriversi a PagoPA DevPortal", + "passwordError": "Non rispetta i requisiti.", "passwordPolicy": "Minimo 8 caratteri, almeno un numero, almeno una lettera maiuscola e almeno un carattere speciale", "emailSent": "Email inviata a {email}", "advantages": { @@ -207,11 +210,14 @@ }, "confirmLogin": { "title": "Verifica la tua identità", - "body": "Abbiamo inviato un codice di verifica a {email}
Il codice scade tra 3 minuti.", "code": "Codice di verifica", + "confirmationCodeSent": "Abbiamo inviato un codice di verifica a ", + "confirmationCodeExpires": "Il codice scade tra 3 minuti.", "checkJunkMail": "Non hai ricevuto alcuna email? Controlla la posta indesiderata oppure", "continue": "Continua", - "resendEmail": "Reinvia email" + "resendEmail": "Reinvia email", + "invalidCode": "Il codice inserito non è corretto", + "emptyCode": "Il codice è obbligatorio" }, "accountActivated": { "goToLogin": "Vai al login", @@ -222,9 +228,9 @@ "title": "Recupera password", "body": "Inserisci il tuo indirizzo e-mail e ti invieremo le istruzioni per impostare una nuova password.", "goBackToLogin": "Torna al login", - "send": "Invia", + "send": "Conferma", "checkEmailTitle": "Controlla l'email", - "checkEmail": "Se esiste un account associato a {email}, riceverai una e-mail con un link per impostare una nuova password.", + "checkEmail": "Se esiste un account associato a {email}, riceverai una email con un link per impostare una nuova password.", "resendEmailPrompt": "Non hai ricevuto l'email? Controlla nella posta indesiderata oppure", "resendEmail": "Reinvia e-mail", "wrongEmail": "L'indirizzo e-mail è errato?", @@ -256,6 +262,7 @@ "role": "Ruolo", "requiredFieldError": "Questo campo non può essere vuoto", "emailFieldError": "Inserisci un indirizzo email valido", + "emailAlreadyTaken": "Esiste già un account per questo indirizzo email", "goToModel": "Vai al modello", "edit": "Modifica", "cancel": "Annulla",