first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import MakePrimary from './actions/make-primary/make-primary'
import Remove from './actions/remove'
import useAsync from '../../../../shared/hooks/use-async'
import { useUserEmailsContext } from '../../context/user-email-context'
import { UserEmailData } from '../../../../../../types/user-email'
type ActionsProps = {
userEmailData: UserEmailData
primary?: UserEmailData
}
function Actions({ userEmailData, primary }: ActionsProps) {
const { t } = useTranslation()
const { setLoading: setUserEmailsContextLoading } = useUserEmailsContext()
const makePrimaryAsync = useAsync()
const deleteEmailAsync = useAsync()
useEffect(() => {
setUserEmailsContextLoading(
makePrimaryAsync.isLoading || deleteEmailAsync.isLoading
)
}, [
setUserEmailsContextLoading,
makePrimaryAsync.isLoading,
deleteEmailAsync.isLoading,
])
useEffect(() => {
if (makePrimaryAsync.isLoading && !deleteEmailAsync.isIdle) {
deleteEmailAsync.reset()
}
}, [makePrimaryAsync.isLoading, deleteEmailAsync])
useEffect(() => {
if (deleteEmailAsync.isLoading && !makePrimaryAsync.isIdle) {
makePrimaryAsync.reset()
}
}, [deleteEmailAsync.isLoading, makePrimaryAsync])
return (
<>
<MakePrimary
userEmailData={userEmailData}
primary={primary}
makePrimaryAsync={makePrimaryAsync}
/>{' '}
<Remove
userEmailData={userEmailData}
deleteEmailAsync={deleteEmailAsync}
/>
{(makePrimaryAsync.isError || deleteEmailAsync.isError) && (
<div className="text-danger small">
{t('generic_something_went_wrong')}
</div>
)}
</>
)
}
export default Actions

View File

@@ -0,0 +1,77 @@
import { useTranslation, Trans } from 'react-i18next'
import { MergeAndOverride } from '../../../../../../../../types/utils'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { type UserEmailData } from '../../../../../../../../types/user-email'
type ConfirmationModalProps = MergeAndOverride<
React.ComponentProps<typeof OLModal>,
{
email: string
isConfirmDisabled: boolean
onConfirm: () => void
onHide: () => void
primary?: UserEmailData
}
>
function ConfirmationModal({
email,
isConfirmDisabled,
show,
onConfirm,
onHide,
primary,
}: ConfirmationModalProps) {
const { t } = useTranslation()
return (
<OLModal show={show} onHide={onHide}>
<OLModalHeader closeButton>
<OLModalTitle>{t('confirm_primary_email_change')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="pb-0">
<p>
<Trans
i18nKey="do_you_want_to_change_your_primary_email_address_to"
components={{ b: <b /> }}
values={{ email }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<p>{t('log_in_with_primary_email_address')}</p>
{primary && !primary.confirmedAt && (
<p>
<Trans
i18nKey="this_will_remove_primary_email"
components={{ b: <b /> }}
values={{ email: primary.email }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onHide}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={isConfirmDisabled}
onClick={onConfirm}
>
{t('change_primary_email')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default ConfirmationModal

View File

@@ -0,0 +1,132 @@
import { useState } from 'react'
import PrimaryButton from './primary-button'
import { useTranslation } from 'react-i18next'
import {
inReconfirmNotificationPeriod,
institutionAlreadyLinked,
} from '../../../../utils/selectors'
import { postJSON } from '../../../../../../infrastructure/fetch-json'
import {
State,
useUserEmailsContext,
} from '../../../../context/user-email-context'
import { UserEmailData } from '../../../../../../../../types/user-email'
import { UseAsyncReturnType } from '../../../../../../shared/hooks/use-async'
import { ssoAvailableForInstitution } from '../../../../utils/sso'
import ConfirmationModal from './confirmation-modal'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
const getDescription = (
t: (s: string) => string,
state: State,
userEmailData: UserEmailData
) => {
if (inReconfirmNotificationPeriod(userEmailData)) {
return t('please_reconfirm_your_affiliation_before_making_this_primary')
}
if (userEmailData.confirmedAt) {
return t('make_email_primary_description')
}
const ssoAvailable = ssoAvailableForInstitution(
userEmailData.affiliation?.institution || null
)
if (!institutionAlreadyLinked(state, userEmailData) && ssoAvailable) {
return t('please_link_before_making_primary')
}
return t('please_confirm_your_email_before_making_it_default')
}
type MakePrimaryProps = {
userEmailData: UserEmailData
primary?: UserEmailData
makePrimaryAsync: UseAsyncReturnType
}
function MakePrimary({
userEmailData,
primary,
makePrimaryAsync,
}: MakePrimaryProps) {
const [show, setShow] = useState(false)
const { t } = useTranslation()
const { state, makePrimary, deleteEmail, resetLeaversSurveyExpiration } =
useUserEmailsContext()
const handleShowModal = () => setShow(true)
const handleHideModal = () => setShow(false)
const handleSetDefaultUserEmail = () => {
handleHideModal()
makePrimaryAsync
.runAsync(
// 'delete-unconfirmed-primary' is a temporary parameter here to keep backward compatibility.
// So users with the old version of the frontend don't get their primary email deleted unexpectedly.
// https://github.com/overleaf/internal/issues/23536
postJSON('/user/emails/default?delete-unconfirmed-primary', {
body: {
email: userEmailData.email,
},
})
)
.then(() => {
makePrimary(userEmailData.email)
if (primary && !primary.confirmedAt) {
deleteEmail(primary.email)
resetLeaversSurveyExpiration(primary)
}
})
.catch(() => {})
}
if (userEmailData.default) {
return null
}
const isConfirmDisabled = Boolean(
!userEmailData.confirmedAt ||
state.isLoading ||
inReconfirmNotificationPeriod(userEmailData)
)
return (
<>
{makePrimaryAsync.isLoading ? (
<PrimaryButton disabled isLoading={state.isLoading}>
{t('processing_uppercase')}&hellip;
</PrimaryButton>
) : (
<OLTooltip
id={`make-primary-${userEmailData.email}`}
description={getDescription(t, state, userEmailData)}
>
{/*
Disabled buttons don't work with tooltips, due to pointer-events: none,
so create a wrapper for the tooltip
*/}
<span>
<PrimaryButton
disabled={isConfirmDisabled}
onClick={handleShowModal}
>
{t('make_primary')}
</PrimaryButton>
</span>
</OLTooltip>
)}
<ConfirmationModal
email={userEmailData.email}
isConfirmDisabled={isConfirmDisabled}
primary={primary}
show={show}
onHide={handleHideModal}
onConfirm={handleSetDefaultUserEmail}
/>
</>
)
}
export default MakePrimary

View File

@@ -0,0 +1,22 @@
import OLButton, { OLButtonProps } from '@/features/ui/components/ol/ol-button'
function PrimaryButton({
children,
disabled,
isLoading,
onClick,
}: OLButtonProps) {
return (
<OLButton
size="sm"
disabled={disabled && !isLoading}
isLoading={isLoading}
onClick={onClick}
variant="secondary"
>
{children}
</OLButton>
)
}
export default PrimaryButton

View File

@@ -0,0 +1,89 @@
import { useTranslation } from 'react-i18next'
import { UserEmailData } from '../../../../../../../types/user-email'
import { useUserEmailsContext } from '../../../context/user-email-context'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton, {
OLIconButtonProps,
} from '@/features/ui/components/ol/ol-icon-button'
import getMeta from '@/utils/meta'
type DeleteButtonProps = Pick<
OLIconButtonProps,
'disabled' | 'isLoading' | 'onClick'
>
function DeleteButton({ disabled, isLoading, onClick }: DeleteButtonProps) {
const { t } = useTranslation()
return (
<OLIconButton
variant="danger"
disabled={disabled}
isLoading={isLoading}
size="sm"
onClick={onClick}
accessibilityLabel={t('remove') || ''}
icon="delete"
/>
)
}
type RemoveProps = {
userEmailData: UserEmailData
deleteEmailAsync: UseAsyncReturnType
}
function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) {
const { t } = useTranslation()
const { state, deleteEmail, resetLeaversSurveyExpiration } =
useUserEmailsContext()
const isManaged = getMeta('ol-isManagedAccount')
const getTooltipText = () => {
if (isManaged) {
return t('your_account_is_managed_by_your_group_admin')
}
return userEmailData.default
? t('please_change_primary_to_remove')
: t('remove')
}
const handleRemoveUserEmail = () => {
deleteEmailAsync
.runAsync(
postJSON('/user/emails/delete', {
body: {
email: userEmailData.email,
},
})
)
.then(() => {
deleteEmail(userEmailData.email)
resetLeaversSurveyExpiration(userEmailData)
})
.catch(() => {})
}
if (deleteEmailAsync.isLoading) {
return <DeleteButton isLoading />
}
return (
<OLTooltip
id={userEmailData.email}
description={getTooltipText()}
overlayProps={{ placement: userEmailData.default ? 'left' : 'top' }}
>
<span>
<DeleteButton
disabled={state.isLoading || userEmailData.default}
onClick={handleRemoveUserEmail}
/>
</span>
</OLTooltip>
)
}
export default Remove

View File

@@ -0,0 +1,257 @@
import { useState, useEffect } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import Cell from './cell'
import Layout from './add-email/layout'
import Input, { DomainInfo } from './add-email/input'
import AddAnotherEmailBtn from './add-email/add-another-email-btn'
import InstitutionFields from './add-email/institution-fields'
import SsoLinkingInfo from './add-email/sso-linking-info'
import AddNewEmailBtn from './add-email/add-new-email-btn'
import useAsync from '../../../../shared/hooks/use-async'
import { useUserEmailsContext } from '../../context/user-email-context'
import { ssoAvailableForDomain } from '../../utils/sso'
import { postJSON } from '../../../../infrastructure/fetch-json'
import { University } from '../../../../../../types/university'
import { CountryCode } from '../../data/countries-list'
import { isValidEmail } from '../../../../shared/utils/email'
import getMeta from '../../../../utils/meta'
import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
import OLCol from '@/features/ui/components/ol/ol-col'
import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
function AddEmail() {
const { t } = useTranslation()
const [isFormVisible, setIsFormVisible] = useState(
() => window.location.hash === '#add-email'
)
const [newEmail, setNewEmail] = useState('')
const [confirmationStep, setConfirmationStep] = useState(false)
const [newEmailMatchedDomain, setNewEmailMatchedDomain] =
useState<DomainInfo | null>(null)
const [countryCode, setCountryCode] = useState<CountryCode | null>(null)
const [universities, setUniversities] = useState<
Partial<Record<CountryCode, University[]>>
>({})
const [universityName, setUniversityName] = useState('')
const [role, setRole] = useState('')
const [department, setDepartment] = useState('')
const { isLoading, isError, error, runAsync } = useAsync()
const {
state,
setLoading: setUserEmailsContextLoading,
getEmails,
} = useUserEmailsContext()
const emailAddressLimit = getMeta('ol-emailAddressLimit') || 10
const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha()
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
const handleShowAddEmailForm = () => {
setIsFormVisible(true)
}
const handleEmailChange = (value: string, domain?: DomainInfo) => {
setNewEmail(value)
setNewEmailMatchedDomain(domain || null)
}
const getSelectedKnownUniversityId = (): number | undefined => {
if (countryCode) {
return universities[countryCode]?.find(
({ name }) => name === universityName
)?.id
}
return newEmailMatchedDomain?.university.id
}
const handleAddNewEmail = () => {
const selectedKnownUniversityId = getSelectedKnownUniversityId()
const knownUniversityData = selectedKnownUniversityId && {
university: {
id: selectedKnownUniversityId,
},
role,
department,
}
const unknownUniversityData = universityName &&
!selectedKnownUniversityId && {
university: {
name: universityName,
country_code: countryCode,
},
role,
department,
}
runAsync(
(async () => {
const token = await getReCaptchaToken()
await postJSON('/user/emails/secondary', {
body: {
email: newEmail,
...knownUniversityData,
...unknownUniversityData,
'g-recaptcha-response': token,
},
})
})()
)
.then(() => {
setConfirmationStep(true)
})
.catch(() => {})
}
if (confirmationStep) {
return (
<ConfirmEmailForm
confirmationEndpoint="/user/emails/confirm-secondary"
resendEndpoint="/user/emails/resend-secondary-confirmation"
flow="secondary"
email={newEmail}
onSuccessfulConfirmation={getEmails}
interstitial={false}
onCancel={() => {
setConfirmationStep(false)
setIsFormVisible(false)
}}
/>
)
}
if (!isFormVisible) {
return (
<Layout isError={isError} error={error}>
<OLCol lg={12}>
<Cell>
{state.data.emailCount >= emailAddressLimit ? (
<span className="small">
<Trans
i18nKey="email_limit_reached"
values={{
emailAddressLimit,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</span>
) : (
<AddAnotherEmailBtn onClick={handleShowAddEmailForm} />
)}
</Cell>
</OLCol>
</Layout>
)
}
const InputComponent = (
<>
<label htmlFor="affiliations-email" className="visually-hidden">
{t('email')}
</label>
<Input
onChange={handleEmailChange}
handleAddNewEmail={handleAddNewEmail}
/>
</>
)
const recaptchaConditions = (
<OLCol>
<Cell>
<div className="affiliations-table-cell-tabbed">
<RecaptchaConditions />
</div>
</Cell>
</OLCol>
)
if (!isValidEmail(newEmail)) {
return (
<form>
<Layout isError={isError} error={error}>
<ReCaptcha2 page="addEmail" recaptchaRef={recaptchaRef} />
<OLCol lg={8}>
<Cell>
{InputComponent}
<div className="affiliations-table-cell-tabbed">
<div>{t('start_by_adding_your_email')}</div>
</div>
</Cell>
</OLCol>
<OLCol lg={4}>
<Cell className="text-lg-end">
<AddNewEmailBtn email={newEmail} disabled />
</Cell>
</OLCol>
{recaptchaConditions}
</Layout>
</form>
)
}
const isSsoAvailableForDomain =
newEmailMatchedDomain && ssoAvailableForDomain(newEmailMatchedDomain)
return (
<form>
<Layout isError={isError} error={error}>
<ReCaptcha2 page="addEmail" recaptchaRef={recaptchaRef} />
<OLCol lg={8}>
<Cell>
{InputComponent}
{!isSsoAvailableForDomain ? (
<div className="affiliations-table-cell-tabbed">
<InstitutionFields
countryCode={countryCode}
setCountryCode={setCountryCode}
universities={universities}
setUniversities={setUniversities}
universityName={universityName}
setUniversityName={setUniversityName}
role={role}
setRole={setRole}
department={department}
setDepartment={setDepartment}
newEmailMatchedDomain={newEmailMatchedDomain}
/>
</div>
) : null}
</Cell>
</OLCol>
{!isSsoAvailableForDomain ? (
<OLCol lg={4}>
<Cell className="text-lg-end">
<AddNewEmailBtn
email={newEmail}
disabled={state.isLoading}
isLoading={isLoading}
onClick={handleAddNewEmail}
/>
</Cell>
</OLCol>
) : (
<OLCol lg={12}>
<Cell>
<div className="affiliations-table-cell-tabbed">
<SsoLinkingInfo
email={newEmail}
domainInfo={newEmailMatchedDomain as DomainInfo}
/>
</div>
</Cell>
</OLCol>
)}
{recaptchaConditions}
</Layout>
</form>
)
}
export default AddEmail

View File

@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import OLButton, { OLButtonProps } from '@/features/ui/components/ol/ol-button'
function AddAnotherEmailBtn({ onClick, ...props }: OLButtonProps) {
const { t } = useTranslation()
return (
<OLButton
variant="link"
onClick={onClick}
className="btn-inline-link"
{...props}
>
{t('add_another_email')}
</OLButton>
)
}
export default AddAnotherEmailBtn

View File

@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next'
import OLButton, { OLButtonProps } from '@/features/ui/components/ol/ol-button'
const isValidEmail = (email: string) => {
return Boolean(email)
}
type AddNewEmailColProps = {
email: string
} & OLButtonProps
function AddNewEmailBtn({
email,
disabled,
isLoading,
...props
}: AddNewEmailColProps) {
const { t } = useTranslation()
return (
<OLButton
variant="primary"
disabled={(disabled && !isLoading) || !isValidEmail(email)}
isLoading={isLoading}
{...props}
>
{t('add_new_email')}
</OLButton>
)
}
export default AddNewEmailBtn

View File

@@ -0,0 +1,114 @@
import { useState, forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useCombobox } from 'downshift'
import classnames from 'classnames'
import countries, { CountryCode } from '../../../data/countries-list'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
type CountryInputProps = {
setValue: React.Dispatch<React.SetStateAction<CountryCode | null>>
inputRef?: React.ForwardedRef<HTMLInputElement>
} & React.InputHTMLAttributes<HTMLInputElement>
const itemToString = (item: (typeof countries)[number] | null) =>
item?.name ?? ''
function Downshift({ setValue, inputRef }: CountryInputProps) {
const { t } = useTranslation()
const [inputItems, setInputItems] = useState(() => [...countries])
const [inputValue, setInputValue] = useState('')
const {
isOpen,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
getItemProps,
highlightedIndex,
openMenu,
selectedItem,
} = useCombobox({
inputValue,
items: inputItems,
itemToString,
onSelectedItemChange: ({ selectedItem }) => {
setValue(selectedItem?.code ?? null)
setInputValue(selectedItem?.name ?? '')
},
onInputValueChange: ({ inputValue = '' }) => {
setInputItems(
countries.filter(country =>
itemToString(country).toLowerCase().includes(inputValue.toLowerCase())
)
)
},
})
const shouldOpen = isOpen && inputItems.length
return (
<div className={classnames('dropdown', 'd-block')}>
<div {...getComboboxProps()}>
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<label {...getLabelProps()} className="visually-hidden">
{t('country')}
</label>
<OLFormControl
{...getInputProps({
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value)
},
onFocus: () => {
if (!isOpen) {
openMenu()
}
},
ref: inputRef,
})}
placeholder={t('country')}
/>
<i className="caret" />
</div>
<ul
{...getMenuProps()}
className={classnames('dropdown-menu', 'select-dropdown-menu', {
show: shouldOpen,
})}
>
{inputItems.map((item, index) => (
// eslint-disable-next-line jsx-a11y/role-supports-aria-props
<li
key={`${item.name}-${index}`}
{...getItemProps({ item, index })}
aria-selected={selectedItem?.name === item.name}
>
<DropdownItem
as="span"
role={undefined}
className={classnames({
active: selectedItem?.name === item.name,
'dropdown-item-highlighted': highlightedIndex === index,
})}
trailingIcon={
selectedItem?.name === item.name ? 'check' : undefined
}
>
{item.name}
</DropdownItem>
</li>
))}
</ul>
</div>
)
}
const CountryInput = forwardRef<
HTMLInputElement,
Omit<CountryInputProps, 'inputRef'>
>((props, ref) => <Downshift {...props} inputRef={ref} />)
CountryInput.displayName = 'CountryInput'
export default CountryInput

View File

@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import OLButton, { OLButtonProps } from '@/features/ui/components/ol/ol-button'
function EmailAffiliatedWithInstitution({ onClick, ...props }: OLButtonProps) {
const { t } = useTranslation()
return (
<div className="mt-1">
{t('is_email_affiliated')}
<OLButton
variant="link"
onClick={onClick}
className="btn-inline-link"
{...props}
>
{t('let_us_know')}
</OLButton>
</div>
)
}
export default EmailAffiliatedWithInstitution

View File

@@ -0,0 +1,186 @@
import {
ChangeEvent,
KeyboardEvent,
useCallback,
useEffect,
useState,
useRef,
} from 'react'
import { Nullable } from '../../../../../../../types/utils'
import { getJSON } from '../../../../../infrastructure/fetch-json'
import useAbortController from '../../../../../shared/hooks/use-abort-controller'
import domainBlocklist from '../../../domain-blocklist'
import { debugConsole } from '@/utils/debugging'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
const LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
function matchLocalAndDomain(emailHint: string) {
const match = emailHint.match(LOCAL_AND_DOMAIN_REGEX)
if (match) {
return { local: match[1], domain: match[2] }
} else {
return { local: null, domain: null }
}
}
export type DomainInfo = {
hostname: string
confirmed?: boolean
university: {
id: number
name: string
ssoEnabled?: boolean
ssoBeta?: boolean
}
}
let domainCache = new Map<string, DomainInfo>()
export function clearDomainCache() {
domainCache = new Map<string, DomainInfo>()
}
type InputProps = {
onChange: (value: string, domain?: DomainInfo) => void
handleAddNewEmail: () => void
}
function Input({ onChange, handleAddNewEmail }: InputProps) {
const { signal } = useAbortController()
const inputRef = useRef<HTMLInputElement>(null)
const [suggestion, setSuggestion] = useState<string | null>(null)
const [inputValue, setInputValue] = useState<string | null>(null)
const [matchedDomain, setMatchedDomain] = useState<DomainInfo | null>(null)
useEffect(() => {
inputRef.current?.focus()
}, [inputRef])
useEffect(() => {
if (inputValue == null) {
return
}
if (matchedDomain && inputValue.endsWith(matchedDomain.hostname)) {
onChange(inputValue, matchedDomain)
} else {
onChange(inputValue)
}
}, [onChange, inputValue, suggestion, matchedDomain])
const handleEmailChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const hint = event.target.value
setInputValue(hint)
const { local, domain } = matchLocalAndDomain(hint)
if (domain && !matchedDomain?.hostname.startsWith(domain)) {
setSuggestion(null)
}
if (!domain) {
return
}
if (domainCache.has(domain)) {
const cachedDomain = domainCache.get(domain) as DomainInfo
setSuggestion(`${local}@${cachedDomain.hostname}`)
setMatchedDomain(cachedDomain)
return
}
if (domainBlocklist.some(d => domain.endsWith(d))) {
return
}
const query = `?hostname=${domain}&limit=1`
getJSON<Nullable<DomainInfo[]>>(`/institutions/domains${query}`, {
signal,
})
.then(data => {
if (!(data && data[0])) {
return
}
if (domainBlocklist.some(d => data[0].hostname.endsWith(d))) {
return
}
const hostname = data[0]?.hostname
if (hostname) {
domainCache.set(domain, data[0])
setSuggestion(`${local}@${hostname}`)
setMatchedDomain(data[0])
} else {
setSuggestion(null)
setMatchedDomain(null)
}
})
.catch(error => {
debugConsole.error(error)
setSuggestion(null)
setMatchedDomain(null)
})
},
[signal, matchedDomain]
)
const handleKeyDownEvent = useCallback(
(event: KeyboardEvent) => {
const setInputValueAndResetSuggestion = () => {
setInputValue(suggestion)
setSuggestion(null)
}
if (event.key === 'Enter') {
event.preventDefault()
if (suggestion) {
setInputValueAndResetSuggestion()
return
}
if (!inputValue) {
return
}
const match = matchLocalAndDomain(inputValue)
if (match.local && match.domain) {
handleAddNewEmail()
}
}
if (event.key === 'Tab' && suggestion) {
event.preventDefault()
setInputValueAndResetSuggestion()
}
},
[inputValue, suggestion, handleAddNewEmail]
)
useEffect(() => {
if (!inputValue) {
setSuggestion(null)
} else if (suggestion && !suggestion.startsWith(inputValue)) {
setSuggestion(null)
}
}, [suggestion, inputValue])
return (
<div className="input-suggestions">
<OLFormControl
data-testid="affiliations-email-shadow"
readOnly
className="input-suggestions-shadow"
value={suggestion || ''}
/>
<OLFormControl
id="affiliations-email"
data-testid="affiliations-email"
className="input-suggestions-main"
type="email"
onChange={handleEmailChange}
onKeyDown={handleKeyDownEvent}
value={inputValue || ''}
placeholder="e.g. johndoe@mit.edu"
ref={inputRef}
/>
</div>
)
}
export default Input

View File

@@ -0,0 +1,200 @@
import { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import CountryInput from './country-input'
import DownshiftInput from '../downshift-input'
import EmailAffiliatedWithInstitution from './email-affiliated-with-institution'
import defaultRoles from '../../../data/roles'
import defaultDepartments from '../../../data/departments'
import { CountryCode } from '../../../data/countries-list'
import { University } from '../../../../../../../types/university'
import { DomainInfo } from './input'
import { getJSON } from '../../../../../infrastructure/fetch-json'
import useAsync from '../../../../../shared/hooks/use-async'
import UniversityName from './university-name'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
type InstitutionFieldsProps = {
countryCode: CountryCode | null
setCountryCode: React.Dispatch<React.SetStateAction<CountryCode | null>>
universities: Partial<Record<CountryCode, University[]>>
setUniversities: React.Dispatch<
React.SetStateAction<Partial<Record<CountryCode, University[]>>>
>
universityName: string
setUniversityName: React.Dispatch<React.SetStateAction<string>>
role: string
setRole: React.Dispatch<React.SetStateAction<string>>
department: string
setDepartment: React.Dispatch<React.SetStateAction<string>>
newEmailMatchedDomain: DomainInfo | null
}
function InstitutionFields({
countryCode,
setCountryCode,
universities,
setUniversities,
universityName,
setUniversityName,
role,
setRole,
department,
setDepartment,
newEmailMatchedDomain,
}: InstitutionFieldsProps) {
const { t } = useTranslation()
const countryRef = useRef<HTMLInputElement | null>(null)
const [departments, setDepartments] = useState<string[]>([
...defaultDepartments,
])
const [isInstitutionFieldsVisible, setIsInstitutionFieldsVisible] =
useState(false)
const [isUniversityDirty, setIsUniversityDirty] = useState(false)
const { runAsync: institutionRunAsync } = useAsync<University[]>()
useEffect(() => {
if (isInstitutionFieldsVisible && countryRef.current) {
countryRef.current?.focus()
}
}, [countryRef, isInstitutionFieldsVisible])
useEffect(() => {
if (universityName) {
setIsUniversityDirty(true)
}
}, [setIsUniversityDirty, universityName])
// If the institution selected by autocompletion has changed
// hide the fields visibility and reset values
useEffect(() => {
if (!newEmailMatchedDomain) {
setIsInstitutionFieldsVisible(false)
setRole('')
setDepartment('')
}
}, [newEmailMatchedDomain, setRole, setDepartment])
useEffect(() => {
const selectedKnownUniversity = countryCode
? universities[countryCode]?.find(({ name }) => name === universityName)
: undefined
if (selectedKnownUniversity && selectedKnownUniversity.departments.length) {
setDepartments(selectedKnownUniversity.departments)
} else {
setDepartments([...defaultDepartments])
}
}, [countryCode, universities, universityName])
// Fetch country institution
useEffect(() => {
// Skip if country not selected or universities for
// that country are already fetched
if (!countryCode || universities[countryCode]) {
return
}
institutionRunAsync(
getJSON(`/institutions/list?country_code=${countryCode}`)
)
.then(data => {
setUniversities(state => ({ ...state, [countryCode]: data }))
})
.catch(() => {})
}, [countryCode, universities, setUniversities, institutionRunAsync])
const getUniversityItems = () => {
if (!countryCode) {
return []
}
return (
universities[countryCode]
?.map(({ name }) => name)
.filter(name =>
name.trim().toLowerCase().includes(universityName.toLowerCase())
) ?? []
)
}
const handleShowInstitutionFields = () => {
setIsInstitutionFieldsVisible(true)
}
const handleSelectUniversityManually = () => {
setRole('')
setDepartment('')
handleShowInstitutionFields()
}
const isLetUsKnowVisible =
!newEmailMatchedDomain && !isInstitutionFieldsVisible
const isAutocompletedInstitutionVisible =
newEmailMatchedDomain && !isInstitutionFieldsVisible
const isRoleAndDepartmentVisible =
isAutocompletedInstitutionVisible || isUniversityDirty
// Is the email affiliated with an institution?
if (isLetUsKnowVisible) {
return (
<EmailAffiliatedWithInstitution onClick={handleShowInstitutionFields} />
)
}
return (
<>
{isAutocompletedInstitutionVisible ? (
// Display the institution name after autocompletion
<UniversityName
name={newEmailMatchedDomain.university.name}
onClick={handleSelectUniversityManually}
/>
) : (
// Display the country and university fields
<>
<OLFormGroup className="mb-2">
<CountryInput
id="new-email-country-input"
setValue={setCountryCode}
ref={countryRef}
/>
</OLFormGroup>
<OLFormGroup className={isRoleAndDepartmentVisible ? 'mb-2' : 'mb-0'}>
<DownshiftInput
items={getUniversityItems()}
inputValue={universityName}
placeholder={t('university')}
label={t('university')}
setValue={setUniversityName}
disabled={!countryCode}
/>
</OLFormGroup>
</>
)}
{isRoleAndDepartmentVisible && (
<>
<OLFormGroup className="mb-2">
<DownshiftInput
items={[...defaultRoles]}
inputValue={role}
placeholder={t('role')}
label={t('role')}
setValue={setRole}
/>
</OLFormGroup>
<OLFormGroup className="mb-0">
<DownshiftInput
items={departments}
inputValue={department}
placeholder={t('department')}
label={t('department')}
setValue={setDepartment}
/>
</OLFormGroup>
</>
)}
</>
)
}
export default InstitutionFields

View File

@@ -0,0 +1,26 @@
import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
import { getUserFacingMessage } from '../../../../../infrastructure/fetch-json'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLNotification from '@/features/ui/components/ol/ol-notification'
type LayoutProps = {
children: React.ReactNode
isError: UseAsyncReturnType['isError']
error: UseAsyncReturnType['error']
}
function Layout({ isError, error, children }: LayoutProps) {
return (
<div className="affiliations-table-row-highlighted">
<OLRow>{children}</OLRow>
{isError && (
<OLNotification
type="error"
content={getUserFacingMessage(error) ?? ''}
/>
)}
</div>
)
}
export default Layout

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { DomainInfo } from './input'
import getMeta from '../../../../../utils/meta'
import { useLocation } from '../../../../../shared/hooks/use-location'
import OLButton from '@/features/ui/components/ol/ol-button'
type SSOLinkingInfoProps = {
domainInfo: DomainInfo
email: string
}
function SsoLinkingInfo({ domainInfo, email }: SSOLinkingInfoProps) {
const { samlInitPath } = getMeta('ol-ExposedSettings')
const { t } = useTranslation()
const location = useLocation()
const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
useState(false)
function handleLinkAccountsButtonClick() {
setLinkAccountsButtonDisabled(true)
location.assign(
`${samlInitPath}?university_id=${domainInfo.university.id}&auto=/user/settings&email=${email}`
)
}
return (
<>
<p className="affiliations-table-label">{domainInfo.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: domainInfo.university.name }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<p>
<Trans
i18nKey="doing_this_will_verify_affiliation_and_allow_log_in_2"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ institutionName: domainInfo.university.name }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
<a
href="/learn/how-to/Institutional_Login"
target="_blank"
rel="noopener noreferrer"
>
{t('find_out_more_about_institution_login')}.
</a>
</p>
<OLButton
variant="primary"
className="btn-link-accounts"
size="sm"
disabled={linkAccountsButtonDisabled}
onClick={handleLinkAccountsButtonClick}
>
{t('link_accounts_and_add_email')}
</OLButton>
</>
)
}
export default SsoLinkingInfo

View File

@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
type UniversityNameProps = {
name: string
onClick: () => void
}
function UniversityName({ name, onClick }: UniversityNameProps) {
const { t } = useTranslation()
return (
<p>
{name}
<span className="small">
{' '}
<OLButton variant="link" onClick={onClick} className="btn-inline-link">
{t('change')}
</OLButton>
</span>
</p>
)
}
export default UniversityName

View File

@@ -0,0 +1,160 @@
import { Interstitial } from '@/shared/components/interstitial'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import OLButton from '@/features/ui/components/ol/ol-button'
import { Trans, useTranslation } from 'react-i18next'
import EmailInput from './add-email/input'
import { useState } from 'react'
import MaterialIcon from '@/shared/components/material-icon'
import { sendMB } from '@/infrastructure/event-tracking'
import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
import { postJSON } from '../../../../infrastructure/fetch-json'
import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
import getMeta from '@/utils/meta'
type AddSecondaryEmailError = {
name: string
data: any
}
export function AddSecondaryEmailPrompt() {
const isReady = useWaitForI18n()
const { t } = useTranslation()
const [email, setEmail] = useState<string>()
const [error, setError] = useState<AddSecondaryEmailError | undefined>()
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha()
if (!isReady) {
return null
}
const onEmailChange = (newEmail: string) => {
if (newEmail !== email) {
setEmail(newEmail)
setError(undefined)
}
}
const errorHandler = (err: any) => {
let errorName = 'generic_something_went_wrong'
if (err?.response?.status === 409) {
errorName = 'email_already_registered'
} else if (err?.response?.status === 429) {
errorName = 'too_many_attempts'
} else if (err?.response?.status === 422) {
errorName = 'email_must_be_linked_to_institution'
} else if (err?.data.errorReason === 'cannot_verify_user_not_robot') {
errorName = 'cannot_verify_user_not_robot'
}
setError({ name: errorName, data: err?.data })
sendMB('add-secondary-email-error', { errorName })
}
const handleSubmit = async (e?: React.FormEvent<HTMLFormElement>) => {
if (e) {
e.preventDefault()
}
setIsSubmitting(true)
const token = await getReCaptchaToken()
await postJSON('/user/emails/secondary', {
body: {
email,
'g-recaptcha-response': token,
},
})
.then(() => {
location.assign('/user/emails/confirm-secondary')
})
.catch(errorHandler)
.finally(() => {
setIsSubmitting(false)
})
}
return (
<>
<Interstitial showLogo title={t('add_a_recovery_email_address')}>
<form className="add-secondary-email" onSubmit={handleSubmit}>
<ReCaptcha2 page="addEmail" recaptchaRef={recaptchaRef} />
<p>{t('keep_your_account_safe_add_another_email')}</p>
<EmailInput
onChange={onEmailChange}
handleAddNewEmail={handleSubmit}
/>
<div aria-live="polite">
{error && <ErrorMessage error={error} />}
</div>
<OLButton disabled={isSubmitting} variant="primary" type="submit">
{isSubmitting ? <>{t('adding')}&hellip;</> : t('add_email_address')}
</OLButton>
<OLButton disabled={isSubmitting} variant="secondary" href="/project">
{t('not_now')}
</OLButton>
<p className="add-secondary-email-learn-more">
<Trans
i18nKey="learn_more_about_account"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/learn/how-to/Keeping_your_account_secure" />,
]}
/>
</p>
</form>
</Interstitial>
{!getMeta('ol-ExposedSettings').recaptchaDisabled?.addEmail && (
<div className="mt-5">
<RecaptchaConditions />
</div>
)}
</>
)
}
function ErrorMessage({ error }: { error: AddSecondaryEmailError }) {
const { t } = useTranslation()
let errorText
switch (error.name) {
case 'email_already_registered':
errorText = t('email_already_registered')
break
case 'too_many_attempts':
errorText = t('too_many_attempts')
break
case 'email_must_be_linked_to_institution':
errorText = (
<Trans
i18nKey="email_must_be_linked_to_institution"
values={{ institutionName: error?.data?.institutionName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
components={[<a href="/account/settings" />]}
/>
)
break
case 'cannot_verify_user_not_robot':
errorText = t('cannot_verify_user_not_robot')
break
default:
errorText = t('generic_something_went_wrong')
}
return (
<div className="add-secondary-email-error small text-danger">
<MaterialIcon className="icon" type="error" />
<div>{errorText}</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import classNames from 'classnames'
type CellProps = {
children: React.ReactNode
className?: string
}
function Cell({ children, className }: CellProps) {
return (
<div className={classNames('affiliations-table-cell', className)}>
{children}
</div>
)
}
export default Cell

View File

@@ -0,0 +1,309 @@
import { postJSON } from '@/infrastructure/fetch-json'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import Notification from '@/shared/components/notification'
import getMeta from '@/utils/meta'
import { FormEvent, MouseEventHandler, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import LoadingSpinner from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
import { sendMB } from '@/infrastructure/event-tracking'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
type Feedback = {
type: 'input' | 'alert'
style: 'error' | 'info'
message: string
}
type ConfirmEmailFormProps = {
confirmationEndpoint: string
flow: string
resendEndpoint: string
successMessage?: React.ReactNode
successButtonText?: string
email?: string
onSuccessfulConfirmation?: () => void
interstitial: boolean
isModal?: boolean
onCancel?: () => void
}
export function ConfirmEmailForm({
confirmationEndpoint,
flow,
resendEndpoint,
successMessage,
successButtonText,
email = getMeta('ol-email'),
onSuccessfulConfirmation,
interstitial,
isModal,
onCancel,
}: ConfirmEmailFormProps) {
const { t } = useTranslation()
const [confirmationCode, setConfirmationCode] = useState('')
const [feedback, setFeedback] = useState<Feedback | null>(null)
const [isConfirming, setIsConfirming] = useState(false)
const [isResending, setIsResending] = useState(false)
const [successRedirectPath, setSuccessRedirectPath] = useState('')
const { isReady } = useWaitForI18n()
const errorHandler = (err: any, actionType?: string) => {
let errorName = err?.data?.message?.key || 'generic_something_went_wrong'
if (err?.response?.status === 429) {
if (actionType === 'confirm') {
errorName = 'too_many_confirm_code_verification_attempts'
} else if (actionType === 'resend') {
errorName = 'too_many_confirm_code_resend_attempts'
}
setFeedback({
type: 'alert',
style: 'error',
message: errorName,
})
} else {
setFeedback({
type: 'input',
style: 'error',
message: errorName,
})
}
sendMB('email-verification-error', {
errorName,
flow,
})
}
const invalidFormHandler = () => {
if (!confirmationCode) {
return setFeedback({
type: 'input',
style: 'error',
message: 'please_enter_confirmation_code',
})
}
}
const submitHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsConfirming(true)
setFeedback(null)
sendMB('email-verification-click', {
button: 'verify',
flow,
})
try {
const data = await postJSON(confirmationEndpoint, {
body: { code: confirmationCode },
})
if (onSuccessfulConfirmation) {
onSuccessfulConfirmation()
} else {
setSuccessRedirectPath(data?.redir || '/')
}
} catch (err) {
errorHandler(err, 'confirm')
} finally {
setIsConfirming(false)
}
}
const resendHandler: MouseEventHandler<HTMLButtonElement> = () => {
setIsResending(true)
setFeedback(null)
postJSON(resendEndpoint)
.then(data => {
setIsResending(false)
if (data?.message?.key) {
setFeedback({
type: 'alert',
style: 'info',
message: data.message.key,
})
}
})
.catch(err => {
errorHandler(err, 'resend')
})
.finally(() => {
setIsResending(false)
})
sendMB('email-verification-click', {
button: 'resend',
flow,
})
}
const changeHandler = (e: FormEvent<HTMLInputElement>) => {
setConfirmationCode(e.currentTarget.value)
setFeedback(null)
}
if (!isReady) {
return <LoadingSpinner />
}
if (successRedirectPath && successButtonText && successMessage) {
return (
<ConfirmEmailSuccessfullForm
successMessage={successMessage}
successButtonText={successButtonText}
redirectTo={successRedirectPath}
/>
)
}
let intro = <h5 className="h5">{t('confirm_your_email')}</h5>
if (isModal) intro = <h5 className="h5">{t('we_sent_code')}</h5>
if (interstitial)
intro = (
<h1 className="h3 interstitial-header">{t('confirm_your_email')}</h1>
)
return (
<form
onSubmit={submitHandler}
onInvalid={invalidFormHandler}
className="confirm-email-form"
>
<div className="confirm-email-form-inner">
{feedback?.type === 'alert' && (
<Notification
ariaLive="polite"
className="confirm-email-alert"
type={feedback.style}
content={<ErrorMessage error={feedback.message} />}
/>
)}
{intro}
<OLFormLabel htmlFor="one-time-code">
{isModal
? t('enter_the_code', { email })
: t('enter_the_confirmation_code', { email })}
</OLFormLabel>
<input
id="one-time-code"
className="form-control"
placeholder={t('enter_6_digit_code')}
inputMode="numeric"
required
value={confirmationCode}
onChange={changeHandler}
data-ol-dirty={feedback ? 'true' : undefined}
maxLength={6}
autoComplete="one-time-code"
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
<div aria-live="polite">
{feedback?.type === 'input' && (
<div className="small text-danger">
<MaterialIcon className="icon" type="error" />
<div>
<ErrorMessage error={feedback.message} />
</div>
</div>
)}
</div>
<div className="form-actions">
<OLButton
disabled={isResending}
type="submit"
isLoading={isConfirming}
loadingLabel={t('confirming')}
>
{t('confirm')}
</OLButton>
<OLButton
variant="secondary"
disabled={isConfirming}
onClick={resendHandler}
isLoading={isResending}
loadingLabel={t('resending_confirmation_code')}
>
{t('resend_confirmation_code')}
</OLButton>
{onCancel && (
<OLButton
variant="danger-ghost"
disabled={isConfirming || isResending}
onClick={onCancel}
>
{t('cancel')}
</OLButton>
)}
</div>
</div>
</form>
)
}
function ConfirmEmailSuccessfullForm({
successMessage,
successButtonText,
redirectTo,
}: {
successMessage: React.ReactNode
successButtonText: string
redirectTo: string
}) {
const submitHandler = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
location.assign(redirectTo)
}
return (
<form onSubmit={submitHandler}>
<div aria-live="polite">{successMessage}</div>
<div className="form-actions">
<OLButton type="submit" variant="primary">
{successButtonText}
</OLButton>
</div>
</form>
)
}
function ErrorMessage({ error }: { error: string }) {
const { t } = useTranslation()
switch (error) {
case 'invalid_confirmation_code':
return <span>{t('invalid_confirmation_code')}</span>
case 'expired_confirmation_code':
return (
<Trans
i18nKey="expired_confirmation_code"
/* eslint-disable-next-line react/jsx-key */
components={[<strong />]}
/>
)
case 'email_already_registered':
return <span>{t('email_already_registered')}</span>
case 'too_many_confirm_code_resend_attempts':
return <span>{t('too_many_confirm_code_resend_attempts')}</span>
case 'too_many_confirm_code_verification_attempts':
return <span>{t('too_many_confirm_code_verification_attempts')}</span>
case 'we_sent_new_code':
return <span>{t('we_sent_new_code')}</span>
case 'please_enter_confirmation_code':
return <span>{t('please_enter_confirmation_code')}</span>
default:
return <span>{t('generic_something_went_wrong')}</span>
}
}

View File

@@ -0,0 +1,28 @@
import { ConfirmEmailForm } from './confirm-email-form'
import { useTranslation } from 'react-i18next'
import { Interstitial } from '@/shared/components/interstitial'
export default function ConfirmSecondaryEmailForm() {
const { t } = useTranslation()
const successMessage = (
<>
<h1 className="h3 interstitial-header">
{t('thanks_for_confirming_your_email_address')}
</h1>
</>
)
return (
<Interstitial className="confirm-email" showLogo>
<ConfirmEmailForm
successMessage={successMessage}
successButtonText={t('go_to_overleaf')}
confirmationEndpoint="/user/emails/confirm-secondary"
resendEndpoint="/user/emails/resend-secondary-confirmation"
flow="secondary"
interstitial
/>
</Interstitial>
)
}

View File

@@ -0,0 +1,154 @@
import { useState, useEffect, forwardRef } from 'react'
import { useCombobox } from 'downshift'
import classnames from 'classnames'
import { escapeRegExp } from 'lodash'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
type DownshiftInputProps = {
highlightMatches?: boolean
items: string[]
itemsTitle?: string
inputValue: string
label: string
setValue: (value: string) => void
inputRef?: React.ForwardedRef<HTMLInputElement>
showLabel?: boolean
showSuggestedText?: boolean
} & React.InputHTMLAttributes<HTMLInputElement>
const filterItemsByInputValue = (
items: DownshiftInputProps['items'],
inputValue: DownshiftInputProps['inputValue']
) => items.filter(item => item.toLowerCase().includes(inputValue.toLowerCase()))
function Downshift({
highlightMatches = false,
items,
itemsTitle,
inputValue,
placeholder,
label,
setValue,
disabled,
inputRef,
showLabel = false,
showSuggestedText = false,
}: DownshiftInputProps) {
const [inputItems, setInputItems] = useState(items)
useEffect(() => {
setInputItems(items)
}, [items])
const {
isOpen,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
getItemProps,
highlightedIndex,
openMenu,
selectedItem,
} = useCombobox({
inputValue,
items: inputItems,
initialSelectedItem: inputValue,
onSelectedItemChange: ({ selectedItem }) => {
setValue(selectedItem ?? '')
},
onInputValueChange: ({ inputValue = '' }) => {
setInputItems(filterItemsByInputValue(items, inputValue))
},
onStateChange: ({ type }) => {
if (type === useCombobox.stateChangeTypes.FunctionOpenMenu) {
setInputItems(filterItemsByInputValue(items, inputValue))
}
},
})
const highlightMatchedCharacters = (item: string, query: string) => {
if (!query || !highlightMatches) return item
const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi')
const parts = item.split(regex)
return parts.map((part, index) =>
regex.test(part) ? <strong key={`${part}-${index}`}>{part}</strong> : part
)
}
const shouldOpen = isOpen && inputItems.length
return (
<div className={classnames('dropdown', 'd-block')}>
<div {...getComboboxProps()}>
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<OLFormLabel
{...getLabelProps()}
className={showLabel ? '' : 'visually-hidden'}
>
{label}
</OLFormLabel>
<OLFormControl
{...getInputProps({
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value)
},
onFocus: () => {
if (!isOpen) {
openMenu()
}
},
ref: inputRef,
})}
placeholder={placeholder}
disabled={disabled}
/>
</div>
<ul
{...getMenuProps()}
className={classnames('dropdown-menu', 'select-dropdown-menu', {
show: shouldOpen,
})}
>
{showSuggestedText && inputItems.length && (
<li>
<DropdownItem as="span" role={undefined} disabled>
{itemsTitle}
</DropdownItem>
</li>
)}
{inputItems.map((item, index) => (
// eslint-disable-next-line jsx-a11y/role-supports-aria-props
<li
key={`${item}${index}`}
{...getItemProps({ item, index })}
aria-selected={selectedItem === item}
>
<DropdownItem
as="span"
role={undefined}
className={classnames({
active: selectedItem === item,
'dropdown-item-highlighted': highlightedIndex === index,
})}
trailingIcon={selectedItem === item ? 'check' : undefined}
>
{highlightMatchedCharacters(item, inputValue)}
</DropdownItem>
</li>
))}
</ul>
</div>
)
}
const DownshiftInput = forwardRef<
HTMLInputElement,
Omit<DownshiftInputProps, 'inputRef'>
>((props, ref) => <Downshift {...props} inputRef={ref} />)
DownshiftInput.displayName = 'DownshiftInput'
export default DownshiftInput

View File

@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next'
import { UserEmailData } from '../../../../../../types/user-email'
import { ssoAvailableForInstitution } from '../../utils/sso'
import OLBadge from '@/features/ui/components/ol/ol-badge'
import ResendConfirmationCodeModal from '@/features/settings/components/emails/resend-confirmation-code-modal'
type EmailProps = {
userEmailData: UserEmailData
}
function Email({ userEmailData }: EmailProps) {
const { t } = useTranslation()
const ssoAvailable = ssoAvailableForInstitution(
userEmailData.affiliation?.institution || null
)
const isPrimary = userEmailData.default
const isProfessional =
userEmailData.confirmedAt &&
userEmailData.affiliation?.institution.confirmed &&
userEmailData.affiliation.licence !== 'free'
const hasBadges = isPrimary || isProfessional
return (
<>
{userEmailData.email}
{!userEmailData.confirmedAt && (
<div className="small">
<strong>{t('unconfirmed')}.</strong>
<br />
{!ssoAvailable && (
<ResendConfirmationCodeModal email={userEmailData.email} />
)}
</div>
)}
{hasBadges && (
<div>
{isPrimary && (
<>
<OLBadge bg="info">Primary</OLBadge>{' '}
</>
)}
{isProfessional && (
<OLBadge bg="primary">{t('professional')}</OLBadge>
)}
</div>
)}
</>
)
}
export default Email

View File

@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import EmailCell from './cell'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import classnames from 'classnames'
function Header() {
const { t } = useTranslation()
return (
<>
<OLRow>
<OLCol lg={4} className="d-none d-sm-block">
<EmailCell>
<strong>{t('email')}</strong>
</EmailCell>
</OLCol>
<OLCol lg={8} className="d-none d-sm-block">
<EmailCell>
<strong>{t('institution_and_role')}</strong>
</EmailCell>
</OLCol>
</OLRow>
<div className={classnames('d-none d-sm-block', 'horizontal-divider')} />
<div className={classnames('d-none d-sm-block', 'horizontal-divider')} />
</>
)
}
export default Header

View File

@@ -0,0 +1,176 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { UserEmailData } from '../../../../../../types/user-email'
import { isChangingAffiliation } from '../../utils/selectors'
import { useUserEmailsContext } from '../../context/user-email-context'
import DownshiftInput from './downshift-input'
import useAsync from '../../../../shared/hooks/use-async'
import { getJSON, postJSON } from '../../../../infrastructure/fetch-json'
import defaultRoles from '../../data/roles'
import defaultDepartments from '../../data/departments'
import { University } from '../../../../../../types/university'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
type InstitutionAndRoleProps = {
userEmailData: UserEmailData
}
function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
const { t } = useTranslation()
const { isLoading, isError, runAsync } = useAsync()
const changeAffiliationAsync = useAsync<University>()
const { affiliation } = userEmailData
const {
state,
setLoading: setUserEmailsContextLoading,
setEmailAffiliationBeingEdited,
updateAffiliation,
} = useUserEmailsContext()
const [role, setRole] = useState(affiliation?.role || '')
const [department, setDepartment] = useState(affiliation?.department || '')
const [departments, setDepartments] = useState<string[]>(() => [
...defaultDepartments,
])
const roleRef = useRef<HTMLInputElement | null>(null)
const isChangingAffiliationInProgress = isChangingAffiliation(
state,
userEmailData.email
)
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
useEffect(() => {
if (isChangingAffiliationInProgress && roleRef.current) {
roleRef.current?.focus()
}
}, [roleRef, isChangingAffiliationInProgress])
const handleChangeAffiliation = () => {
setEmailAffiliationBeingEdited(userEmailData.email)
if (!affiliation?.institution.id) {
return
}
changeAffiliationAsync
.runAsync(getJSON(`/institutions/list/${affiliation.institution.id}`))
.then(data => {
if (data.departments.length) {
setDepartments(data.departments)
}
})
.catch(() => {
setDepartments([...defaultDepartments])
})
}
const handleCancelAffiliationChange = () => {
setEmailAffiliationBeingEdited(null)
setRole(affiliation?.role || '')
setDepartment(affiliation?.department || '')
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
runAsync(
postJSON('/user/emails/endorse', {
body: {
email: userEmailData.email,
role,
department,
},
})
)
.then(() => {
updateAffiliation(userEmailData.email, role, department)
})
.catch(() => {})
}
if (!affiliation?.institution) {
return null
}
return (
<>
<div>{affiliation.institution.name}</div>
{!isChangingAffiliationInProgress ? (
<div className="small">
{(affiliation.role || affiliation.department) && (
<>
{[affiliation.role, affiliation.department]
.filter(Boolean)
.join(', ')}
<br />
</>
)}
<OLButton
onClick={handleChangeAffiliation}
variant="link"
className="btn-inline-link"
>
{!affiliation.department && !affiliation.role
? t('add_role_and_department')
: t('change')}
</OLButton>
</div>
) : (
<div className="affiliation-change-container small">
<form onSubmit={handleSubmit}>
<OLFormGroup className="mb-2">
<DownshiftInput
items={[...defaultRoles]}
inputValue={role}
placeholder={t('role')}
label={t('role')}
setValue={setRole}
ref={roleRef}
/>
</OLFormGroup>
<OLFormGroup className="mb-2">
<DownshiftInput
items={departments}
inputValue={department}
placeholder={t('department')}
label={t('department')}
setValue={setDepartment}
/>
</OLFormGroup>
<OLButton
variant="primary"
type="submit"
disabled={!role || !department}
isLoading={isLoading}
loadingLabel={t('saving')}
>
{t('save_or_cancel-save')}
</OLButton>
{!isLoading && (
<>
<span className="mx-1">{t('save_or_cancel-or')}</span>
<OLButton
variant="link"
onClick={handleCancelAffiliationChange}
className="btn-inline-link"
>
{t('save_or_cancel-cancel')}
</OLButton>
</>
)}
</form>
</div>
)}
{isError && (
<div className="text-danger small">
{t('generic_something_went_wrong')}
</div>
)}
</>
)
}
export default InstitutionAndRole

View File

@@ -0,0 +1,174 @@
import { useState, useEffect, useLayoutEffect } from 'react'
import { UserEmailData } from '../../../../../../types/user-email'
import getMeta from '../../../../utils/meta'
import ReconfirmationInfoSuccess from './reconfirmation-info/reconfirmation-info-success'
import ReconfirmationInfoPromptText from './reconfirmation-info/reconfirmation-info-prompt-text'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import { useUserEmailsContext } from '@/features/settings/context/user-email-context'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { ssoAvailableForInstitution } from '@/features/settings/utils/sso'
import { Trans, useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { useLocation } from '@/shared/hooks/use-location'
import OLButton from '@/features/ui/components/ol/ol-button'
import LoadingSpinner from '@/shared/components/loading-spinner'
type ReconfirmationInfoProps = {
userEmailData: UserEmailData
}
function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
const reconfirmedViaSAML = getMeta('ol-reconfirmedViaSAML')
const { t } = useTranslation()
const { samlInitPath } = getMeta('ol-ExposedSettings')
const { error, isLoading, isError, isSuccess, runAsync } = useAsync()
const { state, setLoading: setUserEmailsContextLoading } =
useUserEmailsContext()
const [hasSent, setHasSent] = useState(false)
const [isPending, setIsPending] = useState(false)
const location = useLocation()
const ssoAvailable = Boolean(
ssoAvailableForInstitution(userEmailData.affiliation?.institution ?? null)
)
const handleRequestReconfirmation = () => {
if (userEmailData.affiliation?.institution && ssoAvailable) {
setIsPending(true)
location.assign(
`${samlInitPath}?university_id=${userEmailData.affiliation.institution.id}&reconfirm=/user/settings`
)
} else {
runAsync(
postJSON('/user/emails/send-reconfirmation', {
body: {
email: userEmailData.email,
},
})
).catch(debugConsole.error)
}
}
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
useLayoutEffect(() => {
if (isSuccess) {
setHasSent(true)
}
}, [isSuccess])
const rateLimited =
isError && error instanceof FetchError && error.response?.status === 429
if (!userEmailData.affiliation) {
return null
}
if (
userEmailData.samlProviderId &&
userEmailData.samlProviderId === reconfirmedViaSAML
) {
return (
<OLRow>
<OLCol lg={12}>
<OLNotification
type="info"
content={
<ReconfirmationInfoSuccess
institution={userEmailData.affiliation.institution}
/>
}
/>
</OLCol>
</OLRow>
)
}
if (userEmailData.affiliation.inReconfirmNotificationPeriod) {
return (
<OLRow>
<OLCol lg={12}>
<OLNotification
type="info"
content={
<>
{hasSent ? (
<Trans
i18nKey="please_check_your_inbox_to_confirm"
values={{
institutionName:
userEmailData.affiliation.institution.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
/>
) : (
<ReconfirmationInfoPromptText
institutionName={userEmailData.affiliation.institution.name}
primary={userEmailData.default}
/>
)}
<br />
{isError && (
<div className="text-danger">
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
)}
</>
}
action={
hasSent ? (
<>
{isLoading ? (
<>
<LoadingSpinner loadingText={`${t('sending')}`} />
</>
) : (
<OLButton
variant="link"
disabled={state.isLoading}
onClick={handleRequestReconfirmation}
className="btn-inline-link"
>
{t('resend_confirmation_email')}
</OLButton>
)}
</>
) : (
<OLButton
variant="secondary"
disabled={isPending}
isLoading={isLoading}
onClick={handleRequestReconfirmation}
>
{isLoading ? (
<>
<LoadingSpinner loadingText={`${t('sending')}`} />
</>
) : (
t('confirm_affiliation')
)}
</OLButton>
)
}
/>
</OLCol>
</OLRow>
)
}
return null
}
export default ReconfirmationInfo

View File

@@ -0,0 +1,48 @@
import { Trans, useTranslation } from 'react-i18next'
import { Institution } from '../../../../../../../types/institution'
type ReconfirmationInfoPromptTextProps = {
primary: boolean
institutionName: Institution['name']
}
function ReconfirmationInfoPromptText({
primary,
institutionName,
}: ReconfirmationInfoPromptTextProps) {
const { t } = useTranslation()
return (
<>
<Trans
i18nKey="are_you_still_at"
values={{
institutionName,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
/>{' '}
<Trans
i18nKey="please_reconfirm_institutional_email"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<span />]
}
/>{' '}
<a
href="/learn/how-to/Institutional_Email_Reconfirmation"
target="_blank"
>
{t('learn_more')}
</a>
<br />
{primary ? <i>{t('need_to_add_new_primary_before_remove')}</i> : null}
</>
)
}
export default ReconfirmationInfoPromptText

View File

@@ -0,0 +1,31 @@
import { Trans, useTranslation } from 'react-i18next'
import { Institution } from '../../../../../../../types/institution'
type ReconfirmationInfoSuccessProps = {
institution: Institution
className?: string
}
function ReconfirmationInfoSuccess({
institution,
className,
}: ReconfirmationInfoSuccessProps) {
const { t } = useTranslation()
return (
<div className={className}>
<div>
<Trans
i18nKey="your_affiliation_is_confirmed"
values={{ institutionName: institution.name }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>{' '}
{t('thank_you_exclamation')}
</div>
</div>
)
}
export default ReconfirmationInfoSuccess

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import useAsync from '../../../../shared/hooks/use-async'
import { UserEmailData } from '../../../../../../types/user-email'
import { useUserEmailsContext } from '../../context/user-email-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
type ResendConfirmationEmailButtonProps = {
email: UserEmailData['email']
}
function ResendConfirmationCodeModal({
email,
}: ResendConfirmationEmailButtonProps) {
const { t } = useTranslation()
const { error, isLoading, isError, runAsync } = useAsync()
const {
state,
setLoading: setUserEmailsContextLoading,
getEmails,
} = useUserEmailsContext()
const [modalVisible, setModalVisible] = useState(false)
// Update global isLoading prop
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
const handleResendConfirmationEmail = async () => {
await runAsync(
postJSON('/user/emails/send-confirmation-code', { body: { email } })
)
.then(() => setModalVisible(true))
.catch(() => {})
}
if (isLoading) {
return (
<>
<Icon type="refresh" spin fw /> {t('sending')}&hellip;
</>
)
}
const rateLimited =
error && error instanceof FetchError && error.response?.status === 429
return (
<>
{modalVisible && (
<OLModal
animation
show={modalVisible}
onHide={() => setModalVisible(false)}
id="action-project-modal"
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle>{t('confirm_your_email')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<ConfirmEmailForm
isModal
flow="resend"
interstitial={false}
resendEndpoint="/user/emails/resend-confirmation-code"
confirmationEndpoint="/user/emails/confirm-code"
email={email}
onSuccessfulConfirmation={() => {
getEmails()
setModalVisible(false)
}}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setModalVisible(false)}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</OLModal>
)}
<OLButton
variant="link"
disabled={state.isLoading || isLoading}
onClick={handleResendConfirmationEmail}
className="btn-inline-link"
>
{t('resend_confirmation_code')}
</OLButton>
<br />
{isError && (
<div className="text-danger">
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
)}
</>
)
}
export default ResendConfirmationCodeModal

View File

@@ -0,0 +1,168 @@
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { UserEmailData } from '../../../../../../types/user-email'
import Email from './email'
import InstitutionAndRole from './institution-and-role'
import EmailCell from './cell'
import Actions from './actions'
import { institutionAlreadyLinked } from '../../utils/selectors'
import { useUserEmailsContext } from '../../context/user-email-context'
import getMeta from '../../../../utils/meta'
import { ssoAvailableForInstitution } from '../../utils/sso'
import ReconfirmationInfo from './reconfirmation-info'
import { useLocation } from '../../../../shared/hooks/use-location'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLButton from '@/features/ui/components/ol/ol-button'
type EmailsRowProps = {
userEmailData: UserEmailData
primary?: UserEmailData
}
function EmailsRow({ userEmailData, primary }: EmailsRowProps) {
const hasSSOAffiliation = Boolean(
userEmailData.affiliation &&
ssoAvailableForInstitution(userEmailData.affiliation.institution)
)
return (
<>
<OLRow>
<OLCol lg={4}>
<EmailCell>
<Email userEmailData={userEmailData} />
</EmailCell>
</OLCol>
<OLCol lg={5}>
{userEmailData.affiliation?.institution && (
<EmailCell>
<InstitutionAndRole userEmailData={userEmailData} />
</EmailCell>
)}
</OLCol>
<OLCol lg={3}>
<EmailCell className="text-lg-end">
<Actions userEmailData={userEmailData} primary={primary} />
</EmailCell>
</OLCol>
</OLRow>
{hasSSOAffiliation && (
<SSOAffiliationInfo userEmailData={userEmailData} />
)}
<ReconfirmationInfo userEmailData={userEmailData} />
</>
)
}
type SSOAffiliationInfoProps = {
userEmailData: UserEmailData
}
function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
const { samlInitPath } = getMeta('ol-ExposedSettings')
const { t } = useTranslation()
const { state } = useUserEmailsContext()
const location = useLocation()
const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
useState(false)
function handleLinkAccountsButtonClick() {
setLinkAccountsButtonDisabled(true)
location.assign(
`${samlInitPath}?university_id=${userEmailData.affiliation?.institution?.id}&auto=/user/settings&email=${userEmailData.email}`
)
}
if (
!userEmailData.samlProviderId &&
institutionAlreadyLinked(state, userEmailData)
) {
// if the email is not linked to the institution, but there's another email already linked to that institution
// no SSO affiliation is displayed, since cannot have multiple emails linked to the same institution
return null
}
if (userEmailData.samlProviderId) {
return (
<OLRow>
<OLCol lg={{ span: 8, offset: 4 }}>
<EmailCell>
<p>
<Trans
i18nKey="acct_linked_to_institution_acct_2"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
values={{
institutionName: userEmailData.affiliation?.institution.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
</EmailCell>
</OLCol>
</OLRow>
)
}
return (
<OLRow>
<OLCol lg={{ span: 8, offset: 4 }}>
<div className="horizontal-divider" />
<OLRow>
<OLCol lg={9}>
<EmailCell>
<p className="small">
<Trans
i18nKey="can_link_your_institution_acct_2"
values={{
institutionName:
userEmailData.affiliation?.institution.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
/>
</p>
<p className="small">
<Trans
i18nKey="doing_this_allow_log_in_through_institution_2"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<strong />]
}
/>{' '}
<a href="/learn/how-to/Institutional_Login" target="_blank">
{t('find_out_more_about_institution_login')}
</a>
</p>
</EmailCell>
</OLCol>
<OLCol lg={3} className="text-lg-end">
<EmailCell>
<OLButton
variant="primary"
className="btn-link-accounts"
disabled={linkAccountsButtonDisabled}
onClick={handleLinkAccountsButtonClick}
size="sm"
>
{t('link_accounts')}
</OLButton>
</EmailCell>
</OLCol>
</OLRow>
</OLCol>
</OLRow>
)
}
export default EmailsRow

View File

@@ -0,0 +1,96 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export function SSOAlert() {
const { t } = useTranslation()
const institutionLinked = getMeta('ol-institutionLinked')
const institutionEmailNonCanonical = getMeta(
'ol-institutionEmailNonCanonical'
)
const samlError = getMeta('ol-samlError')
const [infoClosed, setInfoClosed] = useState(false)
const [warningClosed, setWarningClosed] = useState(false)
const [errorClosed, setErrorClosed] = useState(false)
const handleInfoClosed = () => setInfoClosed(true)
const handleWarningClosed = () => setWarningClosed(true)
const handleErrorClosed = () => setErrorClosed(true)
if (samlError) {
return !errorClosed ? (
<OLNotification
type="error"
content={
<>
{samlError.translatedMessage
? samlError.translatedMessage
: samlError.message}
{samlError.tryAgain && <p>{t('try_again')}</p>}
</>
}
isDismissible
onDismiss={handleErrorClosed}
/>
) : null
}
if (!institutionLinked) {
return null
}
return (
<>
{!infoClosed && (
<OLNotification
type="info"
content={
<>
<p>
<Trans
i18nKey="institution_acct_successfully_linked_2"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ institutionName: institutionLinked.universityName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
{institutionLinked.hasEntitlement && (
<p>
<Trans
i18nKey="this_grants_access_to_features_2"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ featureType: t('professional') }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
)}
</>
}
isDismissible
onDismiss={handleInfoClosed}
/>
)}
{!warningClosed && institutionEmailNonCanonical && (
<OLNotification
type="warning"
content={
<Trans
i18nKey="in_order_to_match_institutional_metadata_2"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ email: institutionEmailNonCanonical }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
isDismissible
onDismiss={handleWarningClosed}
/>
)}
</>
)
}