[Settings] Link SSO email (#7778)

* [Settings] Link SSO email

GitOrigin-RevId: c46ed1709ceedd74df52e02a87f22614d024afea
This commit is contained in:
Miguel Serrano 2022-04-27 18:13:17 +02:00 committed by Copybot
parent 8472ac6fc6
commit 23d1bfd11e
8 changed files with 241 additions and 77 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "Youve tried to login with <b>__email__</b>.",
"tried_to_register_with_email": "Youve 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",

View file

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