Просмотр исходного кода

[Settings] Link SSO email (#7778)

* [Settings] Link SSO email

GitOrigin-RevId: c46ed1709ceedd74df52e02a87f22614d024afea
Miguel Serrano 4 лет назад
Родитель
Сommit
23d1bfd11e

+ 4 - 0
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": "",

+ 10 - 1
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<string, InstitutionInfo>()
 

+ 52 - 0
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 (
+    <>
+      <p className="affiliations-table-label">
+        {institutionInfo.university.name}
+      </p>
+      <p>
+        <Trans
+          i18nKey="to_add_email_accounts_need_to_be_linked_2"
+          components={[<strong />]} // eslint-disable-line react/jsx-key
+          values={{ institutionName: institutionInfo.university.name }}
+        />
+      </p>
+      <p>
+        <Trans
+          i18nKey="doing_this_will_verify_affiliation_and_allow_log_in_2"
+          components={[<strong />]} // eslint-disable-line react/jsx-key
+          values={{ institutionName: institutionInfo.university.name }}
+        />{' '}
+        <a
+          href="/learn/how-to/Institutional_Login"
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          {t('find_out_more_about_institution_login')}.
+        </a>
+      </p>
+      <a
+        className="btn-sm btn btn-primary btn-link-accounts"
+        href={`${samlInitPath}?university_id=${institutionInfo.university.id}&auto=/user/settings&email=${email}`}
+      >
+        {t('link_accounts_and_add_email')}
+      </a>
+    </>
+  )
+}

+ 108 - 67
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<InstitutionInfo | null>(null)
   const [countryCode, setCountryCode] = useState<CountryCode | null>(null)
   const [universities, setUniversities] = useState<
     Partial<Record<CountryCode, University[]>>
@@ -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 (
     <div className="affiliations-table-row--highlighted">
       <Row>
@@ -160,83 +184,100 @@ function AddEmail() {
                 <AddEmailInput onChange={handleEmailChange} />
               </Cell>
             </Col>
-            <Col md={4}>
-              <Cell>
-                {isInstitutionFieldsVisible ? (
-                  <>
-                    <div className="form-group mb-2">
-                      <CountryInput
-                        id="new-email-country-input"
-                        setValue={setCountryCode}
-                      />
-                    </div>
-                    <div className="form-group mb-2">
-                      <DownshiftInput
-                        items={getUniversityItems()}
-                        inputValue={university}
-                        placeholder={t('university')}
-                        label={t('university')}
-                        setValue={setUniversity}
-                        disabled={!countryCode}
-                      />
-                    </div>
-                    {isUniversityDirty && (
+
+            {ssoAvailable && (
+              <Col md={8}>
+                <Cell>
+                  <AddEmailSSOLinkingInfo
+                    email={newEmail}
+                    institutionInfo={newEmailMatchedInstitution}
+                  />
+                </Cell>
+              </Col>
+            )}
+
+            {!ssoAvailable && (
+              <>
+                <Col md={4}>
+                  <Cell>
+                    {isInstitutionFieldsVisible ? (
                       <>
                         <div className="form-group mb-2">
-                          <DownshiftInput
-                            items={roles}
-                            inputValue={role}
-                            placeholder={t('role')}
-                            label={t('role')}
-                            setValue={setRole}
+                          <CountryInput
+                            id="new-email-country-input"
+                            setValue={setCountryCode}
                           />
                         </div>
-                        <div className="form-group mb-0">
+                        <div className="form-group mb-2">
                           <DownshiftInput
-                            items={departments}
-                            inputValue={department}
-                            placeholder={t('department')}
-                            label={t('department')}
-                            setValue={setDepartment}
+                            items={getUniversityItems()}
+                            inputValue={university}
+                            placeholder={t('university')}
+                            label={t('university')}
+                            setValue={setUniversity}
+                            disabled={!countryCode}
                           />
                         </div>
+                        {isUniversityDirty && (
+                          <>
+                            <div className="form-group mb-2">
+                              <DownshiftInput
+                                items={roles}
+                                inputValue={role}
+                                placeholder={t('role')}
+                                label={t('role')}
+                                setValue={setRole}
+                              />
+                            </div>
+                            <div className="form-group mb-0">
+                              <DownshiftInput
+                                items={departments}
+                                inputValue={department}
+                                placeholder={t('department')}
+                                label={t('department')}
+                                setValue={setDepartment}
+                              />
+                            </div>
+                          </>
+                        )}
                       </>
+                    ) : (
+                      <div className="mt-1">
+                        {t('is_email_affiliated')}
+                        <br />
+                        <Button
+                          className="btn-inline-link"
+                          onClick={handleShowInstitutionFields}
+                        >
+                          {t('let_us_know')}
+                        </Button>
+                      </div>
                     )}
-                  </>
-                ) : (
-                  <div className="mt-1">
-                    {t('is_email_affiliated')}
-                    <br />
+                  </Cell>
+                </Col>
+
+                <Col md={4}>
+                  <Cell className="text-md-right">
                     <Button
-                      className="btn-inline-link"
-                      onClick={handleShowInstitutionFields}
+                      bsSize="small"
+                      bsStyle="success"
+                      disabled={
+                        !isValidEmail(newEmail) || isLoading || state.isLoading
+                      }
+                      onClick={handleAddNewEmail}
                     >
-                      {t('let_us_know')}
+                      {t('add_new_email')}
                     </Button>
-                  </div>
-                )}
-              </Cell>
-            </Col>
-            <Col md={4}>
-              <Cell className="text-md-right">
-                <Button
-                  bsSize="small"
-                  bsStyle="success"
-                  disabled={
-                    !isValidEmail(newEmail) || isLoading || state.isLoading
-                  }
-                  onClick={handleAddNewEmail}
-                >
-                  {t('add_new_email')}
-                </Button>
-                {isError && (
-                  <div className="text-danger small">
-                    <Icon type="exclamation-triangle" fw />{' '}
-                    {t('error_performing_request')}
-                  </div>
-                )}
-              </Cell>
-            </Col>
+                    {isError && (
+                      <div className="text-danger small">
+                        <Icon type="exclamation-triangle" fw />{' '}
+                        {t('error_performing_request')}
+                      </div>
+                    )}
+                  </Cell>
+                </Col>
+              </>
+            )}
           </form>
         )}
       </Row>

+ 4 - 1
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 (

+ 15 - 0
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',
   })
 }

+ 2 - 0
services/web/locales/en.json

@@ -169,6 +169,7 @@
   "this_grants_access_to_features": "This grants you access to <b>__appName__</b> <b>__featureType__</b> features.",
   "this_grants_access_to_features_2": "This grants you access to <0>__appName__</0> <0>__featureType__</0> features.",
   "to_add_email_accounts_need_to_be_linked": "To add this email, your <b>__appName__</b> and <b>__institutionName__</b> accounts will need to be linked.",
+  "to_add_email_accounts_need_to_be_linked_2": "To add this email, your <0>__appName__</0> and <0>__institutionName__</0> accounts will need to be linked.",
   "tried_to_log_in_with_email": "You’ve tried to login with <b>__email__</b>.",
   "tried_to_register_with_email": "You’ve tried to register with <b>__email__</b>, which is already registered with <b>__appName__</b> 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 <b>__appName__</b> 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 <b>__institutionName__</b> and will allow you to log in to <b>__appName__</b> through your institution.",
+  "doing_this_will_verify_affiliation_and_allow_log_in_2": "Doing this will verify your affiliation with <0>__institutionName__</0> and will allow you to log in to <0>__appName__</0> through your institution.",
   "email_already_associated_with": "The <b>__email1__</b> email is already associated with the <b>__email2__</b> <b>__appName__</b> 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",

+ 46 - 8
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('<EmailsSection />', 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('<EmailsSection />', function () {
     render(<EmailsSection />)
 
     await fetchMock.flush(true)
-    fetchMock.reset()
+    resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailData])
       .post('/user/emails', 200)
@@ -125,7 +143,7 @@ describe('<EmailsSection />', function () {
     render(<EmailsSection />)
 
     await fetchMock.flush(true)
-    fetchMock.reset()
+    resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [])
       .post('/user/emails', 500)
@@ -158,13 +176,33 @@ describe('<EmailsSection />', 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(<EmailsSection />)
+
+    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(<EmailsSection />)
 
     await fetchMock.flush(true)
-    fetchMock.reset()
+    resetFetchMock()
 
     await userEvent.click(
       screen.getByRole('button', {
@@ -203,7 +241,7 @@ describe('<EmailsSection />', 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('<EmailsSection />', function () {
     render(<EmailsSection />)
 
     await fetchMock.flush(true)
-    fetchMock.reset()
+    resetFetchMock()
 
     await userEvent.click(
       screen.getByRole('button', {
@@ -290,7 +328,7 @@ describe('<EmailsSection />', function () {
     expect(universityInput.disabled).to.be.false
 
     await fetchMock.flush(true)
-    fetchMock.reset()
+    resetFetchMock()
 
     // Enter the university manually
     await userEvent.type(universityInput, newUniversity)