From 86cc1a8b64a1cc8d9d45bb0c539bbc7dcb3fb407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Alby?= Date: Tue, 31 May 2022 14:06:28 +0200 Subject: [PATCH] Merge pull request #8178 from overleaf/ta-settings-reconfirmation [SettingsPage] Add Reconfirmation Prompt GitOrigin-RevId: 2e03ba7f459b32faf6d66f21ba692ca54f62a320 --- .../web/frontend/extracted-translations.json | 10 +- .../components/emails/reconfirmation-info.tsx | 77 ++++++++ .../reconfirmation-info-prompt.tsx | 165 +++++++++++++++++ .../reconfirmation-info-success.tsx | 29 +++ .../settings/components/emails/row.tsx | 2 + .../stories/settings/emails.stories.js | 9 + .../stories/settings/helpers/emails.js | 85 +++++++++ .../stylesheets/app/account-settings.less | 22 +++ .../stylesheets/components/alerts.less | 8 +- .../emails/reconfirmation-info.test.tsx | 174 ++++++++++++++++++ .../settings/fixtures/test-user-email-data.ts | 31 ++++ 11 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx create mode 100644 services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx create mode 100644 services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index f5161f79b1..6c52024c23 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx b/services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx new file mode 100644 index 0000000000..e62828c407 --- /dev/null +++ b/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 ( + + + + ) + } + + if (userEmailData.affiliation.inReconfirmNotificationPeriod) { + return ( + + + + ) + } + + return null +} + +type ReconfirmationInfoContentWrapperProps = { + asAlertInfo: boolean + children: ReactNode +} + +function ReconfirmationInfoContentWrapper({ + asAlertInfo, + children, +}: ReconfirmationInfoContentWrapperProps) { + return ( + + +
+ {children} +
+ +
+ ) +} + +export default ReconfirmationInfo diff --git a/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx b/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx new file mode 100644 index 0000000000..4c76310c56 --- /dev/null +++ b/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 ( +
+ ] + } + />{' '} + {isLoading ? ( + <> + {t('sending')}... + + ) : ( + + )} +
+ {isError && ( +
{t('generic_something_went_wrong')}
+ )} +
+ ) + } + + return ( + <> +
+ +
+
+ +
+ {isError && ( +
{t('generic_something_went_wrong')}
+ )} +
+ + ) +} + +type ReconfirmationInfoPromptTextProps = { + primary: boolean + institutionName: Institution['name'] +} + +function ReconfirmationInfoPromptText({ + primary, + institutionName, +}: ReconfirmationInfoPromptTextProps) { + const { t } = useTranslation() + + return ( +
+ + ] + } + />{' '} + ] + } + />{' '} + + {t('learn_more')} + +
+ {primary ? {t('need_to_add_new_primary_before_remove')} : null} +
+ ) +} + +export default ReconfirmationInfoPrompt diff --git a/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx b/services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx new file mode 100644 index 0000000000..4c470b2c68 --- /dev/null +++ b/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 ( +
+ ] + } + />{' '} + {t('thank_you_exclamation')} +
+ ) +} + +export default ReconfirmationInfoSuccess diff --git a/services/web/frontend/js/features/settings/components/emails/row.tsx b/services/web/frontend/js/features/settings/components/emails/row.tsx index 8c5643e546..28b21afb5e 100644 --- a/services/web/frontend/js/features/settings/components/emails/row.tsx +++ b/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 && ( )} + ) } diff --git a/services/web/frontend/stories/settings/emails.stories.js b/services/web/frontend/stories/settings/emails.stories.js index 6f0fdb0968..9621ad6349 100644 --- a/services/web/frontend/stories/settings/emails.stories.js +++ b/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 } +export const ReconfirmationEmailsList = args => { + useFetchMock(reconfirmationSetupMocks) + setReconfirmationMeta() + + return +} + export const NetworkErrors = args => { useFetchMock(errorsMocks) setDefaultMeta() diff --git a/services/web/frontend/stories/settings/helpers/emails.js b/services/web/frontend/stories/settings/helpers/emails.js index f1a54fd955..2356f9d66b 100644 --- a/services/web/frontend/stories/settings/helpers/emails.js +++ b/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' + ) +} diff --git a/services/web/frontend/stylesheets/app/account-settings.less b/services/web/frontend/stylesheets/app/account-settings.less index 8c3b4a3778..e40acfef49 100644 --- a/services/web/frontend/stylesheets/app/account-settings.less +++ b/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; +} diff --git a/services/web/frontend/stylesheets/components/alerts.less b/services/web/frontend/stylesheets/components/alerts.less index 4499651d05..817bf906a9 100755 --- a/services/web/frontend/stylesheets/components/alerts.less +++ b/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; } } diff --git a/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx b/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx new file mode 100644 index 0000000000..786bb1b309 --- /dev/null +++ b/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( + + + + ) +} + +describe('', 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', + }) + }) + }) + }) +}) diff --git a/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts b/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts index 37dc48ceef..c7a806163a 100644 --- a/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts +++ b/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 },