diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 2b1b4ac9da..649d2cde0d 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -87,6 +87,7 @@ "dismiss": "", "dismiss_error_popup": "", "doesnt_match": "", + "doing_this_will_verify_affiliation_and_allow_log_in_2": "", "done": "", "download": "", "download_pdf": "", @@ -120,6 +121,7 @@ "file_name_in_this_project": "", "file_outline": "", "files_cannot_include_invalid_characters": "", + "find_out_more_about_institution_login": "", "find_out_more_about_the_file_outline": "", "find_the_symbols_you_need_with_premium": "", "first_name": "", @@ -216,6 +218,7 @@ "learn_more_about_the_symbol_palette": "", "let_us_know": "", "link": "", + "link_accounts_and_add_email": "", "link_sharing_is_off": "", "link_sharing_is_on": "", "link_to_github": "", @@ -431,6 +434,7 @@ "this_project_is_public_read_only": "", "this_project_will_appear_in_your_dropbox_folder_at": "", "timedout": "", + "to_add_email_accounts_need_to_be_linked_2": "", "to_add_more_collaborators": "", "to_change_access_permissions": "", "toggle_compile_options_menu": "", 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 deecc803cc..6715ebd8d8 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 @@ -19,7 +19,16 @@ function matchLocalAndDomain(emailHint: string) { } } -type InstitutionInfo = { hostname: string; university: { id: number } } +export type InstitutionInfo = { + hostname: string + confirmed?: boolean + university: { + id: number + name: string + ssoEnabled?: boolean + ssoBeta?: boolean + } +} let domainCache = new Map() diff --git a/services/web/frontend/js/features/settings/components/emails/add-email-sso-linking-info.tsx b/services/web/frontend/js/features/settings/components/emails/add-email-sso-linking-info.tsx new file mode 100644 index 0000000000..de76e29a72 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/emails/add-email-sso-linking-info.tsx @@ -0,0 +1,52 @@ +import { Trans, useTranslation } from 'react-i18next' +import { InstitutionInfo } from './add-email-input' +import { ExposedSettings } from '../../../../../../types/exposed-settings' +import getMeta from '../../../../utils/meta' + +type AddEmailSSOLinkingInfoProps = { + institutionInfo: InstitutionInfo + email: string +} + +export function AddEmailSSOLinkingInfo({ + institutionInfo, + email, +}: AddEmailSSOLinkingInfoProps) { + const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings + const { t } = useTranslation() + + return ( + <> +

+ {institutionInfo.university.name} +

+

+ ]} // eslint-disable-line react/jsx-key + values={{ institutionName: institutionInfo.university.name }} + /> +

+

+ ]} // eslint-disable-line react/jsx-key + values={{ institutionName: institutionInfo.university.name }} + />{' '} + + {t('find_out_more_about_institution_login')}. + +

+ + {t('link_accounts_and_add_email')} + + + ) +} 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 b64f0401f7..584ccb0fb2 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 @@ -5,7 +5,7 @@ import Cell from './cell' import Icon from '../../../../shared/components/icon' import DownshiftInput from './downshift-input' import CountryInput from './country-input' -import { AddEmailInput } from './add-email-input' +import { AddEmailInput, InstitutionInfo } from './add-email-input' import useAsync from '../../../../shared/hooks/use-async' import { useUserEmailsContext } from '../../context/user-email-context' import { getJSON, postJSON } from '../../../../infrastructure/fetch-json' @@ -13,17 +13,35 @@ import { defaults as roles } from '../../roles' import { defaults as departments } from '../../departments' import { University } from '../../../../../../types/university' import { CountryCode } from '../../../../../../types/country' +import { ExposedSettings } from '../../../../../../types/exposed-settings' +import getMeta from '../../../../utils/meta' +import { AddEmailSSOLinkingInfo } from './add-email-sso-linking-info' const isValidEmail = (email: string) => { return Boolean(email) } +const ssoAvailableForDomain = (domain: InstitutionInfo | null) => { + const { hasSamlBeta, hasSamlFeature } = getMeta( + 'ol-ExposedSettings' + ) as ExposedSettings + if (!hasSamlFeature || !domain || !domain.confirmed || !domain.university) { + return false + } + if (domain.university.ssoEnabled) { + return true + } + return hasSamlBeta && domain.university.ssoBeta +} + function AddEmail() { const { t } = useTranslation() const [isFormVisible, setIsFormVisible] = useState( () => window.location.hash === '#add-email' ) const [newEmail, setNewEmail] = useState('') + const [newEmailMatchedInstitution, setNewEmailMatchedInstitution] = + useState(null) const [countryCode, setCountryCode] = useState(null) const [universities, setUniversities] = useState< Partial> @@ -77,8 +95,9 @@ function AddEmail() { setIsInstitutionFieldsVisible(true) } - const handleEmailChange = (value: string) => { + const handleEmailChange = (value: string, institution?: InstitutionInfo) => { setNewEmail(value) + setNewEmailMatchedInstitution(institution || null) } const handleAddNewEmail = () => { @@ -118,6 +137,7 @@ function AddEmail() { getEmails() setIsFormVisible(false) setNewEmail('') + setNewEmailMatchedInstitution(null) setCountryCode(null) setIsUniversityDirty(false) setUniversity('') @@ -136,6 +156,10 @@ function AddEmail() { return universities[countryCode]?.map(({ name }) => name) ?? [] } + const ssoAvailable = + newEmailMatchedInstitution && + ssoAvailableForDomain(newEmailMatchedInstitution) + return (
@@ -160,83 +184,100 @@ function AddEmail() { - - - {isInstitutionFieldsVisible ? ( - <> -
- -
-
- -
- {isUniversityDirty && ( + + {ssoAvailable && ( + + + + + + )} + + {!ssoAvailable && ( + <> + + + {isInstitutionFieldsVisible ? ( <>
-
-
+
+ {isUniversityDirty && ( + <> +
+ +
+
+ +
+ + )} + ) : ( +
+ {t('is_email_affiliated')} +
+ +
)} - - ) : ( -
- {t('is_email_affiliated')} -
+ + + + + -
- )} - - - - - - {isError && ( -
- {' '} - {t('error_performing_request')} -
- )} -
- + {isError && ( +
+ {' '} + {t('error_performing_request')} +
+ )} + + + + )} )} diff --git a/services/web/frontend/stories/settings/add-email-input.stories.tsx b/services/web/frontend/stories/settings/add-email-input.stories.tsx index 7f373e619a..d6e6e639ef 100644 --- a/services/web/frontend/stories/settings/add-email-input.stories.tsx +++ b/services/web/frontend/stories/settings/add-email-input.stories.tsx @@ -4,7 +4,10 @@ import { AddEmailInput } from '../../js/features/settings/components/emails/add- export const EmailInput = args => { useFetchMock(fetchMock => fetchMock.get(/\/institutions\/domains/, [ - { hostname: 'autocomplete.edu', id: 123 }, + { + hostname: 'autocomplete.edu', + university: { id: 123, name: 'Auto Complete University' }, + }, ]) ) return ( diff --git a/services/web/frontend/stories/settings/helpers/emails.js b/services/web/frontend/stories/settings/helpers/emails.js index b6340c02ba..1a74d17f55 100644 --- a/services/web/frontend/stories/settings/helpers/emails.js +++ b/services/web/frontend/stories/settings/helpers/emails.js @@ -41,10 +41,23 @@ const fakeInstitutions = [ { id: 9326, name: 'Unknown', country_code: 'al', departments: [] }, ] +const fakeInstitutionDomain = [ + { + university: { + id: 1234, + ssoEnabled: true, + name: 'Auto Complete University', + }, + hostname: 'autocomplete.edu', + confirmed: true, + }, +] + export function defaultSetupMocks(fetchMock) { fetchMock .get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY }) .get(/\/institutions\/list/, fakeInstitutions, { delay: MOCK_DELAY }) + .get(/\/institutions\/domains/, fakeInstitutionDomain) .post(/\/user\/emails\/*/, 200, { delay: MOCK_DELAY, }) @@ -60,5 +73,7 @@ export function setDefaultMeta() { window.metaAttributesCache = window.metaAttributesCache || new Map() window.metaAttributesCache.set('ol-ExposedSettings', { hasAffiliationsFeature: true, + hasSamlFeature: true, + samlInitPath: 'saml/init', }) } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 542ead0908..b0a1ed4c81 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -169,6 +169,7 @@ "this_grants_access_to_features": "This grants you access to __appName__ __featureType__ features.", "this_grants_access_to_features_2": "This grants you access to <0>__appName__ <0>__featureType__ features.", "to_add_email_accounts_need_to_be_linked": "To add this email, your __appName__ and __institutionName__ accounts will need to be linked.", + "to_add_email_accounts_need_to_be_linked_2": "To add this email, your <0>__appName__ and <0>__institutionName__ accounts will need to be linked.", "tried_to_log_in_with_email": "You’ve tried to login with __email__.", "tried_to_register_with_email": "You’ve tried to register with __email__, which is already registered with __appName__ as an institutional account.", "log_in_with_email": "Log in with __email__", @@ -208,6 +209,7 @@ "institutional": "Institutional", "doing_this_allow_log_in_through_institution": "Doing this will allow you to log in to __appName__ through your institution portal and will reconfirm your institutional email address.", "doing_this_will_verify_affiliation_and_allow_log_in": "Doing this will verify your affiliation with __institutionName__ and will allow you to log in to __appName__ through your institution.", + "doing_this_will_verify_affiliation_and_allow_log_in_2": "Doing this will verify your affiliation with <0>__institutionName__ and will allow you to log in to <0>__appName__ through your institution.", "email_already_associated_with": "The __email1__ email is already associated with the __email2__ __appName__ account.", "enter_institution_email_to_log_in": "Enter your institutional email to log in through your institution.", "find_out_more_about_institution_login": "Find out more about institutional login", 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 a3d77f0ed4..56d41d94bc 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 @@ -36,17 +36,35 @@ const userEmailData: UserEmailData = { default: false, } +const institutionDomainData = [ + { + university: { + id: 1234, + ssoEnabled: true, + name: 'Auto Complete University', + }, + hostname: 'autocomplete.edu', + confirmed: true, + }, +] + +function resetFetchMock() { + fetchMock.reset() + fetchMock.get('express:/institutions/domains', []) +} + describe('', function () { beforeEach(function () { window.metaAttributesCache.set('ol-ExposedSettings', { hasAffiliationsFeature: true, + hasSamlFeature: true, + samlInitPath: 'saml/init', }) - fetchMock.reset() }) afterEach(function () { window.metaAttributesCache = new Map() - fetchMock.reset() + resetFetchMock() }) it('renders "add another email" button', function () { @@ -85,7 +103,7 @@ describe('', function () { render() await fetchMock.flush(true) - fetchMock.reset() + resetFetchMock() fetchMock .get('/user/emails?ensureAffiliation=true', [userEmailData]) .post('/user/emails', 200) @@ -125,7 +143,7 @@ describe('', function () { render() await fetchMock.flush(true) - fetchMock.reset() + resetFetchMock() fetchMock .get('/user/emails?ensureAffiliation=true', []) .post('/user/emails', 500) @@ -158,13 +176,33 @@ describe('', function () { expect(submitBtn.disabled).to.be.false }) + it('can link email address to an existing SSO institution', async function () { + fetchMock.reset() + fetchMock.get('/user/emails?ensureAffiliation=true', []) + fetchMock.get('express:/institutions/domains', institutionDomainData) + render() + + await userEvent.click( + screen.getByRole('button', { + name: /add another email/i, + }) + ) + + const input = screen.getByLabelText(/email/i) + fireEvent.change(input, { + target: { value: 'user@autocomplete.edu' }, + }) + + await screen.findByRole('link', { name: 'Link Accounts and Add Email' }) + }) + it('adds new email address with existing institution', async function () { const country = 'Germany' fetchMock.get('/user/emails?ensureAffiliation=true', []) render() await fetchMock.flush(true) - fetchMock.reset() + resetFetchMock() await userEvent.click( screen.getByRole('button', { @@ -203,7 +241,7 @@ describe('', function () { expect(universityInput.disabled).to.be.false await fetchMock.flush(true) - fetchMock.reset() + resetFetchMock() // Select the university from dropdown await userEvent.click(universityInput) @@ -251,7 +289,7 @@ describe('', function () { render() await fetchMock.flush(true) - fetchMock.reset() + resetFetchMock() await userEvent.click( screen.getByRole('button', { @@ -290,7 +328,7 @@ describe('', function () { expect(universityInput.disabled).to.be.false await fetchMock.flush(true) - fetchMock.reset() + resetFetchMock() // Enter the university manually await userEvent.type(universityInput, newUniversity)