first commit
This commit is contained in:
@@ -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
|
@@ -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
|
@@ -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')}…
|
||||
</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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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')}…</> : 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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
}
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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')}…
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
@@ -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
|
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user