Explorar o código

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

[web] Show confirmation modal when making an email primary

GitOrigin-RevId: eb67fc37c18a5ecd59973baa27ee9ef8e4b67423
ilkin-overleaf %!s(int64=3) %!d(string=hai) anos
pai
achega
122d2310e6

+ 3 - 0
services/web/frontend/extracted-translations.json

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

+ 1 - 1
services/web/frontend/js/features/settings/components/emails/actions.tsx

@@ -1,6 +1,6 @@
 import { useEffect } from 'react'
 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 useAsync from '../../../../shared/hooks/use-async'
 import { useUserEmailsContext } from '../../context/user-email-context'

+ 0 - 112
services/web/frontend/js/features/settings/components/emails/actions/make-primary.tsx

@@ -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

+ 62 - 0
services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx

@@ -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

+ 116 - 0
services/web/frontend/js/features/settings/components/emails/actions/make-primary/make-primary.tsx

@@ -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

+ 17 - 0
services/web/frontend/js/features/settings/components/emails/actions/make-primary/primary-button.tsx

@@ -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

+ 3 - 0
services/web/locales/en.json

@@ -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_email": "Confirm Email",
   "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_token_invalid": "Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.",
   "confirming": "Confirming",
@@ -342,6 +343,7 @@
   "dismiss_error_popup": "Dismiss first error alert",
   "do_not_have_acct_or_do_not_want_to_link": "If you don’t have an <b>__appName__</b> account, or if you don’t want to link to your <b>__institutionName__</b> account, please click <b>__clickText__</b>.",
   "do_not_link_accounts": "Don’t 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?",
   "document_history": "Document history",
   "documentation": "Documentation",
@@ -854,6 +856,7 @@
   "log_in_with": "Log in with __provider__",
   "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_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_from": "Log out from __email__",
   "log_viewer_error": "There was a problem displaying this project’s compilation errors and logs.",

+ 60 - 30
services/web/test/frontend/features/settings/components/emails/emails-section-actions.test.tsx

@@ -2,6 +2,7 @@ import {
   render,
   screen,
   waitForElementToBeRemoved,
+  within,
   fireEvent,
 } from '@testing-library/react'
 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 () {
-    fetchMock
-      .get('/user/emails?ensureAffiliation=true', [userEmailData])
-      .post('/user/emails/default', 200)
-    const userEmailDataCopy = { ...userEmailData }
-    render(<EmailsSection />)
+  describe('make an email primary', function () {
+    const confirmPrimaryEmail = async () => {
+      const button = await screen.findByRole('button', {
+        name: /make primary/i,
+      })
+      fireEvent.click(button)
 
-    const button = await screen.findByRole('button', { name: /make primary/i })
-    fireEvent.click(button)
+      const withinModal = within(screen.getByRole('dialog'))
+      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(() =>
-      screen.getByRole('button', { name: /sending/i })
-    )
+      const button = await screen.findByRole('button', {
+        name: /make primary/i,
+      })
+      fireEvent.click(button)
 
-    expect(
-      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
-  })
+      const withinModal = within(screen.getByRole('dialog'))
+      withinModal.getByText(
+        /do you want to change your primary email address to .*/i
+      )
+      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 () {
-    fetchMock
-      .get('/user/emails?ensureAffiliation=true', [userEmailData])
-      .post('/user/emails/default', 503)
-    render(<EmailsSection />)
+      fireEvent.click(withinModal.getByRole('button', { name: /cancel/i }))
+      expect(screen.queryByRole('dialog')).to.be.null
+    })
 
-    const button = await screen.findByRole('button', { name: /make primary/i })
-    fireEvent.click(button)
+    it('shows loader and removes button', async function () {
+      fetchMock
+        .get('/user/emails?ensureAffiliation=true', [userEmailData])
+        .post('/user/emails/default', 200)
+      render(<EmailsSection />)
 
-    await waitForElementToBeRemoved(() =>
-      screen.getByRole('button', { name: /sending/i })
-    )
+      await confirmPrimaryEmail()
+
+      expect(
+        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
+    })
 
-    screen.getByText(/sorry, something went wrong/i)
-    screen.getByRole('button', { name: /make primary/i })
+    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 })
+    })
   })
 })