diff --git a/services/web/frontend/js/features/settings/components/emails/add-email.tsx b/services/web/frontend/js/features/settings/components/emails/add-email.tsx index f976ecb83e..d2722c269d 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email.tsx @@ -22,6 +22,17 @@ import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm- import RecaptchaConditions from '@/shared/components/recaptcha-conditions' import SsoLinkingInfoGroup from './add-email/sso-linking-info-group' import Notification from '@/shared/components/notification' +import { useFeatureFlag } from '@/shared/context/split-test-context' + +function isDomainCapturedByGroup( + domainInfo: DomainInfo, + domainCapturedByGroupRolloutFlagEnabled: boolean +): boolean { + return domainCapturedByGroupRolloutFlagEnabled + ? (domainInfo.capturedByGroup && domainInfo.group?.domainCaptureEnabled) || + false + : domainInfo.group?.domainCaptureEnabled || false +} function AddEmail() { const { t } = useTranslation() @@ -49,6 +60,10 @@ function AddEmail() { const emailAddressLimit = getMeta('ol-emailAddressLimit') || 10 const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha() + const domainCapturedByGroupRolloutFlagEnabled = useFeatureFlag( + 'domain-captured-by-group' + ) + useEffect(() => { setUserEmailsContextLoading(isLoading) }, [setUserEmailsContextLoading, isLoading]) @@ -196,8 +211,15 @@ function AddEmail() { ) } + const isDomainCaptured = newEmailMatchedDomain + ? isDomainCapturedByGroup( + newEmailMatchedDomain, + domainCapturedByGroupRolloutFlagEnabled + ) + : false const isSsoAvailableForDomain = - newEmailMatchedDomain && ssoAvailableForDomain(newEmailMatchedDomain) + newEmailMatchedDomain && + ssoAvailableForDomain(newEmailMatchedDomain, isDomainCaptured) return (
@@ -243,6 +265,7 @@ function AddEmail() { @@ -259,15 +282,14 @@ function AddEmailViaSSO({ email, domainInfo, userInstitutions, + isDomainCaptured, }: { email: string domainInfo: DomainInfo userInstitutions: string[] + isDomainCaptured: boolean }) { - if ( - domainInfo.group?.domainCaptureEnabled && - domainInfo.group?.managedUsersEnabled - ) { + if (isDomainCaptured && domainInfo.group?.managedUsersEnabled) { return ( - } else if ( - domainInfo.group?.domainCaptureEnabled && - domainInfo.group?.ssoConfig?.enabled - ) { + } else if (isDomainCaptured && domainInfo.group?.ssoConfig?.enabled) { return } } diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx index 27c5bf6f9f..e41d6773c6 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx @@ -27,6 +27,7 @@ function matchLocalAndDomain(emailHint: string) { export type DomainInfo = { hostname: string confirmed?: boolean + capturedByGroup?: boolean university: { id: number name: string diff --git a/services/web/frontend/js/features/settings/utils/sso.ts b/services/web/frontend/js/features/settings/utils/sso.ts index 29764c3883..aa82ee090e 100644 --- a/services/web/frontend/js/features/settings/utils/sso.ts +++ b/services/web/frontend/js/features/settings/utils/sso.ts @@ -3,7 +3,8 @@ import { DomainInfo } from '../components/emails/add-email/input' import { Institution } from '../../../../../types/institution' export const ssoAvailableForDomain = ( - domain: DomainInfo | null + domain: DomainInfo | null, + isDomainCapturedByGroup: boolean ): domain is DomainInfo => { const { hasSamlBeta, hasSamlFeature } = getMeta('ol-ExposedSettings') if (!hasSamlFeature || !domain || !domain.confirmed || !domain.university) { @@ -13,7 +14,7 @@ export const ssoAvailableForDomain = ( return true } - if (domain.group?.ssoConfig?.enabled) { + if (isDomainCapturedByGroup && domain.group?.ssoConfig?.enabled) { return true } diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx index e98fd26965..14ee4734ce 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx @@ -14,6 +14,7 @@ import { Affiliation } from '../../../../../../types/affiliation' import withMarkup from '../../../../helpers/with-markup' import getMeta from '@/utils/meta' import { clearDomainCache } from '../../../../../../frontend/js/features/settings/components/emails/add-email/input' +import { SplitTestProvider } from '@/shared/context/split-test-context' const userEmailData: UserEmailData & { affiliation: Affiliation } = { affiliation: { @@ -57,6 +58,14 @@ const institutionDomainData = [ }, ] as const +function renderEmailsSection() { + return render(, { + wrapper: ({ children }) => ( + {children} + ), + }) +} + function resetFetchMock() { fetchMock.removeRoutes().clearHistory() fetchMock.get('express:/institutions/domains', []) @@ -94,14 +103,14 @@ describe('', function () { it('renders "add another email" button', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() await screen.findByRole('button', { name: 'Add another email' }) }) it('renders input', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() await fetchMock.callHistory.flush(true) const button = await screen.findByRole('button', { @@ -116,7 +125,7 @@ describe('', function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200) fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200) - render() + renderEmailsSection() await fetchMock.callHistory.flush(true) const button = await screen.findByRole('button', { @@ -159,7 +168,7 @@ describe('', function () { it('renders "add new email" button', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -175,7 +184,7 @@ describe('', function () { emails.push({ email: `bar${i}@overleaf.com` }) } fetchMock.get('/user/emails?ensureAffiliation=true', emails) - render() + renderEmailsSection() const findByTextWithMarkup = withMarkup(screen.findByText) await findByTextWithMarkup( @@ -188,7 +197,7 @@ describe('', function () { it('adds new email address', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const addAnotherEmailBtn = await screen.findByRole( 'button', @@ -232,7 +241,7 @@ describe('', function () { it('fails to add add new email address', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const addAnotherEmailBtn = await screen.findByRole( 'button', @@ -271,7 +280,7 @@ describe('', function () { it('can link email address to an existing SSO institution', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -295,7 +304,7 @@ describe('', function () { fetchMock.get('/user/emails?ensureAffiliation=true', [ { email: 'bar@autocomplete.edu', samlProviderId: '1234' }, ]) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -321,7 +330,7 @@ describe('', function () { const country = 'Germany' const customDepartment = 'Custom department' fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -424,7 +433,7 @@ describe('', function () { it('autocompletes institution name', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -480,7 +489,7 @@ describe('', function () { const countryCode = 'de' const newUniversity = 'Abcdef' fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -596,7 +605,7 @@ describe('', function () { ] const hostnameFirstChar = institutionDomainDataCopy[0].hostname.charAt(0) fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -669,7 +678,7 @@ describe('', function () { ] const hostnameFirstChar = institutionDomainDataCopy[0].hostname.charAt(0) fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -766,7 +775,7 @@ describe('', function () { it('can add email address via SSO', async function () { // note: this UI is a WIP fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -809,7 +818,7 @@ describe('', function () { it('renders error', async function () { // note: this UI is a WIP fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -855,7 +864,7 @@ describe('', function () { it('renders Commons UI', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -900,7 +909,7 @@ describe('', function () { it('renders group domain-capture error instead of Commons UI', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', []) - render() + renderEmailsSection() const button = await screen.findByRole('button', { name: 'Add another email', @@ -925,5 +934,192 @@ describe('', function () { ).to.be.null }) }) + + describe('when the domain-captured-by-group feature flag is enabled', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-splitTestVariants', { + 'domain-captured-by-group': 'enabled', + }) + }) + + afterEach(function () { + window.metaAttributesCache.set('ol-splitTestVariants', {}) + }) + + describe('and capturedByGroup is true with managedUsersEnabled', function () { + beforeEach(async function () { + await fetchMock.callHistory.flush(true) + fetchMock.removeRoutes().clearHistory() + const institution = { + university: { + id: 1234, + ssoEnabled: false, + name: 'Auto Complete University', + }, + hostname: 'autocomplete.edu', + confirmed: true, + capturedByGroup: true, + group: { + domainCaptureEnabled: true, + managedUsersEnabled: true, + ssoConfig: { enabled: true }, + }, + } + + fetchMock.get('express:/institutions/domains', [institution]) + }) + + it('renders the domain-capture error', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', []) + renderEmailsSection() + + const button = await screen.findByRole('button', { + name: 'Add another email', + }) + + await userEvent.click(button) + + const input = screen.getByRole('textbox', { name: 'Email' }) + fireEvent.change(input, { + target: { value: 'user@autocomplete.edu' }, + }) + + const notification = await screen.findByRole('alert') + within(notification).getByText( + 'Your company email address has been registered under a verified domain, and cannot be added as a secondary email.', + { exact: false } + ) + }) + }) + + describe('and capturedByGroup is false with managedUsersEnabled (domainCaptureEnabled alone is not enough)', function () { + beforeEach(async function () { + await fetchMock.callHistory.flush(true) + fetchMock.removeRoutes().clearHistory() + const institution = { + university: { + id: 1234, + ssoEnabled: false, + name: 'Auto Complete University', + }, + hostname: 'autocomplete.edu', + confirmed: true, + capturedByGroup: false, + group: { + domainCaptureEnabled: true, + managedUsersEnabled: true, + ssoConfig: { enabled: true }, + }, + } + + fetchMock.get('express:/institutions/domains', [institution]) + }) + + it('does not render the domain-capture error and shows regular institution fields', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', []) + renderEmailsSection() + + const button = await screen.findByRole('button', { + name: 'Add another email', + }) + + await userEvent.click(button) + + const input = screen.getByRole('textbox', { name: 'Email' }) + fireEvent.change(input, { + target: { value: 'user@autocomplete.edu' }, + }) + + await screen.findByRole('button', { name: 'Add new email' }) + expect(screen.queryByRole('alert')).to.be.null + }) + }) + + describe('and capturedByGroup is true with ssoConfig enabled (no managedUsers)', function () { + beforeEach(async function () { + await fetchMock.callHistory.flush(true) + fetchMock.removeRoutes().clearHistory() + const institution = { + university: { + id: 1234, + ssoEnabled: false, + name: 'Auto Complete University', + }, + hostname: 'autocomplete.edu', + confirmed: true, + capturedByGroup: true, + group: { + domainCaptureEnabled: true, + managedUsersEnabled: false, + ssoConfig: { enabled: true }, + }, + } + + fetchMock.get('express:/institutions/domains', [institution]) + }) + + it('renders the group SSO unavailable message', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', []) + renderEmailsSection() + + const button = await screen.findByRole('button', { + name: 'Add another email', + }) + + await userEvent.click(button) + + const input = screen.getByRole('textbox', { name: 'Email' }) + fireEvent.change(input, { + target: { value: 'user@autocomplete.edu' }, + }) + + await screen.findByText('This feature is currently unavailable.') + }) + }) + + describe('and capturedByGroup is false with ssoConfig enabled', function () { + beforeEach(async function () { + await fetchMock.callHistory.flush(true) + fetchMock.removeRoutes().clearHistory() + const institution = { + university: { + id: 1234, + ssoEnabled: false, + name: 'Auto Complete University', + }, + hostname: 'autocomplete.edu', + confirmed: true, + capturedByGroup: false, + group: { + domainCaptureEnabled: true, + managedUsersEnabled: false, + ssoConfig: { enabled: true }, + }, + } + + fetchMock.get('express:/institutions/domains', [institution]) + }) + + it('does not render SSO UI and shows regular institution fields', async function () { + fetchMock.get('/user/emails?ensureAffiliation=true', []) + renderEmailsSection() + + const button = await screen.findByRole('button', { + name: 'Add another email', + }) + + await userEvent.click(button) + + const input = screen.getByRole('textbox', { name: 'Email' }) + fireEvent.change(input, { + target: { value: 'user@autocomplete.edu' }, + }) + + await screen.findByRole('button', { name: 'Add new email' }) + expect(screen.queryByText('This feature is currently unavailable.')) + .to.be.null + }) + }) + }) }) }) diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx index e638c9ef8f..3d6bfedc35 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx @@ -12,6 +12,7 @@ import { UserEmailsProvider } from '../../../../../../frontend/js/features/setti import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section' import { Affiliation } from '../../../../../../types/affiliation' import getMeta from '@/utils/meta' +import { SplitTestProvider } from '@/shared/context/split-test-context' const userData1: UserEmailData & { affiliation: Affiliation } = { affiliation: { @@ -74,6 +75,14 @@ const userData2: UserEmailData & { affiliation: Affiliation } = { default: false, } +function renderEmailsSection() { + return render(, { + wrapper: ({ children }) => ( + {children} + ), + }) +} + describe('user role and institution', function () { beforeEach(function () { Object.assign(getMeta('ol-ExposedSettings'), { @@ -125,7 +134,7 @@ describe('user role and institution', function () { it('fetches institution data and replaces departments dropdown on add/change', async function () { const userEmailData = userData1 fetchMock.modifyRoute('get user emails', { response: [userEmailData] }) - render() + renderEmailsSection() await fetchMock.callHistory.flush(true) fetchMock.removeRoutes().clearHistory() @@ -158,7 +167,7 @@ describe('user role and institution', function () { .modifyRoute('get user emails', { response: [userData1] }) .get(/\/institutions\/list/, { departments: [] }) .post('/user/emails/endorse', 200) - render() + renderEmailsSection() const addBtn = await screen.findByRole('button', { name: /add role and department/i, diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx index 55c833df1c..7745aecf0f 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx @@ -16,6 +16,15 @@ import { unconfirmedUserData, } from '../../fixtures/test-user-email-data' import getMeta from '@/utils/meta' +import { SplitTestProvider } from '@/shared/context/split-test-context' + +function renderEmailsSection() { + return render(, { + wrapper: ({ children }) => ( + {children} + ), + }) +} describe('', function () { beforeEach(function () { @@ -30,13 +39,13 @@ describe('', function () { }) it('renders translated heading', function () { - render() + renderEmailsSection() screen.getByRole('heading', { name: /emails and affiliations/i }) }) it('renders translated description', function () { - render() + renderEmailsSection() screen.getByText(/add additional email addresses/i) screen.getByText(/to change your primary email/i) @@ -46,14 +55,14 @@ describe('', function () { }) it('renders a loading message when loading', async function () { - render() + renderEmailsSection() await screen.findByText(/loading/i) }) it('renders an error message and hides loading message on error', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', 500) - render() + renderEmailsSection() await screen.findByText( /an error has occurred while performing your request/i @@ -63,7 +72,7 @@ describe('', function () { it('renders user emails', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', fakeUsersData) - render() + renderEmailsSection() await waitFor(() => { fakeUsersData.forEach(userData => { @@ -74,7 +83,7 @@ describe('', function () { it('renders primary status', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', [professionalUserData]) - render() + renderEmailsSection() await screen.findByText(`${professionalUserData.email}`) screen.getByText('Primary') @@ -82,14 +91,14 @@ describe('', function () { it('shows confirmation status for unconfirmed users', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) - render() + renderEmailsSection() await screen.findByText(/unconfirmed/i) }) it('hides confirmation status for confirmed users', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', [confirmedUserData]) - render() + renderEmailsSection() await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) expect(screen.queryByText(/please check your inbox/i)).to.be.null @@ -97,14 +106,14 @@ describe('', function () { it('renders resend link', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) - render() + renderEmailsSection() await screen.findByRole('button', { name: 'Send confirmation code' }) }) it('renders professional label', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', [professionalUserData]) - render() + renderEmailsSection() const node = await screen.findByText(professionalUserData.email, { exact: false, @@ -115,7 +124,7 @@ describe('', function () { it('shows loader when resending email', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) - render() + renderEmailsSection() await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) fetchMock.post('/user/emails/send-confirmation-code', 200) @@ -145,7 +154,7 @@ describe('', function () { it('shows error when resending email fails', async function () { fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData]) - render() + renderEmailsSection() await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) fetchMock.post('/user/emails/send-confirmation-code', 503) @@ -195,7 +204,7 @@ describe('', function () { ] fetchMock.get('/user/emails?ensureAffiliation=true', emails) - render() + renderEmailsSection() await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) diff --git a/services/web/test/frontend/features/settings/utils/sso.test.ts b/services/web/test/frontend/features/settings/utils/sso.test.ts new file mode 100644 index 0000000000..b0c0cafa41 --- /dev/null +++ b/services/web/test/frontend/features/settings/utils/sso.test.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai' +import { ssoAvailableForDomain } from '../../../../../frontend/js/features/settings/utils/sso' +import { DomainInfo } from '../../../../../frontend/js/features/settings/components/emails/add-email/input' + +const baseDomain: DomainInfo = { + hostname: 'example.edu', + confirmed: true, + university: { + id: 1, + name: 'Example University', + ssoEnabled: false, + ssoBeta: false, + }, + group: { + domainCaptureEnabled: false, + ssoConfig: { + enabled: false, + }, + }, +} + +describe('ssoAvailableForDomain', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + hasSamlFeature: true, + hasSamlBeta: false, + }) + }) + + it('returns false when hasSamlFeature is false', function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + hasSamlFeature: false, + hasSamlBeta: false, + }) + expect(ssoAvailableForDomain(baseDomain, false)).to.be.false + }) + + it('returns false when domain is null', function () { + expect(ssoAvailableForDomain(null, false)).to.be.false + }) + + it('returns false when domain is not confirmed', function () { + const domain = { ...baseDomain, confirmed: false } + expect(ssoAvailableForDomain(domain, false)).to.be.false + }) + + it('returns true when university.ssoEnabled is true, regardless of isDomainCapturedByGroup', function () { + const domain: DomainInfo = { + ...baseDomain, + university: { ...baseDomain.university, ssoEnabled: true }, + } + expect(ssoAvailableForDomain(domain, false)).to.be.true + expect(ssoAvailableForDomain(domain, true)).to.be.true + }) + + describe('group SSO via ssoConfig', function () { + const domainWithGroupSso: DomainInfo = { + ...baseDomain, + group: { + domainCaptureEnabled: true, + ssoConfig: { enabled: true }, + }, + } + + it('returns true when isDomainCapturedByGroup is true and group ssoConfig is enabled', function () { + expect(ssoAvailableForDomain(domainWithGroupSso, true)).to.be.true + }) + + it('returns false when isDomainCapturedByGroup is false even if group ssoConfig is enabled', function () { + expect(ssoAvailableForDomain(domainWithGroupSso, false)).to.be.false + }) + }) + + it('returns true when hasSamlBeta and university.ssoBeta are both true', function () { + window.metaAttributesCache.set('ol-ExposedSettings', { + hasSamlFeature: true, + hasSamlBeta: true, + }) + const domain: DomainInfo = { + ...baseDomain, + university: { ...baseDomain.university, ssoBeta: true }, + } + expect(ssoAvailableForDomain(domain, false)).to.be.true + }) + + it('returns false when hasSamlBeta is false and university.ssoBeta is true', function () { + const domain: DomainInfo = { + ...baseDomain, + university: { ...baseDomain.university, ssoBeta: true }, + } + expect(ssoAvailableForDomain(domain, false)).to.be.false + }) +})