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