Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DEV-1333] Fix sign-in and confirm sign-in UI and UX #601

Merged
merged 17 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twenty-drinks-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nextjs-website": patch
---

Fix sign-in and confirm sign-in UI and UX
45 changes: 26 additions & 19 deletions apps/nextjs-website/src/__tests__/helpers/auth.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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('[email protected]')).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);
});
});
81 changes: 59 additions & 22 deletions apps/nextjs-website/src/app/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');

const [noAccountError, setNoAccountError] = useState<boolean>(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(
Expand All @@ -33,36 +63,43 @@ 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 (
<Box
sx={{
display: 'flex',
minHeight: '100vh',
alignItems: 'center',
justifyContent: 'center',
width: '100vw',
backgroundImage: 'url(/images/hero.jpg)',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
backgroundPosition: 'bottom right',
}}
>
<PageBackgroundWrapper>
<Grid
container
justifyContent='center'
sx={{ mx: 'auto' }}
my={6}
spacing={6}
>
{logInStep === LoginSteps.LOG_IN && <LoginForm onLogin={onLogin} />}
{logInStep === LoginSteps.LOG_IN && (
<LoginForm noAccount={noAccountError} onLogin={onLogin} />
)}
{logInStep === LoginSteps.MFA_CHALLENGE && (
<ConfirmLogIn email={userName} onConfirmLogin={confirmLogin} />
<ConfirmLogIn
email={username}
onConfirmLogin={confirmLogin}
resendCode={resendCode}
/>
)}
{logInStep === LoginSteps.CONFIRM_ACCOUNT && (
<ConfirmSignUp email={username || ''} onBack={onBackStep} />
)}
</Grid>
</Box>
</PageBackgroundWrapper>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetStateAction<boolean>>;
resendCode?: () => Promise<boolean>;
};

const ResendEmail = ({ text, email }: ResendEmailProps) => {
const ResendEmail = ({
text,
email,
setSubmitting,
isLoginCTA = false,
resendCode,
}: ResendEmailProps) => {
const t = useTranslations('auth.resendEmail');
const { palette } = useTheme();

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
resendCode: () => Promise<boolean>;
}

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<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [code, setCode] = useState<string>('');
const [codeError, setCodeError] = useState<boolean>(false);
const [emptyCode, setEmptyCode] = useState<boolean>(false);

const onConfirmLoginHandler = useCallback(() => {
setCodeError(false);
setEmptyCode(false);
setSubmitting(true);

onConfirmLogin(code).catch((e) => {
setError(e.message);
setSubmitting(false);
if (e.message === 'Challenge response cannot be empty') {
marcobottaro marked this conversation as resolved.
Show resolved Hide resolved
setEmptyCode(true);
} else if (e.name === 'NotAuthorizedException') {
setCodeError(true);
}
});
}, [onConfirmLogin, code]);

Expand All @@ -57,53 +64,65 @@ const ConfirmLogin = ({ email, onConfirmLogin }: confirmLoginProps) => {
<IllusEmailValidation />
</Stack>
<Typography variant='h4' pt={8} mb={5} textAlign='center'>
{confirmLogin.title}
{confirmLogin('title')}
</Typography>
{email && (
<Typography variant='body2' mb={6}>
{confirmLogin.body(email)}
{confirmLogin('confirmationCodeSent')}
<Box component='span' fontWeight='fontWeightMedium'>
{email}
</Box>
<br />
{confirmLogin('confirmationCodeExpires')}
</Typography>
)}
<Typography
variant='body1'
sx={{ marginBottom: 1.5, fontWeight: 600 }}
>
{confirmLogin.code}
{confirmLogin('code')}
</Typography>
<Stack spacing={2} mb={4}>
<TextField
variant='outlined'
size='small'
onChange={(e) => setCode(e.target.value)}
helperText={
codeError
? confirmLogin('invalidCode')
: emptyCode
? confirmLogin('emptyCode')
marcobottaro marked this conversation as resolved.
Show resolved Hide resolved
: ''
}
error={codeError || emptyCode}
sx={{
backgroundColor: palette.background.paper,
}}
/>
</Stack>
{email && (
<ResendEmail email={email} text={confirmLogin.checkJunkMail} />
)}
<Stack spacing={4} pt={4} pb={2}>
<Stack spacing={4} pt={4} pb={4}>
<Stack direction='row' justifyContent='center'>
<Button
variant='contained'
disabled={submitting}
onClick={onConfirmLoginHandler}
>
{confirmLogin.continue}
{confirmLogin('continue')}
</Button>
</Stack>
</Stack>
{email && (
<ResendEmail
isLoginCTA={true}
resendCode={resendCode}
setSubmitting={setSubmitting}
email={email}
text={confirmLogin('checkJunkMail')}
/>
)}
</Grid>
</Grid>
</Card>
<Snackbar
open={!!error}
autoHideDuration={snackbarAutoHideDurationMs}
onClose={() => setError(null)}
>
<Alert severity='error'>{error}</Alert>
</Snackbar>
</Box>
);
};
Expand Down
Loading
Loading