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

[SettingsPage] Add Reconfirmation Prompt

GitOrigin-RevId: 2e03ba7f459b32faf6d66f21ba692ca54f62a320
This commit is contained in:
Timothée Alby
2022-05-31 14:06:28 +02:00
committed by Copybot
parent a953756128
commit 86cc1a8b64
11 changed files with 610 additions and 2 deletions

View File

@@ -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": "",

View File

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

View File

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

View File

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

View File

@@ -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} />
</>
)
}

View File

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

View File

@@ -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'
)
}

View File

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

View File

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

View File

@@ -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',
})
})
})
})
})

View File

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