Skip to content

Commit

Permalink
[DEV-1272] update attribute email (#532)
Browse files Browse the repository at this point in the history
* feat: update password from user profile

* add translations & improve errors

* changes after review

* changes after review

* add error state to password text field label

* update message

* Refactor personal-data page to handle both change password and change email

* Add UX to edit email user attribute

* Remove duplicated import

* Fix distance from divider

* Fix it.json indentation

* Add changeset file

* Handle errors and add expiredCodePage

* Add error case and move signOut after router push

* Use PageBackgroundWrapper component instead of Box

* Rename state constant

* Remove ExpiredCodeCard component

* Move components to their own folder

* Add 'use client'

---------

Co-authored-by: jeremygordillo <[email protected]>
  • Loading branch information
marcobottaro and jeremygordillo authored Jan 24, 2024
1 parent d855202 commit eadf4e3
Show file tree
Hide file tree
Showing 10 changed files with 389 additions and 103 deletions.
5 changes: 5 additions & 0 deletions .changeset/thirty-sheep-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nextjs-website": minor
---

Add form to update email attribute of the current user
18 changes: 3 additions & 15 deletions apps/nextjs-website/src/app/auth/account-activated/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
import { Box } from '@mui/material';
import AccountActivatedCard from '@/components/organisms/Auth/AccountActivatedCard';
import PageBackgroundWrapper from '@/components/atoms/PageBackgroundWrapper/PageBackgroundWrapper';

const AccountActivated = () => {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100vw',
minHeight: '616px',
backgroundImage: 'url(/images/hero.jpg)',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
backgroundPosition: 'bottom right',
}}
>
<PageBackgroundWrapper>
<AccountActivatedCard />
</Box>
</PageBackgroundWrapper>
);
};

Expand Down
1 change: 1 addition & 0 deletions apps/nextjs-website/src/app/auth/confirmation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const Confirmation = () => {
})
.catch((error) => {
// TODO: remove console warn and handle errors: [CodeMismatchException, ExpiredCodeException, InternalErrorException, LimitExceededException]
// see apps/nextjs-website/src/app/auth/email-confirmation/page.tsx
!isProduction && console.warn(error);
setState(State.resendCode);
});
Expand Down
58 changes: 58 additions & 0 deletions apps/nextjs-website/src/app/auth/email-confirmation/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client';
import PageNotFound from '@/app/not-found';
import { Auth } from 'aws-amplify';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import Spinner from '@/components/atoms/Spinner/Spinner';
import ExpiredCode from '@/app/auth/expired-code/page';

enum State {
loading = 'loading',
expiredCode = 'expiredCode',
error = 'error',
}

const EmailConfirmation = () => {
const searchParams = useSearchParams();
const router = useRouter();
const code = searchParams.get('code');

const [state, setState] = useState<State>(State.loading);

useEffect(() => {
if (code) {
Auth.verifyCurrentUserAttributeSubmit('email', code)
.then(() => {
// eslint-disable-next-line functional/immutable-data
router.push('/auth/account-activated');
Auth.signOut();
})
.catch((error) => {
switch (error.code) {
case 'ExpiredCodeException':
setState(State.expiredCode);
break;
case 'CodeMismatchException':
case 'InternalErrorException':
case 'LimitExceededException':
default:
setState(State.error);
break;
}
});
} else {
setState(State.error);
}
}, [code, router]);

switch (state) {
case State.error:
return <PageNotFound />;
case State.expiredCode:
return <ExpiredCode />;
default:
return <Spinner />;
}
};

export default EmailConfirmation;
31 changes: 31 additions & 0 deletions apps/nextjs-website/src/app/auth/expired-code/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';
import { Button } from '@mui/material';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import PageBackgroundWrapper from '@/components/atoms/PageBackgroundWrapper/PageBackgroundWrapper';
import SingleCard from '@/components/atoms/SingleCard/SingleCard';
import { IllusError } from '@pagopa/mui-italia';

const ExpiredCode = () => {
const t = useTranslations('auth');

return (
<PageBackgroundWrapper>
<SingleCard
icon={<IllusError />}
title={t('expiredCode.expiredLink')}
cta={
<Button
variant='contained'
component={Link}
href='/profile/personal-data'
>
{t('expiredCode.goToProfile')}
</Button>
}
/>
</PageBackgroundWrapper>
);
};

export default ExpiredCode;
64 changes: 56 additions & 8 deletions apps/nextjs-website/src/app/profile/personal-data/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ import { Box, Divider, Stack, Typography } from '@mui/material';
import { Auth } from 'aws-amplify';
import { useTranslations } from 'next-intl';
import React, { useEffect, useState } from 'react';

import { translations } from '@/_contents/translations';
import { InfoCardItemProfileProps } from '@/components/atoms/InfoCardItem/InfoCardItemProfile';
import {
InfoCardItem,
InfoCardItemProps,
} from '@/components/atoms/InfoCardItem/InfoCardItem';
import { InfoCardItemProps } from '@/components/atoms/InfoCardItem/InfoCardItem';
import DeleteSection from '@/components/molecules/DeleteSection/DeleteSection';
import { ProfileInfoCard } from '@/components/organisms/Auth/ProfileInfoCard';
import { useUser } from '@/helpers/user.helper';
import { useRouter } from 'next/navigation';
import ConfirmationModal from '@/components/atoms/ConfirmationModal/ConfirmationModal';
import PasswordFormWrapper from '@/components/organisms/Auth/PasswordFormWrapper';
import { InfoCardProfile } from '@/components/molecules/InfoCard/InfoCardProfile';
import EmailFormWrapper from '@/components/organisms/EmailFormWrapper/EmailFormWrapper';

const PersonalData = () => {
const {
Expand All @@ -22,7 +23,7 @@ const PersonalData = () => {
},
} = translations;
const t = useTranslations('profile');

const router = useRouter();
const { user, setUserAttributes } = useUser();

const [profileDataSectionItems, setProfileDataSectionItems] = useState<
Expand Down Expand Up @@ -64,20 +65,29 @@ const PersonalData = () => {
}, [user?.attributes]);

const [editItem, setEditItem] = useState<InfoCardItemProps | null>(null);
const [showConfirmationModal, setShowConfirmationModal] = useState<
'password' | 'email' | null
>(null);

async function handleChangePassword(
oldPassword: string,
newPassword: string
) {
await Auth.changePassword(user, oldPassword, newPassword);
setShowConfirmationModal('password');
}

async function handleChangeEmail(newEmail: string) {
await Auth.updateUserAttributes(user, { email: newEmail });
setShowConfirmationModal('email');
}

const accountSectionItems: InfoCardItemProps[] = [
{
name: 'email',
title: t('personalData.fields.email'),
value: user?.attributes.email,
editable: false,
editable: true,
},
{
name: 'password',
Expand Down Expand Up @@ -110,7 +120,13 @@ const PersonalData = () => {
/>
)}
{isEmailItem && (
<InfoCardItem {...item} onEdit={() => setEditItem(item)} />
<EmailFormWrapper
item={item}
isEditing={isEditing}
onCancel={() => setEditItem(null)}
onSave={handleChangeEmail}
onEdit={() => setEditItem(item)}
/>
)}
{showDivider && <Divider />}
</Box>
Expand All @@ -119,6 +135,39 @@ const PersonalData = () => {

return (
<>
{showConfirmationModal === 'password' && (
<ConfirmationModal
setOpen={() => null}
open={true}
title={t('changePassword.dialog.title')}
text={t('changePassword.dialog.text')}
confirmCta={{
label: t('changePassword.dialog.confirmLabel'),
onClick: () => {
Auth.signOut().then(() => {
router.replace('/auth/login');
});
return null;
},
}}
/>
)}
{showConfirmationModal === 'email' && (
<ConfirmationModal
setOpen={() => null}
open={true}
title={t('changeEmail.dialog.title')}
text={t('changeEmail.dialog.text')}
confirmCta={{
label: t('changeEmail.dialog.confirmLabel'),
onClick: () => {
setEditItem(null);
setShowConfirmationModal(null);
return null;
},
}}
/>
)}
<Stack
gap={5}
sx={{ padding: { xs: '40px 24px', md: '80px 40px' }, width: '100%' }}
Expand Down Expand Up @@ -155,7 +204,6 @@ const PersonalData = () => {
return null;
}}
/>

<ProfileInfoCard
cardTitle={t('personalData.accountSection')}
items={accountSectionItems}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { ButtonNaked } from '@pagopa/mui-italia';
import { Stack, Typography } from '@mui/material';
import { useCallback, useState } from 'react';

import { useTranslations } from 'next-intl';
import { emailMatcher } from '@/helpers/auth.helpers';
import RequiredTextField, {
ValidatorFunction,
} from '@/components/molecules/RequiredTextField/RequiredTextField';

type EditEmailFormProps = {
onSave: (email: string) => Promise<void>;
// eslint-disable-next-line functional/no-return-void
onCancel: () => void;
};

type FormSchema = {
email: string;
};

const EditEmailForm = ({ onSave, onCancel }: EditEmailFormProps) => {
const t = useTranslations('profile');

const [errors, setErrors] = useState<Partial<FormSchema>>({});
const [formValue, setFormValue] = useState<FormSchema>({
email: '',
});

const emailValidators: ValidatorFunction[] = [
(value: string) => ({
valid: emailMatcher.test(value),
error: t('changeEmail.wrongEmail'),
}),
];

const validateForm = useCallback(() => {
const { email } = formValue;
// eslint-disable-next-line functional/no-let
let err = {};

if (!emailMatcher.test(email)) {
err = { ...err, email: t('changeEmail.wrongEmail') };
}

setErrors(err);
const hasErrors = Object.keys(err).length > 0;
return !hasErrors;
}, [formValue, t]);

const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { value },
} = event;

setFormValue(() => ({ email: value }));
};

const handleSave = () => {
if (!validateForm()) return;
onSave(formValue.email).catch((error) => {
if (error.code === 'NotAuthorizedException') {
setErrors({ email: t('changeEmail.notAuthorizedException') });
} else {
console.error(error);
}
});
};

const actions = (
<>
<ButtonNaked
color='primary'
sx={{ paddingLeft: 0, paddingRight: 0 }}
onClick={onCancel}
>
{t('changeEmail.cancel')}
</ButtonNaked>
<ButtonNaked variant='contained' color='primary' onClick={handleSave}>
{t('changeEmail.save')}
</ButtonNaked>
</>
);

return (
<Stack gap={3}>
<Stack
alignItems={{ xs: 'flex-start', md: 'center' }}
flexDirection={{ xs: 'column', md: 'row' }}
gap={2}
mt={{ xs: 1, md: 3 }}
>
<Typography
variant='body2'
flexGrow={1}
fontSize={16}
minWidth={{ xs: 'auto', md: '170px' }}
>
{t('changeEmail.title')}
</Typography>
<Stack flexDirection='row' gap={4} display={{ xs: 'none', md: 'flex' }}>
{actions}
</Stack>
</Stack>
<RequiredTextField
label={t('personalData.fields.email')}
value={formValue.email}
onChange={handleEmailChange}
helperText={t('changeEmail.wrongEmail')}
customValidators={emailValidators}
sx={{ marginBottom: { xs: 0, md: 3 } }}
/>
<Stack
flexDirection='row'
gap={4}
display={{ xs: 'flex', md: 'none' }}
sx={{ marginBottom: { xs: 3, md: 0 } }}
>
{actions}
</Stack>
</Stack>
);
};

export default EditEmailForm;
Loading

0 comments on commit eadf4e3

Please sign in to comment.