Merge pull request #12278 from overleaf/ii-change-email-confirmation

[web] Show confirmation modal when making an email primary

GitOrigin-RevId: eb67fc37c18a5ecd59973baa27ee9ef8e4b67423
This commit is contained in:
ilkin-overleaf 2023-03-20 13:45:44 +02:00 committed by Copybot
parent 3a071c247a
commit 122d2310e6
8 changed files with 262 additions and 143 deletions

View file

@ -134,6 +134,7 @@
"confirm_affiliation": "", "confirm_affiliation": "",
"confirm_affiliation_to_relink_dropbox": "", "confirm_affiliation_to_relink_dropbox": "",
"confirm_new_password": "", "confirm_new_password": "",
"confirm_primary_email_change": "",
"conflicting_paths_found": "", "conflicting_paths_found": "",
"connected_users": "", "connected_users": "",
"contact_message_label": "", "contact_message_label": "",
@ -183,6 +184,7 @@
"discount_of": "", "discount_of": "",
"dismiss": "", "dismiss": "",
"dismiss_error_popup": "", "dismiss_error_popup": "",
"do_you_want_to_change_your_primary_email_address_to": "",
"do_you_want_to_overwrite_them": "", "do_you_want_to_overwrite_them": "",
"documentation": "", "documentation": "",
"doesnt_match": "", "doesnt_match": "",
@ -451,6 +453,7 @@
"log_entry_maximum_entries_see_full_logs": "", "log_entry_maximum_entries_see_full_logs": "",
"log_entry_maximum_entries_title": "", "log_entry_maximum_entries_title": "",
"log_hint_extra_info": "", "log_hint_extra_info": "",
"log_in_with_primary_email_address": "",
"log_viewer_error": "", "log_viewer_error": "",
"login_with_service": "", "login_with_service": "",
"logs_and_output_files": "", "logs_and_output_files": "",

View file

@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MakePrimary from './actions/make-primary' import MakePrimary from './actions/make-primary/make-primary'
import Remove from './actions/remove' import Remove from './actions/remove'
import useAsync from '../../../../shared/hooks/use-async' import useAsync from '../../../../shared/hooks/use-async'
import { useUserEmailsContext } from '../../context/user-email-context' import { useUserEmailsContext } from '../../context/user-email-context'

View file

@ -1,112 +0,0 @@
import Tooltip from '../../../../../shared/components/tooltip'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import {
inReconfirmNotificationPeriod,
institutionAlreadyLinked,
} from '../../../utils/selectors'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import {
State,
useUserEmailsContext,
} from '../../../context/user-email-context'
import { UserEmailData } from '../../../../../../../types/user-email'
import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
import { ssoAvailableForInstitution } from '../../../utils/sso'
const getDescription = (
t: (s: string) => string,
state: State,
userEmailData: UserEmailData
) => {
if (inReconfirmNotificationPeriod(userEmailData)) {
return t('please_reconfirm_your_affiliation_before_making_this_primary')
}
if (userEmailData.confirmedAt) {
return t('make_email_primary_description')
}
const ssoAvailable = ssoAvailableForInstitution(
userEmailData.affiliation?.institution || null
)
if (!institutionAlreadyLinked(state, userEmailData) && ssoAvailable) {
return t('please_link_before_making_primary')
}
return t('please_confirm_your_email_before_making_it_default')
}
function PrimaryButton({ children, disabled, onClick }: Button.ButtonProps) {
return (
<Button
bsSize="small"
bsStyle={null}
className="btn-secondary-info btn-secondary"
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
)
}
type MakePrimaryProps = {
userEmailData: UserEmailData
makePrimaryAsync: UseAsyncReturnType
}
function MakePrimary({ userEmailData, makePrimaryAsync }: MakePrimaryProps) {
const { t } = useTranslation()
const { state, makePrimary } = useUserEmailsContext()
const handleSetDefaultUserEmail = () => {
makePrimaryAsync
.runAsync(
postJSON('/user/emails/default', {
body: {
email: userEmailData.email,
},
})
)
.then(() => {
makePrimary(userEmailData.email)
})
.catch(() => {})
}
if (userEmailData.default) {
return null
}
if (makePrimaryAsync.isLoading) {
return <PrimaryButton disabled>{t('sending')}...</PrimaryButton>
}
return (
<Tooltip
id={`make-primary-${userEmailData.email}`}
description={getDescription(t, state, userEmailData)}
>
{/*
Disabled buttons don't work with tooltips, due to pointer-events: none,
so create a wrapper for the tooltip
*/}
<span>
<PrimaryButton
disabled={
!userEmailData.confirmedAt ||
state.isLoading ||
inReconfirmNotificationPeriod(userEmailData)
}
onClick={handleSetDefaultUserEmail}
>
{t('make_primary')}
</PrimaryButton>
</span>
</Tooltip>
)
}
export default MakePrimary

View file

@ -0,0 +1,62 @@
import { useTranslation, Trans } from 'react-i18next'
import { Modal, Button } from 'react-bootstrap'
import AccessibleModal from '../../../../../../shared/components/accessible-modal'
import { MergeAndOverride } from '../../../../../../../../types/utils'
type ConfirmationModalProps = MergeAndOverride<
React.ComponentProps<typeof AccessibleModal>,
{
email: string
isConfirmDisabled: boolean
onConfirm: () => void
onHide: () => void
}
>
function ConfirmationModal({
email,
isConfirmDisabled,
show,
onConfirm,
onHide,
}: ConfirmationModalProps) {
const { t } = useTranslation()
return (
<AccessibleModal show={show} onHide={onHide}>
<Modal.Header closeButton>
<Modal.Title>{t('confirm_primary_email_change')}</Modal.Title>
</Modal.Header>
<Modal.Body className="modal-body-share">
<p>
<Trans
i18nKey="do_you_want_to_change_your_primary_email_address_to"
components={{ b: <b /> }}
values={{ email }}
/>
</p>
<p className="mb-0">{t('log_in_with_primary_email_address')}</p>
</Modal.Body>
<Modal.Footer>
<Button
bsStyle={null}
className="btn-secondary-info btn-secondary"
onClick={onHide}
>
{t('cancel')}
</Button>
<Button
type="button"
bsStyle={null}
className="btn-primary"
disabled={isConfirmDisabled}
onClick={onConfirm}
>
{t('confirm')}
</Button>
</Modal.Footer>
</AccessibleModal>
)
}
export default ConfirmationModal

View file

@ -0,0 +1,116 @@
import { useState } from 'react'
import Tooltip from '../../../../../../shared/components/tooltip'
import PrimaryButton from './primary-button'
import { useTranslation } from 'react-i18next'
import {
inReconfirmNotificationPeriod,
institutionAlreadyLinked,
} from '../../../../utils/selectors'
import { postJSON } from '../../../../../../infrastructure/fetch-json'
import {
State,
useUserEmailsContext,
} from '../../../../context/user-email-context'
import { UserEmailData } from '../../../../../../../../types/user-email'
import { UseAsyncReturnType } from '../../../../../../shared/hooks/use-async'
import { ssoAvailableForInstitution } from '../../../../utils/sso'
import ConfirmationModal from './confirmation-modal'
const getDescription = (
t: (s: string) => string,
state: State,
userEmailData: UserEmailData
) => {
if (inReconfirmNotificationPeriod(userEmailData)) {
return t('please_reconfirm_your_affiliation_before_making_this_primary')
}
if (userEmailData.confirmedAt) {
return t('make_email_primary_description')
}
const ssoAvailable = ssoAvailableForInstitution(
userEmailData.affiliation?.institution || null
)
if (!institutionAlreadyLinked(state, userEmailData) && ssoAvailable) {
return t('please_link_before_making_primary')
}
return t('please_confirm_your_email_before_making_it_default')
}
type MakePrimaryProps = {
userEmailData: UserEmailData
makePrimaryAsync: UseAsyncReturnType
}
function MakePrimary({ userEmailData, makePrimaryAsync }: MakePrimaryProps) {
const [show, setShow] = useState(false)
const { t } = useTranslation()
const { state, makePrimary } = useUserEmailsContext()
const handleShowModal = () => setShow(true)
const handleHideModal = () => setShow(false)
const handleSetDefaultUserEmail = () => {
handleHideModal()
makePrimaryAsync
.runAsync(
postJSON('/user/emails/default', {
body: {
email: userEmailData.email,
},
})
)
.then(() => {
makePrimary(userEmailData.email)
})
.catch(() => {})
}
if (userEmailData.default) {
return null
}
const isConfirmDisabled = Boolean(
!userEmailData.confirmedAt ||
state.isLoading ||
inReconfirmNotificationPeriod(userEmailData)
)
return (
<>
{makePrimaryAsync.isLoading ? (
<PrimaryButton disabled>{t('sending')}...</PrimaryButton>
) : (
<Tooltip
id={`make-primary-${userEmailData.email}`}
description={getDescription(t, state, userEmailData)}
>
{/*
Disabled buttons don't work with tooltips, due to pointer-events: none,
so create a wrapper for the tooltip
*/}
<span>
<PrimaryButton
disabled={isConfirmDisabled}
onClick={handleShowModal}
>
{t('make_primary')}
</PrimaryButton>
</span>
</Tooltip>
)}
<ConfirmationModal
email={userEmailData.email}
isConfirmDisabled={isConfirmDisabled}
show={show}
onHide={handleHideModal}
onConfirm={handleSetDefaultUserEmail}
/>
</>
)
}
export default MakePrimary

View file

@ -0,0 +1,17 @@
import { Button } from 'react-bootstrap'
function PrimaryButton({ children, disabled, onClick }: Button.ButtonProps) {
return (
<Button
bsSize="small"
bsStyle={null}
className="btn-secondary-info btn-secondary"
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
)
}
export default PrimaryButton

View file

@ -255,6 +255,7 @@
"confirm_affiliation_to_relink_dropbox": "Please confirm you are still at the institution and on their license, or upgrade your account in order to relink your Dropbox account.", "confirm_affiliation_to_relink_dropbox": "Please confirm you are still at the institution and on their license, or upgrade your account in order to relink your Dropbox account.",
"confirm_email": "Confirm Email", "confirm_email": "Confirm Email",
"confirm_new_password": "Confirm New Password", "confirm_new_password": "Confirm New Password",
"confirm_primary_email_change": "Confirm primary email change",
"confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.", "confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.",
"confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.", "confirmation_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
"confirming": "Confirming", "confirming": "Confirming",
@ -342,6 +343,7 @@
"dismiss_error_popup": "Dismiss first error alert", "dismiss_error_popup": "Dismiss first error alert",
"do_not_have_acct_or_do_not_want_to_link": "If you dont have an <b>__appName__</b> account, or if you dont want to link to your <b>__institutionName__</b> account, please click <b>__clickText__</b>.", "do_not_have_acct_or_do_not_want_to_link": "If you dont have an <b>__appName__</b> account, or if you dont want to link to your <b>__institutionName__</b> account, please click <b>__clickText__</b>.",
"do_not_link_accounts": "Dont link accounts", "do_not_link_accounts": "Dont link accounts",
"do_you_want_to_change_your_primary_email_address_to": "Do you want to change your primary email address to <b>__email__</b>?",
"do_you_want_to_overwrite_them": "Do you want to overwrite them?", "do_you_want_to_overwrite_them": "Do you want to overwrite them?",
"document_history": "Document history", "document_history": "Document history",
"documentation": "Documentation", "documentation": "Documentation",
@ -854,6 +856,7 @@
"log_in_with": "Log in with __provider__", "log_in_with": "Log in with __provider__",
"log_in_with_email": "Log in with __email__", "log_in_with_email": "Log in with __email__",
"log_in_with_existing_institution_email": "Please log in with your existing <b>__appName__</b> account in order to get your <b>__appName__</b> and <b>__institutionName__</b> institutional accounts linked.", "log_in_with_existing_institution_email": "Please log in with your existing <b>__appName__</b> account in order to get your <b>__appName__</b> and <b>__institutionName__</b> institutional accounts linked.",
"log_in_with_primary_email_address": "This will be the email address to use if you log in with an email address and password. Important __appName__ notifications will be sent to this email address.",
"log_out": "Log Out", "log_out": "Log Out",
"log_out_from": "Log out from __email__", "log_out_from": "Log out from __email__",
"log_viewer_error": "There was a problem displaying this projects compilation errors and logs.", "log_viewer_error": "There was a problem displaying this projects compilation errors and logs.",

View file

@ -2,6 +2,7 @@ import {
render, render,
screen, screen,
waitForElementToBeRemoved, waitForElementToBeRemoved,
within,
fireEvent, fireEvent,
} from '@testing-library/react' } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
@ -160,45 +161,74 @@ describe('email actions - make primary', function () {
}) })
}) })
it('shows loader when making email primary and removes button', async function () { describe('make an email primary', function () {
fetchMock const confirmPrimaryEmail = async () => {
.get('/user/emails?ensureAffiliation=true', [userEmailData]) const button = await screen.findByRole('button', {
.post('/user/emails/default', 200) name: /make primary/i,
const userEmailDataCopy = { ...userEmailData } })
render(<EmailsSection />) fireEvent.click(button)
const button = await screen.findByRole('button', { name: /make primary/i }) const withinModal = within(screen.getByRole('dialog'))
fireEvent.click(button) fireEvent.click(withinModal.getByRole('button', { name: /confirm/i }))
expect(screen.queryByRole('dialog')).to.be.null
expect(screen.queryByRole('button', { name: /make primary/i })).to.be.null await waitForElementToBeRemoved(() =>
screen.getByRole('button', { name: /sending/i, hidden: true })
)
}
userEmailDataCopy.default = true it('shows confirmation modal and closes it', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData])
render(<EmailsSection />)
await waitForElementToBeRemoved(() => const button = await screen.findByRole('button', {
screen.getByRole('button', { name: /sending/i }) name: /make primary/i,
) })
fireEvent.click(button)
expect( const withinModal = within(screen.getByRole('dialog'))
screen.queryByText(/an error has occurred while performing your request/i) withinModal.getByText(
).to.be.null /do you want to change your primary email address to .*/i
expect(screen.queryByRole('button', { name: /make primary/i })).to.be.null )
}) withinModal.getByText(
/this will be the email address to use if you log in with an email address and password/i
)
withinModal.getByText(
/important .* notifications will be sent to this email address/i
)
withinModal.getByRole('button', { name: /confirm/i })
it('shows error when making email primary', async function () { fireEvent.click(withinModal.getByRole('button', { name: /cancel/i }))
fetchMock expect(screen.queryByRole('dialog')).to.be.null
.get('/user/emails?ensureAffiliation=true', [userEmailData]) })
.post('/user/emails/default', 503)
render(<EmailsSection />)
const button = await screen.findByRole('button', { name: /make primary/i }) it('shows loader and removes button', async function () {
fireEvent.click(button) fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails/default', 200)
render(<EmailsSection />)
await waitForElementToBeRemoved(() => await confirmPrimaryEmail()
screen.getByRole('button', { name: /sending/i })
)
screen.getByText(/sorry, something went wrong/i) expect(
screen.getByRole('button', { name: /make primary/i }) screen.queryByText(
/an error has occurred while performing your request/i
)
).to.be.null
expect(screen.queryByRole('button', { name: /make primary/i })).to.be.null
})
it('shows error', async function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails/default', 503)
render(<EmailsSection />)
await confirmPrimaryEmail()
screen.getByText(/sorry, something went wrong/i)
await screen.findByRole('button', { name: /make primary/i })
})
}) })
}) })