Преглед изворни кода

Merge pull request #8178 from overleaf/ta-settings-reconfirmation

[SettingsPage] Add Reconfirmation Prompt

GitOrigin-RevId: 2e03ba7f459b32faf6d66f21ba692ca54f62a320
Timothée Alby пре 4 година
родитељ
комит
666c2c3b47

+ 9 - 1
services/web/frontend/extracted-translations.json

@@ -10,6 +10,7 @@
   "also": "",
   "anyone_with_link_can_edit": "",
   "anyone_with_link_can_view": "",
+  "are_you_still_at": "",
   "ask_proj_owner_to_upgrade_for_git_bridge": "",
   "ask_proj_owner_to_upgrade_for_longer_compiles": "",
   "ask_proj_owner_to_upgrade_for_references_search": "",
@@ -62,6 +63,7 @@
   "compile_mode": "",
   "compile_terminated_by_user": "",
   "compiling": "",
+  "confirm_affiliation": "",
   "confirm_new_password": "",
   "conflicting_paths_found": "",
   "connected_users": "",
@@ -100,14 +102,15 @@
   "dropbox_sync": "",
   "dropbox_sync_both": "",
   "dropbox_sync_description": "",
+  "dropbox_sync_error": "",
   "dropbox_sync_in": "",
   "dropbox_sync_out": "",
   "dropbox_synced": "",
   "duplicate_file": "",
   "easily_manage_your_project_files_everywhere": "",
+  "edit_dictionary": "",
   "edit_dictionary_empty": "",
   "edit_dictionary_remove": "",
-  "edit_dictionary": "",
   "editing": "",
   "editor_and_pdf": "",
   "editor_only_hide_pdf": "",
@@ -273,6 +276,7 @@
   "n_items_plural": "",
   "navigate_log_source": "",
   "navigation": "",
+  "need_to_add_new_primary_before_remove": "",
   "need_to_leave": "",
   "need_to_upgrade_for_more_collabs": "",
   "need_to_upgrade_for_more_collabs_variant": "",
@@ -320,9 +324,11 @@
   "pdf_viewer_error": "",
   "please_change_primary_to_remove": "",
   "please_check_your_inbox": "",
+  "please_check_your_inbox_to_confirm": "",
   "please_compile_pdf_before_download": "",
   "please_confirm_your_email_before_making_it_default": "",
   "please_link_before_making_primary": "",
+  "please_reconfirm_institutional_email": "",
   "please_reconfirm_your_affiliation_before_making_this_primary": "",
   "please_refresh": "",
   "please_select_a_file": "",
@@ -442,6 +448,7 @@
   "take_short_survey": "",
   "template_approved_by_publisher": "",
   "terminated": "",
+  "thank_you_exclamation": "",
   "thanks_settings_updated": "",
   "this_grants_access_to_features_2": "",
   "this_project_is_public": "",
@@ -498,6 +505,7 @@
   "word_count": "",
   "work_offline": "",
   "work_with_non_overleaf_users": "",
+  "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_message": "",
   "zotero_groups_loading_error": "",

+ 77 - 0
services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx

@@ -0,0 +1,77 @@
+import { ReactNode } from 'react'
+import { UserEmailData } from '../../../../../../types/user-email'
+import { Row, Col } from 'react-bootstrap'
+import classNames from 'classnames'
+import getMeta from '../../../../utils/meta'
+import ReconfirmationInfoSuccess from './reconfirmation-info/reconfirmation-info-success'
+import ReconfirmationInfoPrompt from './reconfirmation-info/reconfirmation-info-prompt'
+
+type ReconfirmationInfoProps = {
+  userEmailData: UserEmailData
+}
+
+function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
+  const reconfirmationRemoveEmail = getMeta(
+    'ol-reconfirmationRemoveEmail'
+  ) as string
+  const reconfirmedViaSAML = getMeta('ol-reconfirmedViaSAML') as string
+
+  if (!userEmailData.affiliation) {
+    return null
+  }
+
+  if (
+    userEmailData.samlProviderId &&
+    userEmailData.samlProviderId === reconfirmedViaSAML
+  ) {
+    return (
+      <ReconfirmationInfoContentWrapper asAlertInfo>
+        <ReconfirmationInfoSuccess
+          institution={userEmailData.affiliation.institution}
+        />
+      </ReconfirmationInfoContentWrapper>
+    )
+  }
+
+  if (userEmailData.affiliation.inReconfirmNotificationPeriod) {
+    return (
+      <ReconfirmationInfoContentWrapper
+        asAlertInfo={reconfirmationRemoveEmail === userEmailData.email}
+      >
+        <ReconfirmationInfoPrompt
+          institution={userEmailData.affiliation.institution}
+          primary={userEmailData.default}
+          email={userEmailData.email}
+        />
+      </ReconfirmationInfoContentWrapper>
+    )
+  }
+
+  return null
+}
+
+type ReconfirmationInfoContentWrapperProps = {
+  asAlertInfo: boolean
+  children: ReactNode
+}
+
+function ReconfirmationInfoContentWrapper({
+  asAlertInfo,
+  children,
+}: ReconfirmationInfoContentWrapperProps) {
+  return (
+    <Row>
+      <Col md={12}>
+        <div
+          className={classNames('settings-reconfirm-info', 'small', {
+            'alert alert-info': asAlertInfo,
+          })}
+        >
+          {children}
+        </div>
+      </Col>
+    </Row>
+  )
+}
+
+export default ReconfirmationInfo

+ 165 - 0
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx

@@ -0,0 +1,165 @@
+import { useState, useEffect, useLayoutEffect } from 'react'
+import useAsync from '../../../../../shared/hooks/use-async'
+import { postJSON } from '../../../../../infrastructure/fetch-json'
+import { Trans, useTranslation } from 'react-i18next'
+import { Institution } from '../../../../../../../types/institution'
+import { Button } from 'react-bootstrap'
+import { useUserEmailsContext } from '../../../context/user-email-context'
+import getMeta from '../../../../../utils/meta'
+import { ExposedSettings } from '../../../../../../../types/exposed-settings'
+import { ssoAvailableForInstitution } from '../../../utils/sso'
+import Icon from '../../../../../shared/components/icon'
+
+type ReconfirmationInfoPromptProps = {
+  email: string
+  primary: boolean
+  institution: Institution
+}
+
+function ReconfirmationInfoPrompt({
+  email,
+  primary,
+  institution,
+}: ReconfirmationInfoPromptProps) {
+  const { t } = useTranslation()
+  const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
+  const { isLoading, isError, isSuccess, runAsync } = useAsync()
+  const { state, setLoading: setUserEmailsContextLoading } =
+    useUserEmailsContext()
+  const [isPending, setIsPending] = useState(false)
+  const [hasSent, setHasSent] = useState(false)
+  const ssoAvailable = Boolean(ssoAvailableForInstitution(institution))
+
+  useEffect(() => {
+    setUserEmailsContextLoading(isLoading)
+  }, [setUserEmailsContextLoading, isLoading])
+
+  useLayoutEffect(() => {
+    if (isSuccess) {
+      setHasSent(true)
+    }
+  }, [isSuccess])
+
+  const handleRequestReconfirmation = () => {
+    if (ssoAvailable) {
+      setIsPending(true)
+      window.location.assign(
+        `${samlInitPath}?university_id=${institution.id}&reconfirm=/user/settings`
+      )
+    } else {
+      runAsync(
+        postJSON('/user/emails/resend_confirmation', {
+          body: {
+            email,
+          },
+        })
+      ).catch(console.error)
+    }
+  }
+
+  if (hasSent) {
+    return (
+      <div>
+        <Trans
+          i18nKey="please_check_your_inbox_to_confirm"
+          values={{
+            institutionName: institution.name,
+          }}
+          components={
+            /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+            [<strong />]
+          }
+        />{' '}
+        {isLoading ? (
+          <>
+            <Icon type="refresh" spin fw /> {t('sending')}...
+          </>
+        ) : (
+          <Button
+            className="btn-inline-link"
+            disabled={state.isLoading}
+            onClick={handleRequestReconfirmation}
+          >
+            {t('resend_confirmation_email')}
+          </Button>
+        )}
+        <br />
+        {isError && (
+          <div className="text-danger">{t('generic_something_went_wrong')}</div>
+        )}
+      </div>
+    )
+  }
+
+  return (
+    <>
+      <div>
+        <ReconfirmationInfoPromptText
+          institutionName={institution.name}
+          primary={primary}
+        />
+      </div>
+      <div className="setting-reconfirm-info-right">
+        <Button
+          bsStyle="info"
+          disabled={state.isLoading || isPending}
+          onClick={handleRequestReconfirmation}
+        >
+          {isLoading ? (
+            <>
+              <Icon type="refresh" spin fw /> {t('sending')}...
+            </>
+          ) : (
+            t('confirm_affiliation')
+          )}
+        </Button>
+        <br />
+        {isError && (
+          <div className="text-danger">{t('generic_something_went_wrong')}</div>
+        )}
+      </div>
+    </>
+  )
+}
+
+type ReconfirmationInfoPromptTextProps = {
+  primary: boolean
+  institutionName: Institution['name']
+}
+
+function ReconfirmationInfoPromptText({
+  primary,
+  institutionName,
+}: ReconfirmationInfoPromptTextProps) {
+  const { t } = useTranslation()
+
+  return (
+    <div>
+      <Icon type="warning" className="me-1 icon-warning" />
+      <Trans
+        i18nKey="are_you_still_at"
+        values={{
+          institutionName,
+        }}
+        components={
+          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+          [<strong />]
+        }
+      />{' '}
+      <Trans
+        i18nKey="please_reconfirm_institutional_email"
+        components={
+          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+          [<span />]
+        }
+      />{' '}
+      <a href="/learn/how-to/Institutional_Email_Reconfirmation">
+        {t('learn_more')}
+      </a>
+      <br />
+      {primary ? <i>{t('need_to_add_new_primary_before_remove')}</i> : null}
+    </div>
+  )
+}
+
+export default ReconfirmationInfoPrompt

+ 29 - 0
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx

@@ -0,0 +1,29 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { Institution } from '../../../../../../../types/institution'
+
+type ReconfirmationInfoSuccessProps = {
+  institution: Institution
+}
+
+function ReconfirmationInfoSuccess({
+  institution,
+}: ReconfirmationInfoSuccessProps) {
+  const { t } = useTranslation()
+  return (
+    <div>
+      <Trans
+        i18nKey="your_affiliation_is_confirmed"
+        values={{
+          institutionName: institution.name,
+        }}
+        components={
+          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+          [<strong />]
+        }
+      />{' '}
+      {t('thank_you_exclamation')}
+    </div>
+  )
+}
+
+export default ReconfirmationInfoSuccess

+ 2 - 0
services/web/frontend/js/features/settings/components/emails/row.tsx

@@ -11,6 +11,7 @@ import { useUserEmailsContext } from '../../context/user-email-context'
 import getMeta from '../../../../utils/meta'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
 import { ssoAvailableForInstitution } from '../../utils/sso'
+import ReconfirmationInfo from './reconfirmation-info'
 
 type EmailsRowProps = {
   userEmailData: UserEmailData
@@ -47,6 +48,7 @@ function EmailsRow({ userEmailData }: EmailsRowProps) {
       {hasSSOAffiliation && (
         <SSOAffiliationInfo userEmailData={userEmailData} />
       )}
+      <ReconfirmationInfo userEmailData={userEmailData} />
     </>
   )
 }

+ 9 - 0
services/web/frontend/stories/settings/emails.stories.js

@@ -2,7 +2,9 @@ import EmailsSection from '../../js/features/settings/components/emails-section'
 import useFetchMock from './../hooks/use-fetch-mock'
 import {
   setDefaultMeta,
+  setReconfirmationMeta,
   defaultSetupMocks,
+  reconfirmationSetupMocks,
   errorsMocks,
 } from './helpers/emails'
 
@@ -13,6 +15,13 @@ export const EmailsList = args => {
   return <EmailsSection {...args} />
 }
 
+export const ReconfirmationEmailsList = args => {
+  useFetchMock(reconfirmationSetupMocks)
+  setReconfirmationMeta()
+
+  return <EmailsSection {...args} />
+}
+
 export const NetworkErrors = args => {
   useFetchMock(errorsMocks)
   setDefaultMeta()

+ 85 - 0
services/web/frontend/stories/settings/helpers/emails.js

@@ -39,6 +39,71 @@ const fakeUsersData = [
     default: false,
   },
 ]
+const fakeReconfirmationUsersData = [
+  {
+    affiliation: {
+      institution: {
+        confirmed: true,
+        isUniversity: true,
+        id: 4,
+        name: 'Reconfirmable Email Highlighted',
+      },
+      licence: 'pro_plus',
+      inReconfirmNotificationPeriod: true,
+    },
+    email: 'reconfirmation-highlighted@overleaf.com',
+    confirmedAt: '2022-03-09T10:59:44.139Z',
+    default: false,
+  },
+  {
+    affiliation: {
+      institution: {
+        confirmed: true,
+        isUniversity: true,
+        id: 4,
+        name: 'Reconfirmable Emails Primary',
+      },
+      licence: 'pro_plus',
+      inReconfirmNotificationPeriod: true,
+    },
+    email: 'reconfirmation-nonsso@overleaf.com',
+    confirmedAt: '2022-03-09T10:59:44.139Z',
+    default: true,
+  },
+  {
+    affiliation: {
+      institution: {
+        confirmed: true,
+        ssoEnabled: true,
+        isUniversity: true,
+        id: 3,
+        name: 'Reconfirmable SSO',
+      },
+      licence: 'pro_plus',
+      inReconfirmNotificationPeriod: true,
+    },
+    email: 'reconfirmation-sso@overleaf.com',
+    confirmedAt: '2022-03-09T10:59:44.139Z',
+    samlProviderId: 'reconfirmation-sso-provider-id',
+    default: false,
+  },
+  {
+    affiliation: {
+      institution: {
+        confirmed: true,
+        isUniversity: true,
+        ssoEnabled: true,
+        id: 5,
+        name: 'Reconfirmed SSO',
+      },
+      licence: 'pro_plus',
+    },
+    confirmedAt: '2022-03-09T10:59:44.139Z',
+    email: 'sso@overleaf.com',
+    samlProviderId: 'sso-reconfirmed-provider-id',
+    default: false,
+  },
+]
 
 const fakeInstitutions = [
   {
@@ -103,6 +168,14 @@ export function defaultSetupMocks(fetchMock) {
     })
 }
 
+export function reconfirmationSetupMocks(fetchMock) {
+  defaultSetupMocks(fetchMock)
+  fetchMock.get(/\/user\/emails/, fakeReconfirmationUsersData, {
+    delay: MOCK_DELAY,
+    overwriteRoutes: true,
+  })
+}
+
 export function errorsMocks(fetchMock) {
   fetchMock
     .get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY })
@@ -122,3 +195,15 @@ export function setDefaultMeta() {
     (Date.now() - 1000 * 60 * 60).toString()
   )
 }
+
+export function setReconfirmationMeta() {
+  setDefaultMeta()
+  window.metaAttributesCache.set(
+    'ol-reconfirmationRemoveEmail',
+    'reconfirmation-highlighted@overleaf.com'
+  )
+  window.metaAttributesCache.set(
+    'ol-reconfirmedViaSAML',
+    'sso-reconfirmed-provider-id'
+  )
+}

+ 22 - 0
services/web/frontend/stylesheets/app/account-settings.less

@@ -189,3 +189,25 @@ tbody > tr.affiliations-table-warning-row > td {
     }
   }
 }
+
+.settings-reconfirm-info {
+  display: flex;
+  justify-content: space-between;
+  margin: 0px auto @margin-sm auto !important;
+  padding: @padding-md;
+  &:not(.alert-info) {
+    background-color: @ol-blue-gray-0;
+  }
+
+  > *:not(:last-child) {
+    margin-right: @margin-md;
+  }
+
+  .fa-warning {
+    color: @brand-warning;
+  }
+}
+
+.setting-reconfirm-info-right {
+  white-space: nowrap;
+}

+ 7 - 1
services/web/frontend/stylesheets/components/alerts.less

@@ -101,8 +101,14 @@
   .btn-inline-link {
     color: white;
     text-decoration: underline;
+    background: none;
+    &:hover,
+    &:focus,
+    &:active {
+      background: none;
+    }
   }
-  .btn {
+  .btn:not(.btn-inline-link) {
     text-decoration: none;
   }
 }

+ 174 - 0
services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

@@ -0,0 +1,174 @@
+import {
+  fireEvent,
+  render,
+  screen,
+  waitFor,
+  waitForElementToBeRemoved,
+} from '@testing-library/react'
+import sinon from 'sinon'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import { cloneDeep } from 'lodash'
+import ReconfirmationInfo from '../../../../../../frontend/js/features/settings/components/emails/reconfirmation-info'
+import { ssoUserData } from '../../fixtures/test-user-email-data'
+import { UserEmailData } from '../../../../../../types/user-email'
+import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
+
+function renderReconfirmationInfo(data: UserEmailData) {
+  return render(
+    <UserEmailsProvider>
+      <ReconfirmationInfo userEmailData={data} />
+    </UserEmailsProvider>
+  )
+}
+
+describe('<ReconfirmationInfo/>', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = window.metaAttributesCache || new Map()
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  describe('reconfirmed via SAML', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set(
+        'ol-reconfirmedViaSAML',
+        'sso-prof-saml-id'
+      )
+    })
+
+    afterEach(function () {
+      window.metaAttributesCache = new Map()
+    })
+
+    it('show reconfirmed confirmation', function () {
+      renderReconfirmationInfo(ssoUserData)
+      screen.getByText('SSO University')
+      screen.getByText(/affiliation is confirmed/)
+      screen.getByText(/Thank you!/)
+    })
+  })
+
+  describe('in reconfirm notification period', function () {
+    let inReconfirmUserData: UserEmailData
+    const locationStub = sinon.stub()
+    const originalLocation = window.location
+
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-ExposedSettings', {
+        samlInitPath: '/saml',
+      })
+      Object.defineProperty(window, 'location', {
+        value: {
+          assign: locationStub,
+        },
+      })
+
+      inReconfirmUserData = cloneDeep(ssoUserData)
+      if (inReconfirmUserData.affiliation) {
+        inReconfirmUserData.affiliation.inReconfirmNotificationPeriod = true
+      }
+    })
+
+    afterEach(function () {
+      window.metaAttributesCache = new Map()
+      Object.defineProperty(window, 'location', {
+        value: originalLocation,
+      })
+    })
+
+    it('renders prompt', function () {
+      renderReconfirmationInfo(inReconfirmUserData)
+      screen.getByText(/Are you still at/)
+      screen.getByText('SSO University')
+      screen.getByText(
+        /Please take a moment to confirm your institutional email address/
+      )
+      screen.getByRole('link', { name: 'Learn more' })
+      expect(screen.queryByText(/add a new primary email address/)).to.not.exist
+    })
+
+    it('renders default emails prompt', function () {
+      inReconfirmUserData.default = true
+      renderReconfirmationInfo(inReconfirmUserData)
+      screen.getByText(/add a new primary email address/)
+    })
+
+    describe('SAML reconfirmations', function () {
+      beforeEach(function () {
+        window.metaAttributesCache.set('ol-ExposedSettings', {
+          hasSamlFeature: true,
+          samlInitPath: '/saml/init',
+        })
+      })
+
+      it('redirects to SAML flow', async function () {
+        renderReconfirmationInfo(inReconfirmUserData)
+        const confirmButton = screen.getByRole('button', {
+          name: 'Confirm Affiliation',
+        }) as HTMLButtonElement
+
+        await waitFor(() => {
+          expect(confirmButton.disabled).to.be.false
+        })
+        fireEvent.click(confirmButton)
+
+        await waitFor(() => {
+          expect(confirmButton.disabled).to.be.true
+        })
+        sinon.assert.calledOnce(locationStub)
+        sinon.assert.calledWithMatch(
+          locationStub,
+          '/saml/init?university_id=2&reconfirm=/user/settings'
+        )
+      })
+    })
+
+    describe('Email reconfirmations', function () {
+      beforeEach(function () {
+        window.metaAttributesCache.set('ol-ExposedSettings', {
+          hasSamlFeature: false,
+        })
+        fetchMock.post('/user/emails/resend_confirmation', 200)
+      })
+
+      it('sends and resends confirmation email', async function () {
+        renderReconfirmationInfo(inReconfirmUserData)
+        const confirmButton = screen.getByRole('button', {
+          name: 'Confirm Affiliation',
+        }) as HTMLButtonElement
+
+        await waitFor(() => {
+          expect(confirmButton.disabled).to.be.false
+        })
+        fireEvent.click(confirmButton)
+
+        await waitFor(() => {
+          expect(confirmButton.disabled).to.be.true
+        })
+        expect(fetchMock.called()).to.be.true
+
+        // the confirmation text should now be displayed
+        screen.getByText(/Please check your email inbox to confirm/)
+
+        // try the resend button
+        fetchMock.resetHistory()
+        const resendButton = screen.getByRole('button', {
+          name: 'Resend confirmation email',
+        }) as HTMLButtonElement
+
+        fireEvent.click(resendButton)
+
+        screen.getByText(/Sending/)
+        expect(fetchMock.called()).to.be.true
+        await waitForElementToBeRemoved(() => screen.getByText(/Sending/))
+        screen.getByRole('button', {
+          name: 'Resend confirmation email',
+        })
+      })
+    })
+  })
+})

+ 31 - 0
services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts

@@ -44,6 +44,37 @@ export const professionalUserData: UserEmailData & {
   default: true,
 }
 
+export const ssoUserData: UserEmailData = {
+  affiliation: {
+    cachedConfirmedAt: '2022-02-03T11:46:28.249Z',
+    cachedEntitlement: null,
+    cachedLastDayToReconfirm: null,
+    cachedPastReconfirmDate: false,
+    cachedReconfirmedAt: null,
+    department: 'Art History',
+    institution: {
+      commonsAccount: true,
+      confirmed: true,
+      id: 2,
+      isUniversity: true,
+      maxConfirmationMonths: 12,
+      name: 'SSO University',
+      ssoEnabled: true,
+      ssoBeta: false,
+    },
+    inReconfirmNotificationPeriod: false,
+    inferred: false,
+    licence: 'pro_plus',
+    pastReconfirmDate: false,
+    portal: { slug: '', templates_count: 0 },
+    role: 'Prof',
+  },
+  confirmedAt: '2022-02-03T11:46:28.249Z',
+  email: 'sso-prof@sso-university.edu',
+  samlProviderId: 'sso-prof-saml-id',
+  default: false,
+}
+
 export const fakeUsersData = [
   { ...confirmedUserData },
   { ...unconfirmedUserData },