first commit
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getUserFacingMessage,
|
||||
postJSON,
|
||||
} from '../../../infrastructure/fetch-json'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import useAsync from '../../../shared/hooks/use-async'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import OLFormText from '@/features/ui/components/ol/ol-form-text'
|
||||
|
||||
function AccountInfoSection() {
|
||||
const { t } = useTranslation()
|
||||
const { hasAffiliationsFeature } = getMeta('ol-ExposedSettings')
|
||||
const isExternalAuthenticationSystemUsed = getMeta(
|
||||
'ol-isExternalAuthenticationSystemUsed'
|
||||
)
|
||||
const shouldAllowEditingDetails = getMeta('ol-shouldAllowEditingDetails')
|
||||
const {
|
||||
first_name: initialFirstName,
|
||||
last_name: initialLastName,
|
||||
email: initialEmail,
|
||||
} = useUserContext()
|
||||
|
||||
const [email, setEmail] = useState(initialEmail)
|
||||
const [firstName, setFirstName] = useState(initialFirstName)
|
||||
const [lastName, setLastName] = useState(initialLastName)
|
||||
const { isLoading, isSuccess, isError, error, runAsync } = useAsync()
|
||||
const [isFormValid, setIsFormValid] = useState(true)
|
||||
|
||||
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(event.target.value)
|
||||
setIsFormValid(event.target.validity.valid)
|
||||
}
|
||||
|
||||
const handleFirstNameChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setFirstName(event.target.value)
|
||||
}
|
||||
|
||||
const handleLastNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLastName(event.target.value)
|
||||
}
|
||||
|
||||
const canUpdateEmail =
|
||||
!hasAffiliationsFeature && !isExternalAuthenticationSystemUsed
|
||||
const canUpdateNames = shouldAllowEditingDetails
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!isFormValid) {
|
||||
return
|
||||
}
|
||||
runAsync(
|
||||
postJSON('/user/settings', {
|
||||
body: {
|
||||
email: canUpdateEmail ? email : undefined,
|
||||
first_name: canUpdateNames ? firstName : undefined,
|
||||
last_name: canUpdateNames ? lastName : undefined,
|
||||
},
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('update_account_info')}</h3>
|
||||
<form id="account-info-form" onSubmit={handleSubmit}>
|
||||
{hasAffiliationsFeature ? null : (
|
||||
<ReadOrWriteFormGroup
|
||||
id="email-input"
|
||||
type="email"
|
||||
label={t('email')}
|
||||
value={email}
|
||||
handleChange={handleEmailChange}
|
||||
canEdit={canUpdateEmail}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<ReadOrWriteFormGroup
|
||||
id="first-name-input"
|
||||
type="text"
|
||||
label={t('first_name')}
|
||||
value={firstName}
|
||||
maxLength={255}
|
||||
handleChange={handleFirstNameChange}
|
||||
canEdit={canUpdateNames}
|
||||
required={false}
|
||||
/>
|
||||
<ReadOrWriteFormGroup
|
||||
id="last-name-input"
|
||||
type="text"
|
||||
label={t('last_name')}
|
||||
maxLength={255}
|
||||
value={lastName}
|
||||
handleChange={handleLastNameChange}
|
||||
canEdit={canUpdateNames}
|
||||
required={false}
|
||||
/>
|
||||
{isSuccess ? (
|
||||
<OLFormGroup>
|
||||
<OLNotification
|
||||
type="success"
|
||||
content={t('thanks_settings_updated')}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<OLFormGroup>
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={getUserFacingMessage(error) ?? ''}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
) : null}
|
||||
{canUpdateEmail || canUpdateNames ? (
|
||||
<OLFormGroup>
|
||||
<OLButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
form="account-info-form"
|
||||
disabled={!isFormValid}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('saving') + '…'}
|
||||
>
|
||||
{t('update')}
|
||||
</OLButton>
|
||||
</OLFormGroup>
|
||||
) : null}
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ReadOrWriteFormGroupProps = {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
value?: string
|
||||
handleChange: (event: any) => void
|
||||
canEdit: boolean
|
||||
maxLength?: number
|
||||
required: boolean
|
||||
}
|
||||
|
||||
function ReadOrWriteFormGroup({
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
value,
|
||||
handleChange,
|
||||
canEdit,
|
||||
maxLength,
|
||||
required,
|
||||
}: ReadOrWriteFormGroupProps) {
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
|
||||
const handleInvalid = (event: React.InvalidEvent<HTMLInputElement>) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleChangeAndValidity = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
handleChange(event)
|
||||
setValidationMessage(event.target.validationMessage)
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<OLFormGroup controlId={id}>
|
||||
<OLFormLabel>{label}</OLFormLabel>
|
||||
<OLFormControl type="text" readOnly value={value} />
|
||||
</OLFormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OLFormGroup controlId={id}>
|
||||
<OLFormLabel>{label}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type={type}
|
||||
required={required}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
data-ol-dirty={!!validationMessage}
|
||||
onChange={handleChangeAndValidity}
|
||||
onInvalid={handleInvalid}
|
||||
/>
|
||||
{validationMessage && (
|
||||
<OLFormText type="error">{validationMessage}</OLFormText>
|
||||
)}
|
||||
</OLFormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountInfoSection
|
@@ -0,0 +1,27 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
|
||||
function BetaProgramSection() {
|
||||
const { t } = useTranslation()
|
||||
const { betaProgram } = useUserContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('sharelatex_beta_program')}</h3>
|
||||
{betaProgram ? null : (
|
||||
<p className="small">
|
||||
{/* eslint-disable-next-line react/jsx-key */}
|
||||
<Trans i18nKey="beta_program_benefits" components={[<span />]} />
|
||||
</p>
|
||||
)}
|
||||
<p className="small">
|
||||
{betaProgram
|
||||
? t('beta_program_already_participating')
|
||||
: t('beta_program_not_participating')}
|
||||
</p>
|
||||
<a href="/beta/participate">{t('manage_beta_program_membership')}</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BetaProgramSection
|
@@ -0,0 +1,93 @@
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import {
|
||||
UserEmailsProvider,
|
||||
useUserEmailsContext,
|
||||
} from '../context/user-email-context'
|
||||
import EmailsHeader from './emails/header'
|
||||
import EmailsRow from './emails/row'
|
||||
import AddEmail from './emails/add-email'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import OLSpinner from '@/features/ui/components/ol/ol-spinner'
|
||||
import { LeaversSurveyAlert } from './leavers-survey-alert'
|
||||
|
||||
function EmailsSectionContent() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
state: { data: userEmailsData },
|
||||
isInitializing,
|
||||
isInitializingError,
|
||||
isInitializingSuccess,
|
||||
} = useUserEmailsContext()
|
||||
const userEmails = Object.values(userEmailsData.byId)
|
||||
const primary = userEmails.find(userEmail => userEmail.default)
|
||||
|
||||
// Only show the "add email" button if the user has permission to add a secondary email
|
||||
const hideAddSecondaryEmail = getMeta('ol-cannot-add-secondary-email')
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h3">{t('emails_and_affiliations_title')}</h2>
|
||||
<p className="small">{t('emails_and_affiliations_explanation')}</p>
|
||||
<p className="small">
|
||||
<Trans
|
||||
i18nKey="change_primary_email_address_instructions"
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a
|
||||
href="/learn/how-to/Managing_your_Overleaf_emails"
|
||||
target="_blank"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
<>
|
||||
<EmailsHeader />
|
||||
{isInitializing ? (
|
||||
<div className="affiliations-table-row-highlighted">
|
||||
<div className="affiliations-table-cell text-center">
|
||||
<OLSpinner size="sm" /> {t('loading')}...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{userEmails?.map(userEmail => (
|
||||
<Fragment key={userEmail.email}>
|
||||
<EmailsRow userEmailData={userEmail} primary={primary} />
|
||||
<div className="horizontal-divider" />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{isInitializingSuccess && <LeaversSurveyAlert />}
|
||||
{isInitializingSuccess && !hideAddSecondaryEmail && <AddEmail />}
|
||||
{isInitializingError && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={t('error_performing_request')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailsSection() {
|
||||
const { hasAffiliationsFeature } = getMeta('ol-ExposedSettings')
|
||||
if (!hasAffiliationsFeature) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserEmailsProvider>
|
||||
<EmailsSectionContent />
|
||||
</UserEmailsProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailsSection
|
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
|
||||
function LabsProgramSection() {
|
||||
const { t } = useTranslation()
|
||||
const { labsProgram } = useUserContext()
|
||||
|
||||
const labsStatusText = labsProgram
|
||||
? t('youre_a_member_of_overleaf_labs')
|
||||
: t('get_exclusive_access_to_labs')
|
||||
const labsRedirectText = labsProgram
|
||||
? t('view_labs_experiments')
|
||||
: t('join_overleaf_labs')
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('overleaf_labs')}</h3>
|
||||
|
||||
<p className="small">{labsStatusText}</p>
|
||||
|
||||
<a href="/labs/participate">{labsRedirectText}</a>
|
||||
<hr />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LabsProgramSection
|
@@ -0,0 +1,44 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LeaveModal from './leave/modal'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function LeaveSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsModalOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setIsModalOpen(true)
|
||||
}, [])
|
||||
|
||||
// Prevent managed users deleting their own accounts
|
||||
if (getMeta('ol-cannot-delete-own-account')) {
|
||||
return (
|
||||
<>
|
||||
{t('need_to_leave')} {t('contact_group_admin')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{t('need_to_leave')}{' '}
|
||||
<OLButton
|
||||
className="btn-inline-link"
|
||||
variant="danger"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
{t('delete_your_account')}
|
||||
</OLButton>
|
||||
<LeaveModal isOpen={isModalOpen} handleClose={handleClose} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaveSection
|
@@ -0,0 +1,109 @@
|
||||
import { useState, Dispatch, SetStateAction } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import LeaveModalForm, { LeaveModalFormProps } from './modal-form'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
const WRITEFULL_SUPPORT_EMAIL = 'support@writefull.com'
|
||||
|
||||
type LeaveModalContentProps = {
|
||||
handleHide: () => void
|
||||
inFlight: boolean
|
||||
setInFlight: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
function LeaveModalContentBlock({
|
||||
setInFlight,
|
||||
isFormValid,
|
||||
setIsFormValid,
|
||||
}: LeaveModalFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
||||
const hasPassword = getMeta('ol-hasPassword')
|
||||
|
||||
if (isOverleaf && !hasPassword) {
|
||||
return (
|
||||
<p>
|
||||
<b>
|
||||
<a href="/user/password/reset">{t('delete_acct_no_existing_pw')}</a>
|
||||
</b>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeaveModalForm
|
||||
setInFlight={setInFlight}
|
||||
isFormValid={isFormValid}
|
||||
setIsFormValid={setIsFormValid}
|
||||
/>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="to_delete_your_writefull_account"
|
||||
values={{ email: WRITEFULL_SUPPORT_EMAIL }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={{
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content
|
||||
a: <a href={`mailto:${WRITEFULL_SUPPORT_EMAIL}`} />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LeaveModalContent({
|
||||
handleHide,
|
||||
inFlight,
|
||||
setInFlight,
|
||||
}: LeaveModalContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isFormValid, setIsFormValid] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('delete_account')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="delete_account_warning_message_3"
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
<LeaveModalContentBlock
|
||||
setInFlight={setInFlight}
|
||||
isFormValid={isFormValid}
|
||||
setIsFormValid={setIsFormValid}
|
||||
/>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton disabled={inFlight} onClick={handleHide} variant="secondary">
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
|
||||
<OLButton
|
||||
form="leave-form"
|
||||
type="submit"
|
||||
variant="danger"
|
||||
disabled={inFlight || !isFormValid}
|
||||
>
|
||||
{inFlight ? <>{t('deleting')}…</> : t('delete')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaveModalContent
|
@@ -0,0 +1,51 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import { FetchError } from '../../../../infrastructure/fetch-json'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
type LeaveModalFormErrorProps = {
|
||||
error: FetchError
|
||||
}
|
||||
|
||||
function LeaveModalFormError({ error }: LeaveModalFormErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
||||
|
||||
let errorMessage
|
||||
let errorTip = null
|
||||
if (error.response?.status === 403) {
|
||||
errorMessage = t('email_or_password_wrong_try_again')
|
||||
if (isOverleaf) {
|
||||
errorTip = (
|
||||
<Trans
|
||||
i18nKey="user_deletion_password_reset_tip"
|
||||
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
|
||||
components={[<a href="/user/password/reset" />]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else if (error.data?.error === 'SubscriptionAdminDeletionError') {
|
||||
errorMessage = t('subscription_admins_cannot_be_deleted')
|
||||
} else {
|
||||
errorMessage = t('user_deletion_error')
|
||||
}
|
||||
|
||||
return (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
<>
|
||||
{errorMessage}
|
||||
{errorTip ? (
|
||||
<>
|
||||
<br />
|
||||
{errorTip}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaveModalFormError
|
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, Dispatch, SetStateAction } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { postJSON, FetchError } from '../../../../infrastructure/fetch-json'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import LeaveModalFormError from './modal-form-error'
|
||||
import { useLocation } from '../../../../shared/hooks/use-location'
|
||||
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
|
||||
|
||||
export type LeaveModalFormProps = {
|
||||
setInFlight: Dispatch<SetStateAction<boolean>>
|
||||
isFormValid: boolean
|
||||
setIsFormValid: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
function LeaveModalForm({
|
||||
setInFlight,
|
||||
isFormValid,
|
||||
setIsFormValid,
|
||||
}: LeaveModalFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const userDefaultEmail = getMeta('ol-usersEmail')!
|
||||
const location = useLocation()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmation, setConfirmation] = useState(false)
|
||||
const [error, setError] = useState<FetchError | null>(null)
|
||||
|
||||
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(event.target.value)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
|
||||
const handleConfirmationChange = () => {
|
||||
setConfirmation(prev => !prev)
|
||||
}
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!isFormValid) {
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setInFlight(true)
|
||||
postJSON('/user/delete', {
|
||||
body: {
|
||||
password,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
location.assign('/')
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => {
|
||||
setInFlight(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormValid(
|
||||
!!email &&
|
||||
email.toLowerCase() === userDefaultEmail.toLowerCase() &&
|
||||
password.length > 0 &&
|
||||
confirmation
|
||||
)
|
||||
}, [setIsFormValid, userDefaultEmail, email, password, confirmation])
|
||||
|
||||
return (
|
||||
<form id="leave-form" onSubmit={handleSubmit}>
|
||||
<OLFormGroup controlId="email-input">
|
||||
<OLFormLabel>{t('email')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="text"
|
||||
placeholder={t('email')}
|
||||
required
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLFormGroup controlId="password-input">
|
||||
<OLFormLabel>{t('password')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="password"
|
||||
placeholder={t('password')}
|
||||
required
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLFormCheckbox
|
||||
id="confirm-account-deletion"
|
||||
required
|
||||
checked={confirmation}
|
||||
onChange={handleConfirmationChange}
|
||||
label={
|
||||
<Trans
|
||||
i18nKey="delete_account_confirmation_label"
|
||||
components={[<i />]} // eslint-disable-line react/jsx-key
|
||||
values={{
|
||||
userDefaultEmail,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{error ? <LeaveModalFormError error={error} /> : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaveModalForm
|
@@ -0,0 +1,30 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import LeaveModalContent from './modal-content'
|
||||
import OLModal from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
type LeaveModalProps = {
|
||||
isOpen: boolean
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
function LeaveModal({ isOpen, handleClose }: LeaveModalProps) {
|
||||
const [inFlight, setInFlight] = useState(false)
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
if (!inFlight) {
|
||||
handleClose()
|
||||
}
|
||||
}, [handleClose, inFlight])
|
||||
|
||||
return (
|
||||
<OLModal animation show={isOpen} onHide={handleHide} id="leave-modal">
|
||||
<LeaveModalContent
|
||||
handleHide={handleHide}
|
||||
inFlight={inFlight}
|
||||
setInFlight={setInFlight}
|
||||
/>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaveModal
|
@@ -0,0 +1,71 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import usePersistedState from '../../../shared/hooks/use-persisted-state'
|
||||
import { useUserEmailsContext } from '../context/user-email-context'
|
||||
import { sendMB } from '../../../infrastructure/event-tracking'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
function sendMetrics(segmentation: 'view' | 'click' | 'close') {
|
||||
sendMB('institutional-leavers-survey-notification', { type: segmentation })
|
||||
}
|
||||
|
||||
export function LeaversSurveyAlert() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
showInstitutionalLeaversSurveyUntil,
|
||||
setShowInstitutionalLeaversSurveyUntil,
|
||||
} = useUserEmailsContext()
|
||||
|
||||
const [hide, setHide] = usePersistedState(
|
||||
'hideInstitutionalLeaversSurvey',
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
function handleDismiss() {
|
||||
setShowInstitutionalLeaversSurveyUntil(0)
|
||||
setHide(true)
|
||||
sendMetrics('close')
|
||||
}
|
||||
|
||||
function handleLinkClick() {
|
||||
sendMetrics('click')
|
||||
}
|
||||
|
||||
const shouldDisplay =
|
||||
!hide && Date.now() <= showInstitutionalLeaversSurveyUntil
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldDisplay) {
|
||||
sendMetrics('view')
|
||||
}
|
||||
}, [shouldDisplay])
|
||||
|
||||
if (!shouldDisplay) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<OLNotification
|
||||
type="info"
|
||||
content={
|
||||
<>
|
||||
<strong>{t('limited_offer')}</strong>
|
||||
{`: ${t('institutional_leavers_survey_notification')} `}
|
||||
<a
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLSfYdeeoY5p1d31r5iUx1jw0O-Gd66vcsBi_Ntu3lJRMjV2EJA/viewform"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
{t('take_short_survey')}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
isDismissible
|
||||
onDismiss={handleDismiss}
|
||||
className="mb-0"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -0,0 +1,246 @@
|
||||
import { ElementType } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||
import { useSSOContext, SSOSubscription } from '../context/sso-context'
|
||||
import { SSOLinkingWidget } from './linking/sso-widget'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
const availableIntegrationLinkingWidgets = importOverleafModules(
|
||||
'integrationLinkingWidgets'
|
||||
) as any[]
|
||||
const availableReferenceLinkingWidgets = importOverleafModules(
|
||||
'referenceLinkingWidgets'
|
||||
) as any[]
|
||||
const availableLangFeedbackLinkingWidgets = importOverleafModules(
|
||||
'langFeedbackLinkingWidgets'
|
||||
) as any[]
|
||||
|
||||
function LinkingSection() {
|
||||
useBroadcastUser()
|
||||
const { t } = useTranslation()
|
||||
const { subscriptions } = useSSOContext()
|
||||
const ssoErrorMessage = getMeta('ol-ssoErrorMessage')
|
||||
const cannotUseAi = getMeta('ol-cannot-use-ai')
|
||||
const projectSyncSuccessMessage = getMeta('ol-projectSyncSuccessMessage')
|
||||
|
||||
// hide linking widgets in CI
|
||||
const integrationLinkingWidgets = getMeta('ol-hideLinkingWidgets')
|
||||
? []
|
||||
: availableIntegrationLinkingWidgets
|
||||
const referenceLinkingWidgets = getMeta('ol-hideLinkingWidgets')
|
||||
? []
|
||||
: availableReferenceLinkingWidgets
|
||||
const langFeedbackLinkingWidgets = getMeta('ol-hideLinkingWidgets')
|
||||
? []
|
||||
: availableLangFeedbackLinkingWidgets
|
||||
|
||||
const oauth2ServerComponents = importOverleafModules('oauth2Server') as {
|
||||
import: { default: ElementType }
|
||||
path: string
|
||||
}[]
|
||||
|
||||
const renderSyncSection =
|
||||
getMeta('ol-isSaas') || getMeta('ol-gitBridgeEnabled')
|
||||
|
||||
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
|
||||
oauth2ServerComponents
|
||||
)
|
||||
|
||||
// since we only have Writefull here currently, we should hide the whole section if they cant use ai
|
||||
const haslangFeedbackLinkingWidgets =
|
||||
langFeedbackLinkingWidgets.length && !cannotUseAi
|
||||
const hasIntegrationLinkingSection =
|
||||
renderSyncSection && allIntegrationLinkingWidgets.length
|
||||
const hasReferencesLinkingSection = referenceLinkingWidgets.length
|
||||
|
||||
// Filter out SSO providers that are not allowed to be linked by
|
||||
// managed users. Allow unlinking them if they are already linked.
|
||||
const hideGoogleSSO = getMeta('ol-cannot-link-google-sso')
|
||||
const hideOtherThirdPartySSO = getMeta('ol-cannot-link-other-third-party-sso')
|
||||
|
||||
for (const providerId in subscriptions) {
|
||||
const isLinked = subscriptions[providerId].linked
|
||||
if (providerId === 'google') {
|
||||
if (hideGoogleSSO && !isLinked) {
|
||||
delete subscriptions[providerId]
|
||||
}
|
||||
} else {
|
||||
if (hideOtherThirdPartySSO && !isLinked) {
|
||||
delete subscriptions[providerId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasSSOLinkingSection = Object.keys(subscriptions).length > 0
|
||||
|
||||
if (
|
||||
!haslangFeedbackLinkingWidgets &&
|
||||
!hasIntegrationLinkingSection &&
|
||||
!hasReferencesLinkingSection &&
|
||||
!hasSSOLinkingSection
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 id="integrations">{t('integrations')}</h3>
|
||||
<p className="small">{t('linked_accounts_explained')}</p>
|
||||
{haslangFeedbackLinkingWidgets ? (
|
||||
<>
|
||||
<h3 id="language-feedback" className="text-capitalize">
|
||||
{t('ai_features')}
|
||||
</h3>
|
||||
<div className="settings-widgets-container">
|
||||
{langFeedbackLinkingWidgets.map(
|
||||
({ import: { default: widget }, path }, widgetIndex) => (
|
||||
<ModuleLinkingWidget
|
||||
key={path}
|
||||
ModuleComponent={widget}
|
||||
isLast={widgetIndex === langFeedbackLinkingWidgets.length - 1}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{hasIntegrationLinkingSection ? (
|
||||
<>
|
||||
<h3 id="project-sync" className="text-capitalize">
|
||||
{t('project_synchronisation')}
|
||||
</h3>
|
||||
{projectSyncSuccessMessage ? (
|
||||
<OLNotification
|
||||
type="success"
|
||||
content={projectSyncSuccessMessage}
|
||||
/>
|
||||
) : null}
|
||||
<div className="settings-widgets-container">
|
||||
{allIntegrationLinkingWidgets.map(
|
||||
({ import: importObject, path }, widgetIndex) => (
|
||||
<ModuleLinkingWidget
|
||||
key={Object.keys(importObject)[0]}
|
||||
ModuleComponent={Object.values(importObject)[0]}
|
||||
isLast={
|
||||
widgetIndex === allIntegrationLinkingWidgets.length - 1
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{hasReferencesLinkingSection ? (
|
||||
<>
|
||||
<h3 id="references" className="text-capitalize">
|
||||
{t('reference_managers')}
|
||||
</h3>
|
||||
<div className="settings-widgets-container">
|
||||
{referenceLinkingWidgets.map(
|
||||
({ import: importObject, path }, widgetIndex) => (
|
||||
<ModuleLinkingWidget
|
||||
key={Object.keys(importObject)[0]}
|
||||
ModuleComponent={Object.values(importObject)[0]}
|
||||
isLast={widgetIndex === referenceLinkingWidgets.length - 1}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{hasSSOLinkingSection ? (
|
||||
<>
|
||||
<h3 id="linked-accounts" className="text-capitalize">
|
||||
{t('linked_accounts')}
|
||||
</h3>
|
||||
{ssoErrorMessage ? (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={`${t('sso_link_error')}: ${ssoErrorMessage}`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="settings-widgets-container">
|
||||
{Object.values(subscriptions).map(
|
||||
(subscription, subscriptionIndex) => (
|
||||
<SSOLinkingWidgetContainer
|
||||
key={subscription.providerId}
|
||||
subscription={subscription}
|
||||
isLast={
|
||||
subscriptionIndex === Object.keys(subscriptions).length - 1
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{haslangFeedbackLinkingWidgets ||
|
||||
hasIntegrationLinkingSection ||
|
||||
hasReferencesLinkingSection ||
|
||||
hasSSOLinkingSection ? (
|
||||
<hr />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type LinkingWidgetProps = {
|
||||
ModuleComponent: any
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
function ModuleLinkingWidget({ ModuleComponent, isLast }: LinkingWidgetProps) {
|
||||
return (
|
||||
<>
|
||||
<ModuleComponent />
|
||||
{isLast ? null : <hr />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type SSOLinkingWidgetContainerProps = {
|
||||
subscription: SSOSubscription
|
||||
isLast: boolean
|
||||
}
|
||||
|
||||
function SSOLinkingWidgetContainer({
|
||||
subscription,
|
||||
isLast,
|
||||
}: SSOLinkingWidgetContainerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { unlink } = useSSOContext()
|
||||
|
||||
let description = ''
|
||||
switch (subscription.providerId) {
|
||||
case 'collabratec':
|
||||
description = t('linked_collabratec_description')
|
||||
break
|
||||
case 'google':
|
||||
description = `${t('login_with_service', {
|
||||
service: subscription.provider.name,
|
||||
})}.`
|
||||
break
|
||||
case 'orcid':
|
||||
description = t('oauth_orcid_description')
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SSOLinkingWidget
|
||||
providerId={subscription.providerId}
|
||||
title={subscription.provider.name}
|
||||
description={description}
|
||||
helpPath={subscription.provider.descriptionOptions?.link}
|
||||
linked={subscription.linked}
|
||||
linkPath={subscription.provider.linkPath}
|
||||
onUnlink={() => unlink(subscription.providerId)}
|
||||
/>
|
||||
{isLast ? null : <hr />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkingSection
|
@@ -0,0 +1,132 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import OLBadge from '@/features/ui/components/ol/ol-badge'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function trackUpgradeClick() {
|
||||
sendMB('settings-upgrade-click')
|
||||
}
|
||||
|
||||
type EnableWidgetProps = {
|
||||
logo: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
helpPath: string
|
||||
helpTextOverride?: string
|
||||
hasFeature?: boolean
|
||||
isPremiumFeature?: boolean
|
||||
statusIndicator?: ReactNode
|
||||
children?: ReactNode
|
||||
linked?: boolean
|
||||
handleLinkClick: () => void
|
||||
handleUnlinkClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function EnableWidget({
|
||||
logo,
|
||||
title,
|
||||
description,
|
||||
helpPath,
|
||||
helpTextOverride,
|
||||
hasFeature,
|
||||
isPremiumFeature,
|
||||
statusIndicator,
|
||||
linked,
|
||||
handleLinkClick,
|
||||
handleUnlinkClick,
|
||||
children,
|
||||
disabled,
|
||||
}: EnableWidgetProps) {
|
||||
const { t } = useTranslation()
|
||||
const helpText = helpTextOverride || t('learn_more')
|
||||
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
<div>{logo}</div>
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>{title}</h4>
|
||||
{!hasFeature && isPremiumFeature && (
|
||||
<OLBadge bg="info">{t('premium_feature')}</OLBadge>
|
||||
)}
|
||||
</div>
|
||||
<p className="small">
|
||||
{description}{' '}
|
||||
<a href={helpPath} target="_blank" rel="noreferrer">
|
||||
{helpText}
|
||||
</a>
|
||||
</p>
|
||||
{children}
|
||||
{hasFeature && statusIndicator}
|
||||
</div>
|
||||
<div>
|
||||
<ActionButton
|
||||
hasFeature={hasFeature}
|
||||
linked={linked}
|
||||
handleUnlinkClick={handleUnlinkClick}
|
||||
handleLinkClick={handleLinkClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionButtonProps = {
|
||||
hasFeature?: boolean
|
||||
linked?: boolean
|
||||
handleUnlinkClick: () => void
|
||||
handleLinkClick: () => void
|
||||
disabled?: boolean
|
||||
linkText?: string
|
||||
unlinkText?: string
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
linked,
|
||||
handleUnlinkClick,
|
||||
handleLinkClick,
|
||||
hasFeature,
|
||||
disabled,
|
||||
linkText,
|
||||
unlinkText,
|
||||
}: ActionButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const linkingText = linkText || t('turn_on')
|
||||
const unlinkingText = unlinkText || t('turn_off')
|
||||
if (!hasFeature) {
|
||||
return (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
href="/user/subscription/plans"
|
||||
onClick={trackUpgradeClick}
|
||||
>
|
||||
<span className="text-capitalize">{t('upgrade')}</span>
|
||||
</OLButton>
|
||||
)
|
||||
} else if (linked) {
|
||||
return (
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={handleUnlinkClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{unlinkingText}
|
||||
</OLButton>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
{linkingText}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default EnableWidget
|
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useState, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLBadge from '@/features/ui/components/ol/ol-badge'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import { sendMB } from '../../../../infrastructure/event-tracking'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
function trackUpgradeClick(integration: string) {
|
||||
sendMB('settings-upgrade-click', { integration })
|
||||
}
|
||||
|
||||
function trackLinkingClick(integration: string) {
|
||||
sendMB('link-integration-click', { integration, location: 'Settings' })
|
||||
}
|
||||
|
||||
type IntegrationLinkingWidgetProps = {
|
||||
logo: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
helpPath: string
|
||||
hasFeature?: boolean
|
||||
statusIndicator?: ReactNode
|
||||
linked?: boolean
|
||||
linkPath: string
|
||||
unlinkPath: string
|
||||
unlinkConfirmationTitle: string
|
||||
unlinkConfirmationText: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function IntegrationLinkingWidget({
|
||||
logo,
|
||||
title,
|
||||
description,
|
||||
helpPath,
|
||||
hasFeature,
|
||||
statusIndicator,
|
||||
linked,
|
||||
linkPath,
|
||||
unlinkPath,
|
||||
unlinkConfirmationTitle,
|
||||
unlinkConfirmationText,
|
||||
disabled,
|
||||
}: IntegrationLinkingWidgetProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const handleUnlinkClick = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleModalHide = useCallback(() => {
|
||||
setShowModal(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
<div>{logo}</div>
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>{title}</h4>
|
||||
{!hasFeature && <OLBadge bg="info">{t('premium_feature')}</OLBadge>}
|
||||
</div>
|
||||
<p className="small">
|
||||
{description}{' '}
|
||||
<a href={helpPath} target="_blank" rel="noreferrer">
|
||||
{t('learn_more')}
|
||||
</a>
|
||||
</p>
|
||||
{hasFeature && statusIndicator}
|
||||
</div>
|
||||
<div>
|
||||
<ActionButton
|
||||
integration={title}
|
||||
hasFeature={hasFeature}
|
||||
linked={linked}
|
||||
handleUnlinkClick={handleUnlinkClick}
|
||||
linkPath={linkPath}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<UnlinkConfirmationModal
|
||||
integration={title}
|
||||
show={showModal}
|
||||
title={unlinkConfirmationTitle}
|
||||
content={unlinkConfirmationText}
|
||||
unlinkPath={unlinkPath}
|
||||
handleHide={handleModalHide}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionButtonProps = {
|
||||
integration: string
|
||||
hasFeature?: boolean
|
||||
linked?: boolean
|
||||
handleUnlinkClick: () => void
|
||||
linkPath: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
hasFeature,
|
||||
linked,
|
||||
handleUnlinkClick,
|
||||
linkPath,
|
||||
disabled,
|
||||
integration,
|
||||
}: ActionButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
if (!hasFeature) {
|
||||
return (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
href="/user/subscription/plans"
|
||||
onClick={() => trackUpgradeClick(integration)}
|
||||
>
|
||||
<span className="text-capitalize">{t('upgrade')}</span>
|
||||
</OLButton>
|
||||
)
|
||||
} else if (linked) {
|
||||
return (
|
||||
<OLButton
|
||||
variant="danger-ghost"
|
||||
onClick={handleUnlinkClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('unlink')}
|
||||
</OLButton>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{disabled ? (
|
||||
<OLButton disabled variant="secondary" className="text-capitalize">
|
||||
{t('link')}
|
||||
</OLButton>
|
||||
) : (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
href={linkPath}
|
||||
className="text-capitalize"
|
||||
onClick={() => trackLinkingClick(integration)}
|
||||
>
|
||||
{t('link')}
|
||||
</OLButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnlinkConfirmModalProps = {
|
||||
show: boolean
|
||||
title: string
|
||||
integration: string
|
||||
content: string
|
||||
unlinkPath: string
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
function UnlinkConfirmationModal({
|
||||
show,
|
||||
title,
|
||||
integration,
|
||||
content,
|
||||
unlinkPath,
|
||||
handleHide,
|
||||
}: UnlinkConfirmModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCancel = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
handleHide()
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
sendMB('unlink-integration-click', {
|
||||
integration,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={handleHide}>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{title}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>{content}</p>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<form action={unlinkPath} method="POST" className="form-inline">
|
||||
<input type="hidden" name="_csrf" value={getMeta('ol-csrfToken')} />
|
||||
<OLButton variant="secondary" onClick={handleCancel}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
type="submit"
|
||||
variant="danger-ghost"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{t('unlink')}
|
||||
</OLButton>
|
||||
</form>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FetchError } from '../../../../infrastructure/fetch-json'
|
||||
import IEEELogo from '../../../../shared/svgs/ieee-logo'
|
||||
import GoogleLogo from '../../../../shared/svgs/google-logo'
|
||||
import OrcidLogo from '../../../../shared/svgs/orcid-logo'
|
||||
import LinkingStatus from './status'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
const providerLogos: { readonly [p: string]: JSX.Element } = {
|
||||
collabratec: <IEEELogo />,
|
||||
google: <GoogleLogo />,
|
||||
orcid: <OrcidLogo />,
|
||||
}
|
||||
|
||||
type SSOLinkingWidgetProps = {
|
||||
providerId: string
|
||||
title: string
|
||||
description: string
|
||||
helpPath?: string
|
||||
linked?: boolean
|
||||
linkPath: string
|
||||
onUnlink: () => Promise<void>
|
||||
}
|
||||
|
||||
export function SSOLinkingWidget({
|
||||
providerId,
|
||||
title,
|
||||
description,
|
||||
helpPath,
|
||||
linked,
|
||||
linkPath,
|
||||
onUnlink,
|
||||
}: SSOLinkingWidgetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [unlinkRequestInflight, setUnlinkRequestInflight] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
const handleUnlinkClick = useCallback(() => {
|
||||
setShowModal(true)
|
||||
setErrorMessage('')
|
||||
}, [])
|
||||
|
||||
const handleUnlinkConfirmationClick = useCallback(() => {
|
||||
setShowModal(false)
|
||||
setUnlinkRequestInflight(true)
|
||||
onUnlink()
|
||||
.catch((error: FetchError) => {
|
||||
setErrorMessage(error.getUserFacingMessage())
|
||||
})
|
||||
.finally(() => {
|
||||
setUnlinkRequestInflight(false)
|
||||
})
|
||||
}, [onUnlink])
|
||||
|
||||
const handleModalHide = useCallback(() => {
|
||||
setShowModal(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-widget-container">
|
||||
<div>{providerLogos[providerId]}</div>
|
||||
<div className="description-container">
|
||||
<div className="title-row">
|
||||
<h4>{title}</h4>
|
||||
</div>
|
||||
<p className="small">
|
||||
{description?.replace(/<[^>]+>/g, '')}{' '}
|
||||
{helpPath ? (
|
||||
<a href={helpPath} target="_blank" rel="noreferrer">
|
||||
{t('learn_more')}
|
||||
</a>
|
||||
) : null}
|
||||
</p>
|
||||
{errorMessage ? (
|
||||
<LinkingStatus status="error" description={errorMessage} />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<ActionButton
|
||||
unlinkRequestInflight={unlinkRequestInflight}
|
||||
accountIsLinked={linked}
|
||||
linkPath={`${linkPath}?intent=link`}
|
||||
onUnlinkClick={handleUnlinkClick}
|
||||
/>
|
||||
</div>
|
||||
<UnlinkConfirmModal
|
||||
title={title}
|
||||
show={showModal}
|
||||
handleConfirmation={handleUnlinkConfirmationClick}
|
||||
handleHide={handleModalHide}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionButtonProps = {
|
||||
unlinkRequestInflight: boolean
|
||||
accountIsLinked?: boolean
|
||||
linkPath: string
|
||||
onUnlinkClick: () => void
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
unlinkRequestInflight,
|
||||
accountIsLinked,
|
||||
linkPath,
|
||||
onUnlinkClick,
|
||||
}: ActionButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
if (unlinkRequestInflight) {
|
||||
return (
|
||||
<OLButton variant="danger-ghost" disabled>
|
||||
{t('unlinking')}
|
||||
</OLButton>
|
||||
)
|
||||
} else if (accountIsLinked) {
|
||||
return (
|
||||
<OLButton variant="danger-ghost" onClick={onUnlinkClick}>
|
||||
{t('unlink')}
|
||||
</OLButton>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<OLButton variant="secondary" href={linkPath} className="text-capitalize">
|
||||
{t('link')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnlinkConfirmModalProps = {
|
||||
title: string
|
||||
show: boolean
|
||||
handleConfirmation: () => void
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
function UnlinkConfirmModal({
|
||||
title,
|
||||
show,
|
||||
handleConfirmation,
|
||||
handleHide,
|
||||
}: UnlinkConfirmModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={handleHide}>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>
|
||||
{t('unlink_provider_account_title', { provider: title })}
|
||||
</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>{t('unlink_provider_account_warning', { provider: title })}</p>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleHide}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton variant="danger-ghost" onClick={handleConfirmation}>
|
||||
{t('unlink')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
@@ -0,0 +1,57 @@
|
||||
import { ReactNode } from 'react'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
|
||||
type Status = 'pending' | 'success' | 'error'
|
||||
|
||||
type LinkingStatusProps = {
|
||||
status: Status
|
||||
description: string | ReactNode
|
||||
}
|
||||
|
||||
export default function LinkingStatus({
|
||||
status,
|
||||
description,
|
||||
}: LinkingStatusProps) {
|
||||
return (
|
||||
<span>
|
||||
<StatusIcon status={status} />
|
||||
<span className="small"> {description}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type StatusIconProps = {
|
||||
status: Status
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: StatusIconProps) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Icon
|
||||
type="check-circle"
|
||||
fw
|
||||
className="settings-widget-status-icon status-success"
|
||||
/>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<Icon
|
||||
type="times-circle"
|
||||
fw
|
||||
className="settings-widget-status-icon status-error"
|
||||
/>
|
||||
)
|
||||
case 'pending':
|
||||
return (
|
||||
<Icon
|
||||
type="circle"
|
||||
fw
|
||||
className="settings-widget-status-icon status-pending"
|
||||
spin
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import Notification from '@/shared/components/notification'
|
||||
|
||||
export default function ManagedAccountAlert() {
|
||||
const { t } = useTranslation()
|
||||
const isManaged = getMeta('ol-isManagedAccount')
|
||||
const currentManagedUserAdminEmail = getMeta(
|
||||
'ol-currentManagedUserAdminEmail'
|
||||
)
|
||||
|
||||
if (!isManaged) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Notification
|
||||
type="info"
|
||||
content={
|
||||
<>
|
||||
<div>
|
||||
<strong>
|
||||
{t('account_managed_by_group_administrator', {
|
||||
admin: currentManagedUserAdminEmail,
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
<Trans
|
||||
i18nKey="need_contact_group_admin_to_make_changes"
|
||||
components={[
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a
|
||||
href="/learn/how-to/Understanding_Managed_Overleaf_Accounts"
|
||||
target="_blank"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function NewsletterSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('newsletter')}</h3>
|
||||
<a href="/user/email-preferences">{t('manage_newsletter')}</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewsletterSection
|
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getUserFacingMessage,
|
||||
getErrorMessageKey,
|
||||
postJSON,
|
||||
} from '../../../infrastructure/fetch-json'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import useAsync from '../../../shared/hooks/use-async'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import OLFormText from '@/features/ui/components/ol/ol-form-text'
|
||||
|
||||
type PasswordUpdateResult = {
|
||||
message?: {
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
function PasswordSection() {
|
||||
const { t } = useTranslation()
|
||||
const hideChangePassword = getMeta('ol-cannot-change-password')
|
||||
return (
|
||||
<>
|
||||
<h3>{t('change_password')}</h3>
|
||||
{hideChangePassword ? (
|
||||
<CanOnlyLogInThroughSSO />
|
||||
) : (
|
||||
<PasswordInnerSection />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CanOnlyLogInThroughSSO() {
|
||||
return (
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="you_cant_add_or_change_password_due_to_sso"
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
|
||||
<a href="/learn/how-to/Logging_in_with_Group_single_sign-on" />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordInnerSection() {
|
||||
const { t } = useTranslation()
|
||||
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
||||
const isExternalAuthenticationSystemUsed = getMeta(
|
||||
'ol-isExternalAuthenticationSystemUsed'
|
||||
)
|
||||
const hasPassword = getMeta('ol-hasPassword')
|
||||
|
||||
if (isExternalAuthenticationSystemUsed && !isOverleaf) {
|
||||
return <p>{t('password_managed_externally')}</p>
|
||||
}
|
||||
|
||||
if (!hasPassword) {
|
||||
return (
|
||||
<p>
|
||||
<a href="/user/password/reset" target="_blank">
|
||||
{t('no_existing_password')}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return <PasswordForm />
|
||||
}
|
||||
|
||||
function PasswordForm() {
|
||||
const { t } = useTranslation()
|
||||
const passwordStrengthOptions = getMeta('ol-passwordStrengthOptions')
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword1, setNewPassword1] = useState('')
|
||||
const [newPassword2, setNewPassword2] = useState('')
|
||||
const { isLoading, isSuccess, isError, data, error, runAsync } =
|
||||
useAsync<PasswordUpdateResult>()
|
||||
const [isNewPasswordValid, setIsNewPasswordValid] = useState(false)
|
||||
const [isFormValid, setIsFormValid] = useState(false)
|
||||
|
||||
const handleCurrentPasswordChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setCurrentPassword(event.target.value)
|
||||
}
|
||||
|
||||
const handleNewPassword1Change = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setNewPassword1(event.target.value)
|
||||
setIsNewPasswordValid(event.target.validity.valid)
|
||||
}
|
||||
|
||||
const handleNewPassword2Change = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setNewPassword2(event.target.value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormValid(
|
||||
!!currentPassword && isNewPasswordValid && newPassword1 === newPassword2
|
||||
)
|
||||
}, [currentPassword, newPassword1, newPassword2, isNewPasswordValid])
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!isFormValid) {
|
||||
return
|
||||
}
|
||||
runAsync(
|
||||
postJSON('/user/password/update', {
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword1,
|
||||
newPassword2,
|
||||
},
|
||||
})
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="password-change-form" onSubmit={handleSubmit}>
|
||||
<PasswordFormGroup
|
||||
id="current-password-input"
|
||||
label={t('current_password')}
|
||||
value={currentPassword}
|
||||
handleChange={handleCurrentPasswordChange}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<PasswordFormGroup
|
||||
id="new-password-1-input"
|
||||
label={t('new_password')}
|
||||
value={newPassword1}
|
||||
handleChange={handleNewPassword1Change}
|
||||
minLength={passwordStrengthOptions?.length?.min || 8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordFormGroup
|
||||
id="new-password-2-input"
|
||||
label={t('confirm_new_password')}
|
||||
value={newPassword2}
|
||||
handleChange={handleNewPassword2Change}
|
||||
validationMessage={
|
||||
newPassword1 !== newPassword2 ? t('doesnt_match') : ''
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{isSuccess && data?.message?.text ? (
|
||||
<OLFormGroup>
|
||||
<OLNotification type="success" content={data.message.text} />
|
||||
</OLFormGroup>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<OLFormGroup>
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
getErrorMessageKey(error) === 'password-must-be-strong' ? (
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="password_was_detected_on_a_public_list_of_known_compromised_passwords"
|
||||
components={[
|
||||
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
|
||||
<a
|
||||
href="https://haveibeenpwned.com/passwords"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
. {t('use_a_different_password')}.
|
||||
</>
|
||||
) : getErrorMessageKey(error) === 'password-contains-email' ? (
|
||||
<>
|
||||
{t('invalid_password_contains_email')}.{' '}
|
||||
{t('use_a_different_password')}.
|
||||
</>
|
||||
) : getErrorMessageKey(error) === 'password-too-similar' ? (
|
||||
<>
|
||||
{t('invalid_password_too_similar')}.{' '}
|
||||
{t('use_a_different_password')}.
|
||||
</>
|
||||
) : (
|
||||
(getUserFacingMessage(error) ?? '')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
) : null}
|
||||
<OLFormGroup>
|
||||
<OLButton
|
||||
form="password-change-form"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!isFormValid}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={`${t('saving')}…`}
|
||||
>
|
||||
{t('change')}
|
||||
</OLButton>
|
||||
</OLFormGroup>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
type PasswordFormGroupProps = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
minLength?: number
|
||||
validationMessage?: string
|
||||
autoComplete?: string
|
||||
}
|
||||
|
||||
function PasswordFormGroup({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
handleChange,
|
||||
minLength,
|
||||
validationMessage: parentValidationMessage,
|
||||
autoComplete,
|
||||
}: PasswordFormGroupProps) {
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
const [hadInteraction, setHadInteraction] = useState(false)
|
||||
|
||||
const handleInvalid = (event: React.InvalidEvent<HTMLInputElement>) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleChangeAndValidity = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
handleChange(event)
|
||||
setHadInteraction(true)
|
||||
setValidationMessage(event.target.validationMessage)
|
||||
}
|
||||
|
||||
const isInvalid = Boolean(
|
||||
hadInteraction && (parentValidationMessage || validationMessage)
|
||||
)
|
||||
|
||||
return (
|
||||
<OLFormGroup controlId={id}>
|
||||
<OLFormLabel>{label}</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="password"
|
||||
placeholder="*********"
|
||||
autoComplete={autoComplete}
|
||||
value={value}
|
||||
data-ol-dirty={!!validationMessage}
|
||||
onChange={handleChangeAndValidity}
|
||||
onInvalid={handleInvalid}
|
||||
required={hadInteraction}
|
||||
minLength={minLength}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
{isInvalid && (
|
||||
<OLFormText type="error">
|
||||
{parentValidationMessage || validationMessage}
|
||||
</OLFormText>
|
||||
)}
|
||||
</OLFormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordSection
|
100
services/web/frontend/js/features/settings/components/root.tsx
Normal file
100
services/web/frontend/js/features/settings/components/root.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import SecuritySection from '@/features/settings/components/security-section'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import EmailsSection from './emails-section'
|
||||
import AccountInfoSection from './account-info-section'
|
||||
import ManagedAccountAlert from './managed-account-alert'
|
||||
import PasswordSection from './password-section'
|
||||
import LinkingSection from './linking-section'
|
||||
import BetaProgramSection from './beta-program-section'
|
||||
import LabsProgramSection from './labs-program-section'
|
||||
import SessionsSection from './sessions-section'
|
||||
import NewsletterSection from './newsletter-section'
|
||||
import LeaveSection from './leave-section'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { UserProvider } from '../../../shared/context/user-context'
|
||||
import { SSOProvider } from '../context/sso-context'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
|
||||
import useScrollToIdOnLoad from '../../../shared/hooks/use-scroll-to-id-on-load'
|
||||
import { SSOAlert } from './emails/sso-alert'
|
||||
import OLRow from '@/features/ui/components/ol/ol-row'
|
||||
import OLCol from '@/features/ui/components/ol/ol-col'
|
||||
import OLPageContentCard from '@/features/ui/components/ol/ol-page-content-card'
|
||||
|
||||
function SettingsPageRoot() {
|
||||
const { isReady } = useWaitForI18n()
|
||||
useScrollToIdOnLoad()
|
||||
|
||||
useEffect(() => {
|
||||
eventTracking.sendMB('settings-view')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<OLRow>
|
||||
<OLCol xl={{ span: 10, offset: 1 }}>
|
||||
{isReady ? <SettingsPageContent /> : null}
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPageContent() {
|
||||
const { t } = useTranslation()
|
||||
const { isOverleaf, labsEnabled } = getMeta('ol-ExposedSettings')
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<OLPageContentCard>
|
||||
<div className="page-header">
|
||||
<h1>{t('account_settings')}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<ManagedAccountAlert />
|
||||
<EmailsSection />
|
||||
<SSOAlert />
|
||||
<OLRow>
|
||||
<OLCol lg={5}>
|
||||
<AccountInfoSection />
|
||||
</OLCol>
|
||||
<OLCol lg={{ span: 5, offset: 1 }}>
|
||||
<PasswordSection />
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
<hr />
|
||||
<SecuritySection />
|
||||
<SplitTestProvider>
|
||||
<SSOProvider>
|
||||
<LinkingSection />
|
||||
</SSOProvider>
|
||||
</SplitTestProvider>
|
||||
{isOverleaf ? (
|
||||
<>
|
||||
<BetaProgramSection />
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
{labsEnabled ? (
|
||||
<>
|
||||
<LabsProgramSection />
|
||||
</>
|
||||
) : null}
|
||||
<SessionsSection />
|
||||
{isOverleaf ? (
|
||||
<>
|
||||
<hr />
|
||||
<NewsletterSection />
|
||||
<hr />
|
||||
<LeaveSection />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</OLPageContentCard>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPageRoot
|
@@ -0,0 +1,103 @@
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { GroupSSOLinkingStatus } from '../../../../../types/subscription/sso'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function SecuritySection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const memberOfSSOEnabledGroups = getMeta('ol-memberOfSSOEnabledGroups') || []
|
||||
|
||||
return (
|
||||
<>
|
||||
{memberOfSSOEnabledGroups.length > 0 ? (
|
||||
<>
|
||||
<h3>{t('security')}</h3>
|
||||
{memberOfSSOEnabledGroups.map(
|
||||
({
|
||||
groupId,
|
||||
linked,
|
||||
groupName,
|
||||
adminEmail,
|
||||
}: GroupSSOLinkingStatus) => (
|
||||
<div key={groupId} className="security-row">
|
||||
<span className="icon">
|
||||
<MaterialIcon type="key" />
|
||||
</span>
|
||||
<div className="text">
|
||||
<span className="line-header">
|
||||
<b>{t('single_sign_on_sso')}</b>{' '}
|
||||
{linked ? (
|
||||
<span className="status-label status-label-configured">
|
||||
{t('active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="status-label status-label-ready">
|
||||
{t('ready_to_set_up')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
{linked ? (
|
||||
groupName ? (
|
||||
<Trans
|
||||
i18nKey="sso_user_explanation_enabled_with_group_name"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<b />]}
|
||||
values={{ groupName }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="sso_user_explanation_enabled_with_admin_email"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<b />]}
|
||||
values={{ adminEmail }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
)
|
||||
) : groupName ? (
|
||||
<Trans
|
||||
i18nKey="sso_user_explanation_ready_with_group_name"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<b />, <b />]}
|
||||
values={{ groupName, buttonText: t('set_up_sso') }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="sso_user_explanation_ready_with_admin_email"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<b />, <b />]}
|
||||
values={{ adminEmail, buttonText: t('set_up_sso') }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{linked ? null : (
|
||||
<div className="button-column">
|
||||
<OLButton
|
||||
variant="primary"
|
||||
href={`/subscription/${groupId}/sso_enrollment`}
|
||||
>
|
||||
{t('set_up_sso')}
|
||||
</OLButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SecuritySection
|
@@ -0,0 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function SessionsSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('sessions')}</h3>
|
||||
<a href="/user/sessions">{t('manage_sessions')}</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionsSection
|
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { postJSON } from '../../../infrastructure/fetch-json'
|
||||
import useIsMounted from '../../../shared/hooks/use-is-mounted'
|
||||
import { set, cloneDeep } from 'lodash'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import type { OAuthProvider } from '../../../../../types/oauth-providers'
|
||||
|
||||
export type SSOSubscription = {
|
||||
providerId: string
|
||||
provider: OAuthProvider
|
||||
linked: boolean
|
||||
}
|
||||
|
||||
type SSOContextValue = {
|
||||
subscriptions: Record<string, SSOSubscription>
|
||||
unlink: (id: string, signal?: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
export const SSOContext = createContext<SSOContextValue | undefined>(undefined)
|
||||
|
||||
type SSOProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SSOProvider({ children }: SSOProviderProps) {
|
||||
const isMounted = useIsMounted()
|
||||
const oauthProviders = getMeta('ol-oauthProviders') || {}
|
||||
const thirdPartyIds = getMeta('ol-thirdPartyIds')
|
||||
|
||||
const [subscriptions, setSubscriptions] = useState<
|
||||
Record<string, SSOSubscription>
|
||||
>(() => {
|
||||
const initialSubscriptions: Record<string, SSOSubscription> = {}
|
||||
for (const [id, provider] of Object.entries(oauthProviders)) {
|
||||
const linked = !!thirdPartyIds[id]
|
||||
if (!provider.hideWhenNotLinked || linked) {
|
||||
initialSubscriptions[id] = {
|
||||
providerId: id,
|
||||
provider,
|
||||
linked,
|
||||
}
|
||||
}
|
||||
}
|
||||
return initialSubscriptions
|
||||
})
|
||||
|
||||
const unlink = useCallback(
|
||||
(providerId: string, signal?: AbortSignal) => {
|
||||
if (!subscriptions[providerId].linked) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const body = {
|
||||
link: false,
|
||||
providerId,
|
||||
}
|
||||
|
||||
return postJSON('/user/oauth-unlink', { body, signal }).then(() => {
|
||||
if (isMounted.current) {
|
||||
setSubscriptions(subs =>
|
||||
set(cloneDeep(subs), `${providerId}.linked`, false)
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[isMounted, subscriptions]
|
||||
)
|
||||
|
||||
const value = useMemo<SSOContextValue>(
|
||||
() => ({
|
||||
subscriptions,
|
||||
unlink,
|
||||
}),
|
||||
[subscriptions, unlink]
|
||||
)
|
||||
|
||||
return <SSOContext.Provider value={value}>{children}</SSOContext.Provider>
|
||||
}
|
||||
|
||||
export function useSSOContext() {
|
||||
const context = useContext(SSOContext)
|
||||
if (!context) {
|
||||
throw new Error('SSOContext is only available inside SSOProvider')
|
||||
}
|
||||
return context
|
||||
}
|
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import useSafeDispatch from '../../../shared/hooks/use-safe-dispatch'
|
||||
import * as ActionCreators from '../utils/action-creators'
|
||||
import { UserEmailData } from '../../../../../types/user-email'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import { Affiliation } from '../../../../../types/affiliation'
|
||||
import { normalize, NormalizedObject } from '../../../utils/normalize'
|
||||
import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import useAsync from '../../../shared/hooks/use-async'
|
||||
import usePersistedState from '../../../shared/hooks/use-persisted-state'
|
||||
|
||||
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export enum Actions {
|
||||
SET_DATA = 'SET_DATA', // eslint-disable-line no-unused-vars
|
||||
SET_LOADING_STATE = 'SET_LOADING_STATE', // eslint-disable-line no-unused-vars
|
||||
MAKE_PRIMARY = 'MAKE_PRIMARY', // eslint-disable-line no-unused-vars
|
||||
DELETE_EMAIL = 'DELETE_EMAIL', // eslint-disable-line no-unused-vars
|
||||
SET_EMAIL_AFFILIATION_BEING_EDITED = 'SET_EMAIL_AFFILIATION_BEING_EDITED', // eslint-disable-line no-unused-vars
|
||||
UPDATE_AFFILIATION = 'UPDATE_AFFILIATION', // eslint-disable-line no-unused-vars
|
||||
}
|
||||
|
||||
export type ActionSetData = {
|
||||
type: Actions.SET_DATA
|
||||
payload: UserEmailData[]
|
||||
}
|
||||
|
||||
export type ActionSetLoading = {
|
||||
type: Actions.SET_LOADING_STATE
|
||||
payload: boolean
|
||||
}
|
||||
|
||||
export type ActionMakePrimary = {
|
||||
type: Actions.MAKE_PRIMARY
|
||||
payload: UserEmailData['email']
|
||||
}
|
||||
|
||||
export type ActionDeleteEmail = {
|
||||
type: Actions.DELETE_EMAIL
|
||||
payload: UserEmailData['email']
|
||||
}
|
||||
|
||||
export type ActionSetEmailAffiliationBeingEdited = {
|
||||
type: Actions.SET_EMAIL_AFFILIATION_BEING_EDITED
|
||||
payload: Nullable<UserEmailData['email']>
|
||||
}
|
||||
|
||||
export type ActionUpdateAffiliation = {
|
||||
type: Actions.UPDATE_AFFILIATION
|
||||
payload: {
|
||||
email: UserEmailData['email']
|
||||
role: Affiliation['role']
|
||||
department: Affiliation['department']
|
||||
}
|
||||
}
|
||||
|
||||
export type State = {
|
||||
isLoading: boolean
|
||||
data: {
|
||||
byId: NormalizedObject<UserEmailData>
|
||||
emailCount: number
|
||||
linkedInstitutionIds: NonNullable<UserEmailData['samlProviderId']>[]
|
||||
emailAffiliationBeingEdited: Nullable<UserEmailData['email']>
|
||||
}
|
||||
}
|
||||
|
||||
type Action =
|
||||
| ActionSetData
|
||||
| ActionSetLoading
|
||||
| ActionMakePrimary
|
||||
| ActionDeleteEmail
|
||||
| ActionSetEmailAffiliationBeingEdited
|
||||
| ActionUpdateAffiliation
|
||||
|
||||
const setData = (state: State, action: ActionSetData) => {
|
||||
const normalized = normalize<UserEmailData>(action.payload, {
|
||||
idAttribute: 'email',
|
||||
})
|
||||
const emailCount = action.payload.length
|
||||
const byId = normalized || {}
|
||||
const linkedInstitutionIds = action.payload
|
||||
.filter(email => Boolean(email.samlProviderId))
|
||||
.map(email => email.samlProviderId) as NonNullable<
|
||||
UserEmailData['samlProviderId']
|
||||
>[]
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...initialState.data,
|
||||
byId,
|
||||
emailCount,
|
||||
linkedInstitutionIds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const setLoadingAction = (state: State, action: ActionSetLoading) => ({
|
||||
...state,
|
||||
isLoading: action.payload,
|
||||
})
|
||||
|
||||
const makePrimaryAction = (state: State, action: ActionMakePrimary) => {
|
||||
if (!state.data.byId[action.payload]) {
|
||||
return state
|
||||
}
|
||||
const byId: State['data']['byId'] = {}
|
||||
for (const id of Object.keys(state.data.byId)) {
|
||||
byId[id] = {
|
||||
...state.data.byId[id],
|
||||
default: state.data.byId[id].email === action.payload,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
byId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEmailAction = (state: State, action: ActionDeleteEmail) => {
|
||||
const { [action.payload]: _, ...byId } = state.data.byId
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
emailCount: state.data.emailCount - 1,
|
||||
byId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const setEmailAffiliationBeingEditedAction = (
|
||||
state: State,
|
||||
action: ActionSetEmailAffiliationBeingEdited
|
||||
) => {
|
||||
if (action.payload && !state.data.byId[action.payload]) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
emailAffiliationBeingEdited: action.payload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const updateAffiliationAction = (
|
||||
state: State,
|
||||
action: ActionUpdateAffiliation
|
||||
) => {
|
||||
const { email, role, department } = action.payload
|
||||
|
||||
if (action.payload && !state.data.byId[email]) {
|
||||
return state
|
||||
}
|
||||
|
||||
const affiliation = state.data.byId[email].affiliation
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
byId: {
|
||||
...state.data.byId,
|
||||
[email]: {
|
||||
...state.data.byId[email],
|
||||
...(affiliation && {
|
||||
affiliation: {
|
||||
...affiliation,
|
||||
role,
|
||||
department,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
emailAffiliationBeingEdited: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
isLoading: false,
|
||||
data: {
|
||||
byId: {},
|
||||
emailCount: 0,
|
||||
linkedInstitutionIds: [],
|
||||
emailAffiliationBeingEdited: null,
|
||||
},
|
||||
}
|
||||
|
||||
const reducer = (state: State, action: Action) => {
|
||||
switch (action.type) {
|
||||
case Actions.SET_DATA:
|
||||
return setData(state, action)
|
||||
case Actions.SET_LOADING_STATE:
|
||||
return setLoadingAction(state, action)
|
||||
case Actions.MAKE_PRIMARY:
|
||||
return makePrimaryAction(state, action)
|
||||
case Actions.DELETE_EMAIL:
|
||||
return deleteEmailAction(state, action)
|
||||
case Actions.SET_EMAIL_AFFILIATION_BEING_EDITED:
|
||||
return setEmailAffiliationBeingEditedAction(state, action)
|
||||
case Actions.UPDATE_AFFILIATION:
|
||||
return updateAffiliationAction(state, action)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function useUserEmails() {
|
||||
const [
|
||||
showInstitutionalLeaversSurveyUntil,
|
||||
setShowInstitutionalLeaversSurveyUntil,
|
||||
] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, true)
|
||||
const [state, unsafeDispatch] = useReducer(reducer, initialState)
|
||||
const dispatch = useSafeDispatch(unsafeDispatch)
|
||||
const { data, isLoading, isError, isSuccess, runAsync } =
|
||||
useAsync<UserEmailData[]>()
|
||||
|
||||
const getEmails = useCallback(() => {
|
||||
dispatch(ActionCreators.setLoading(true))
|
||||
runAsync(getJSON('/user/emails?ensureAffiliation=true'))
|
||||
.then(data => {
|
||||
dispatch(ActionCreators.setData(data))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => dispatch(ActionCreators.setLoading(false)))
|
||||
}, [runAsync, dispatch])
|
||||
|
||||
// Get emails on page load
|
||||
useEffect(() => {
|
||||
getEmails()
|
||||
}, [getEmails])
|
||||
|
||||
const resetLeaversSurveyExpiration = useCallback(
|
||||
(deletedEmail: UserEmailData) => {
|
||||
if (
|
||||
deletedEmail.emailHasInstitutionLicence ||
|
||||
deletedEmail.affiliation?.pastReconfirmDate
|
||||
) {
|
||||
const stillHasLicenseAccess = Object.values(state.data.byId).some(
|
||||
userEmail =>
|
||||
userEmail.email !== deletedEmail.email &&
|
||||
userEmail.emailHasInstitutionLicence
|
||||
)
|
||||
if (!stillHasLicenseAccess) {
|
||||
setShowInstitutionalLeaversSurveyUntil(Date.now() + ONE_WEEK_IN_MS)
|
||||
}
|
||||
}
|
||||
},
|
||||
[state, setShowInstitutionalLeaversSurveyUntil]
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
isInitializing: isLoading && !data,
|
||||
isInitializingSuccess: isSuccess,
|
||||
isInitializingError: isError,
|
||||
getEmails,
|
||||
showInstitutionalLeaversSurveyUntil,
|
||||
setShowInstitutionalLeaversSurveyUntil,
|
||||
resetLeaversSurveyExpiration,
|
||||
setLoading: useCallback(
|
||||
(flag: boolean) => dispatch(ActionCreators.setLoading(flag)),
|
||||
[dispatch]
|
||||
),
|
||||
makePrimary: useCallback(
|
||||
(email: UserEmailData['email']) =>
|
||||
dispatch(ActionCreators.makePrimary(email)),
|
||||
[dispatch]
|
||||
),
|
||||
deleteEmail: useCallback(
|
||||
(email: UserEmailData['email']) =>
|
||||
dispatch(ActionCreators.deleteEmail(email)),
|
||||
[dispatch]
|
||||
),
|
||||
setEmailAffiliationBeingEdited: useCallback(
|
||||
(email: Nullable<UserEmailData['email']>) =>
|
||||
dispatch(ActionCreators.setEmailAffiliationBeingEdited(email)),
|
||||
[dispatch]
|
||||
),
|
||||
updateAffiliation: useCallback(
|
||||
(
|
||||
email: UserEmailData['email'],
|
||||
role: Affiliation['role'],
|
||||
department: Affiliation['department']
|
||||
) => dispatch(ActionCreators.updateAffiliation(email, role, department)),
|
||||
[dispatch]
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const UserEmailsContext = createContext<
|
||||
ReturnType<typeof useUserEmails> | undefined
|
||||
>(undefined)
|
||||
UserEmailsContext.displayName = 'UserEmailsContext'
|
||||
|
||||
type UserEmailsProviderProps = {
|
||||
children: React.ReactNode
|
||||
} & Record<string, unknown>
|
||||
|
||||
function UserEmailsProvider(props: UserEmailsProviderProps) {
|
||||
const value = useUserEmails()
|
||||
|
||||
return <UserEmailsContext.Provider value={value} {...props} />
|
||||
}
|
||||
|
||||
const useUserEmailsContext = () => {
|
||||
const context = useContext(UserEmailsContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type EmailContextType = ReturnType<typeof useUserEmailsContext>
|
||||
|
||||
export { UserEmailsProvider, useUserEmailsContext, EmailContextType }
|
@@ -0,0 +1,256 @@
|
||||
const countries = <const>[
|
||||
{ code: 'af', name: 'Afghanistan' },
|
||||
{ code: 'ax', name: 'Åland Islands' },
|
||||
{ code: 'al', name: 'Albania' },
|
||||
{ code: 'dz', name: 'Algeria' },
|
||||
{ code: 'as', name: 'American Samoa' },
|
||||
{ code: 'ad', name: 'Andorra' },
|
||||
{ code: 'ao', name: 'Angola' },
|
||||
{ code: 'ai', name: 'Anguilla' },
|
||||
{ code: 'aq', name: 'Antarctica' },
|
||||
{ code: 'ag', name: 'Antigua and Barbuda' },
|
||||
{ code: 'ar', name: 'Argentina' },
|
||||
{ code: 'am', name: 'Armenia' },
|
||||
{ code: 'aw', name: 'Aruba' },
|
||||
{ code: 'au', name: 'Australia' },
|
||||
{ code: 'at', name: 'Austria' },
|
||||
{ code: 'az', name: 'Azerbaijan' },
|
||||
{ code: 'bs', name: 'Bahamas' },
|
||||
{ code: 'bh', name: 'Bahrain' },
|
||||
{ code: 'bd', name: 'Bangladesh' },
|
||||
{ code: 'bb', name: 'Barbados' },
|
||||
{ code: 'by', name: 'Belarus' },
|
||||
{ code: 'be', name: 'Belgium' },
|
||||
{ code: 'bz', name: 'Belize' },
|
||||
{ code: 'bj', name: 'Benin' },
|
||||
{ code: 'bm', name: 'Bermuda' },
|
||||
{ code: 'bt', name: 'Bhutan' },
|
||||
{ code: 'bo', name: 'Bolivia' },
|
||||
{ code: 'bq', name: 'Bonaire, Saint Eustatius and Saba' },
|
||||
{ code: 'ba', name: 'Bosnia and Herzegovina' },
|
||||
{ code: 'bw', name: 'Botswana' },
|
||||
{ code: 'bv', name: 'Bouvet Island' },
|
||||
{ code: 'br', name: 'Brazil' },
|
||||
{ code: 'io', name: 'British Indian Ocean Territory' },
|
||||
{ code: 'vg', name: 'British Virgin Islands' },
|
||||
{ code: 'bn', name: 'Brunei' },
|
||||
{ code: 'bg', name: 'Bulgaria' },
|
||||
{ code: 'bf', name: 'Burkina Faso' },
|
||||
{ code: 'bi', name: 'Burundi' },
|
||||
{ code: 'kh', name: 'Cambodia' },
|
||||
{ code: 'cm', name: 'Cameroon' },
|
||||
{ code: 'ca', name: 'Canada' },
|
||||
{ code: 'cv', name: 'Cabo Verde' },
|
||||
{ code: 'ky', name: 'Cayman Islands' },
|
||||
{ code: 'cf', name: 'Central African Republic' },
|
||||
{ code: 'td', name: 'Chad' },
|
||||
{ code: 'cl', name: 'Chile' },
|
||||
{ code: 'cn', name: 'China' },
|
||||
{ code: 'cx', name: 'Christmas Island' },
|
||||
{ code: 'cc', name: 'Cocos (Keeling) Islands' },
|
||||
{ code: 'co', name: 'Colombia' },
|
||||
{ code: 'km', name: 'Comoros' },
|
||||
{ code: 'cg', name: 'Congo' },
|
||||
{ code: 'ck', name: 'Cook Islands' },
|
||||
{ code: 'cr', name: 'Costa Rica' },
|
||||
{ code: 'ci', name: "Côte d'Ivoire" },
|
||||
{ code: 'hr', name: 'Croatia' },
|
||||
{ code: 'cu', name: 'Cuba' },
|
||||
{ code: 'cw', name: 'Curaçao' },
|
||||
{ code: 'cy', name: 'Cyprus' },
|
||||
{ code: 'cz', name: 'Czech Republic' },
|
||||
{ code: 'kp', name: "Democratic People's Republic of Korea" },
|
||||
{ code: 'cd', name: 'Democratic Republic of the Congo' },
|
||||
{ code: 'dk', name: 'Denmark' },
|
||||
{ code: 'dj', name: 'Djibouti' },
|
||||
{ code: 'dm', name: 'Dominica' },
|
||||
{ code: 'do', name: 'Dominican Republic' },
|
||||
{ code: 'ec', name: 'Ecuador' },
|
||||
{ code: 'eg', name: 'Egypt' },
|
||||
{ code: 'sv', name: 'El Salvador' },
|
||||
{ code: 'gq', name: 'Equatorial Guinea' },
|
||||
{ code: 'er', name: 'Eritrea' },
|
||||
{ code: 'ee', name: 'Estonia' },
|
||||
{ code: 'et', name: 'Ethiopia' },
|
||||
{ code: 'fk', name: 'Falkland Islands (Malvinas)' },
|
||||
{ code: 'fo', name: 'Faroe Islands' },
|
||||
{ code: 'fj', name: 'Fiji' },
|
||||
{ code: 'fi', name: 'Finland' },
|
||||
{ code: 'fr', name: 'France' },
|
||||
{ code: 'gf', name: 'French Guiana' },
|
||||
{ code: 'pf', name: 'French Polynesia' },
|
||||
{ code: 'tf', name: 'French Southern Territories' },
|
||||
{ code: 'ga', name: 'Gabon' },
|
||||
{ code: 'gm', name: 'Gambia' },
|
||||
{ code: 'ge', name: 'Georgia' },
|
||||
{ code: 'de', name: 'Germany' },
|
||||
{ code: 'gh', name: 'Ghana' },
|
||||
{ code: 'gi', name: 'Gibraltar' },
|
||||
{ code: 'gr', name: 'Greece' },
|
||||
{ code: 'gl', name: 'Greenland' },
|
||||
{ code: 'gd', name: 'Grenada' },
|
||||
{ code: 'gp', name: 'Guadeloupe' },
|
||||
{ code: 'gu', name: 'Guam' },
|
||||
{ code: 'gt', name: 'Guatemala' },
|
||||
{ code: 'gg', name: 'Guernsey' },
|
||||
{ code: 'gn', name: 'Guinea' },
|
||||
{ code: 'gw', name: 'Guinea-Bissau' },
|
||||
{ code: 'gy', name: 'Guyana' },
|
||||
{ code: 'ht', name: 'Haiti' },
|
||||
{ code: 'hm', name: 'Heard Island and McDonald Islands' },
|
||||
{ code: 'va', name: 'Holy See (Vatican City)' },
|
||||
{ code: 'hn', name: 'Honduras' },
|
||||
{ code: 'hk', name: 'Hong Kong' },
|
||||
{ code: 'hu', name: 'Hungary' },
|
||||
{ code: 'is', name: 'Iceland' },
|
||||
{ code: 'in', name: 'India' },
|
||||
{ code: 'id', name: 'Indonesia' },
|
||||
{ code: 'ir', name: 'Iran' },
|
||||
{ code: 'iq', name: 'Iraq' },
|
||||
{ code: 'ie', name: 'Ireland' },
|
||||
{ code: 'im', name: 'Isle of Man' },
|
||||
{ code: 'il', name: 'Israel' },
|
||||
{ code: 'it', name: 'Italy' },
|
||||
{ code: 'jm', name: 'Jamaica' },
|
||||
{ code: 'jp', name: 'Japan' },
|
||||
{ code: 'je', name: 'Jersey' },
|
||||
{ code: 'jo', name: 'Jordan' },
|
||||
{ code: 'kz', name: 'Kazakhstan' },
|
||||
{ code: 'ke', name: 'Kenya' },
|
||||
{ code: 'ki', name: 'Kiribati' },
|
||||
{ code: 'xk', name: 'Kosovo' },
|
||||
{ code: 'kw', name: 'Kuwait' },
|
||||
{ code: 'kg', name: 'Kyrgyzstan' },
|
||||
{ code: 'la', name: 'Laos' },
|
||||
{ code: 'lv', name: 'Latvia' },
|
||||
{ code: 'lb', name: 'Lebanon' },
|
||||
{ code: 'ls', name: 'Lesotho' },
|
||||
{ code: 'lr', name: 'Liberia' },
|
||||
{ code: 'ly', name: 'Libya' },
|
||||
{ code: 'li', name: 'Liechtenstein' },
|
||||
{ code: 'lt', name: 'Lithuania' },
|
||||
{ code: 'lu', name: 'Luxembourg' },
|
||||
{ code: 'mo', name: 'Macao' },
|
||||
{ code: 'mk', name: 'Macedonia' },
|
||||
{ code: 'mg', name: 'Madagascar' },
|
||||
{ code: 'mw', name: 'Malawi' },
|
||||
{ code: 'my', name: 'Malaysia' },
|
||||
{ code: 'mv', name: 'Maldives' },
|
||||
{ code: 'ml', name: 'Mali' },
|
||||
{ code: 'mt', name: 'Malta' },
|
||||
{ code: 'mh', name: 'Marshall Islands' },
|
||||
{ code: 'mq', name: 'Martinique' },
|
||||
{ code: 'mr', name: 'Mauritania' },
|
||||
{ code: 'mu', name: 'Mauritius' },
|
||||
{ code: 'yt', name: 'Mayotte' },
|
||||
{ code: 'mx', name: 'Mexico' },
|
||||
{ code: 'fm', name: 'Micronesia' },
|
||||
{ code: 'md', name: 'Moldova' },
|
||||
{ code: 'mc', name: 'Monaco' },
|
||||
{ code: 'mn', name: 'Mongolia' },
|
||||
{ code: 'me', name: 'Montenegro' },
|
||||
{ code: 'ms', name: 'Montserrat' },
|
||||
{ code: 'ma', name: 'Morocco' },
|
||||
{ code: 'mz', name: 'Mozambique' },
|
||||
{ code: 'mm', name: 'Myanmar' },
|
||||
{ code: 'na', name: 'Namibia' },
|
||||
{ code: 'nr', name: 'Nauru' },
|
||||
{ code: 'np', name: 'Nepal' },
|
||||
{ code: 'nl', name: 'Netherlands' },
|
||||
{ code: 'an', name: 'Netherlands Antilles' },
|
||||
{ code: 'nc', name: 'New Caledonia' },
|
||||
{ code: 'nz', name: 'New Zealand' },
|
||||
{ code: 'ni', name: 'Nicaragua' },
|
||||
{ code: 'ne', name: 'Niger' },
|
||||
{ code: 'ng', name: 'Nigeria' },
|
||||
{ code: 'nu', name: 'Niue' },
|
||||
{ code: 'nf', name: 'Norfolk Island' },
|
||||
{ code: 'mp', name: 'Northern Mariana Islands' },
|
||||
{ code: 'no', name: 'Norway' },
|
||||
{ code: 'om', name: 'Oman' },
|
||||
{ code: 'pk', name: 'Pakistan' },
|
||||
{ code: 'pw', name: 'Palau' },
|
||||
{ code: 'ps', name: 'Palestine' },
|
||||
{ code: 'pa', name: 'Panama' },
|
||||
{ code: 'pg', name: 'Papua New Guinea' },
|
||||
{ code: 'py', name: 'Paraguay' },
|
||||
{ code: 'pe', name: 'Peru' },
|
||||
{ code: 'ph', name: 'Philippines' },
|
||||
{ code: 'pn', name: 'Pitcairn' },
|
||||
{ code: 'pl', name: 'Poland' },
|
||||
{ code: 'pt', name: 'Portugal' },
|
||||
{ code: 'pr', name: 'Puerto Rico' },
|
||||
{ code: 'qa', name: 'Qatar' },
|
||||
{ code: 'kr', name: 'Republic of Korea' },
|
||||
{ code: 're', name: 'Réunion' },
|
||||
{ code: 'ro', name: 'Romania' },
|
||||
{ code: 'ru', name: 'Russia' },
|
||||
{ code: 'rw', name: 'Rwanda' },
|
||||
{ code: 'bl', name: 'Saint Barthélemy' },
|
||||
{ code: 'sh', name: 'Saint Helena, Ascension and Tristan da Cunha' },
|
||||
{ code: 'kn', name: 'Saint Kitts and Nevis' },
|
||||
{ code: 'lc', name: 'Saint Lucia' },
|
||||
{ code: 'mf', name: 'Saint Martin' },
|
||||
{ code: 'pm', name: 'Saint Pierre and Miquelon' },
|
||||
{ code: 'vc', name: 'Saint Vincent and the Grenadines' },
|
||||
{ code: 'ws', name: 'Samoa' },
|
||||
{ code: 'sm', name: 'San Marino' },
|
||||
{ code: 'st', name: 'Sao Tome and Principe' },
|
||||
{ code: 'sa', name: 'Saudi Arabia' },
|
||||
{ code: 'sn', name: 'Senegal' },
|
||||
{ code: 'rs', name: 'Serbia' },
|
||||
{ code: 'sc', name: 'Seychelles' },
|
||||
{ code: 'sl', name: 'Sierra Leone' },
|
||||
{ code: 'sg', name: 'Singapore' },
|
||||
{ code: 'sx', name: 'Sint Maarten' },
|
||||
{ code: 'sk', name: 'Slovakia' },
|
||||
{ code: 'si', name: 'Slovenia' },
|
||||
{ code: 'sb', name: 'Solomon Islands' },
|
||||
{ code: 'so', name: 'Somalia' },
|
||||
{ code: 'za', name: 'South Africa' },
|
||||
{ code: 'gs', name: 'South Georgia and the South Sandwich Islands' },
|
||||
{ code: 'ss', name: 'South Sudan' },
|
||||
{ code: 'es', name: 'Spain' },
|
||||
{ code: 'lk', name: 'Sri Lanka' },
|
||||
{ code: 'sd', name: 'Sudan' },
|
||||
{ code: 'sr', name: 'Suriname' },
|
||||
{ code: 'sj', name: 'Svalbard and Jan Mayen' },
|
||||
{ code: 'sz', name: 'Swaziland' },
|
||||
{ code: 'se', name: 'Sweden' },
|
||||
{ code: 'ch', name: 'Switzerland' },
|
||||
{ code: 'sy', name: 'Syria' },
|
||||
{ code: 'tw', name: 'Taiwan' },
|
||||
{ code: 'tj', name: 'Tajikistan' },
|
||||
{ code: 'tz', name: 'Tanzania' },
|
||||
{ code: 'th', name: 'Thailand' },
|
||||
{ code: 'tl', name: 'Timor-Leste' },
|
||||
{ code: 'tg', name: 'Togo' },
|
||||
{ code: 'tk', name: 'Tokelau' },
|
||||
{ code: 'to', name: 'Tonga' },
|
||||
{ code: 'tt', name: 'Trinidad and Tobago' },
|
||||
{ code: 'tn', name: 'Tunisia' },
|
||||
{ code: 'tr', name: 'Turkey' },
|
||||
{ code: 'tm', name: 'Turkmenistan' },
|
||||
{ code: 'tc', name: 'Turks and Caicos Islands' },
|
||||
{ code: 'tv', name: 'Tuvalu' },
|
||||
{ code: 'vi', name: 'U.S. Virgin Islands' },
|
||||
{ code: 'ug', name: 'Uganda' },
|
||||
{ code: 'ua', name: 'Ukraine' },
|
||||
{ code: 'ae', name: 'United Arab Emirates' },
|
||||
{ code: 'gb', name: 'United Kingdom' },
|
||||
{ code: 'us', name: 'United States of America' },
|
||||
{ code: 'um', name: 'United States Minor Outlying Islands' },
|
||||
{ code: 'uy', name: 'Uruguay' },
|
||||
{ code: 'uz', name: 'Uzbekistan' },
|
||||
{ code: 'vu', name: 'Vanuatu' },
|
||||
{ code: 've', name: 'Venezuela' },
|
||||
{ code: 'vn', name: 'Vietnam' },
|
||||
{ code: 'wf', name: 'Wallis and Futuna' },
|
||||
{ code: 'eh', name: 'Western Sahara' },
|
||||
{ code: 'ye', name: 'Yemen' },
|
||||
{ code: 'zm', name: 'Zambia' },
|
||||
{ code: 'zw', name: 'Zimbabwe' },
|
||||
]
|
||||
|
||||
export default countries
|
||||
export type CountryCode = typeof countries[number]['code']
|
@@ -0,0 +1,78 @@
|
||||
const departments = <const>[
|
||||
'Aeronautics & Astronautics',
|
||||
'Anesthesia',
|
||||
'Anthropology',
|
||||
'Applied Physics',
|
||||
'Art & Art History',
|
||||
'Biochemistry',
|
||||
'Bioengineering',
|
||||
'Biology',
|
||||
'Business School Library',
|
||||
'Business, Graduate School of',
|
||||
'Cardiothoracic Surgery',
|
||||
'Chemical and Systems Biology',
|
||||
'Chemical Engineering',
|
||||
'Chemistry',
|
||||
'Civil & Environmental Engineering',
|
||||
'Classics',
|
||||
'Communication',
|
||||
'Comparative Literature',
|
||||
'Comparative Medicine',
|
||||
'Computer Science',
|
||||
'Dermatology',
|
||||
'Developmental Biology',
|
||||
'Earth System Science',
|
||||
'East Asian Languages and Cultures',
|
||||
'Economics',
|
||||
'Education, School of',
|
||||
'Electrical Engineering',
|
||||
'Energy Resources Engineering',
|
||||
'English',
|
||||
'French and Italian',
|
||||
'Genetics',
|
||||
'Geological Sciences',
|
||||
'Geophysics',
|
||||
'German Studies',
|
||||
'Health Research & Policy',
|
||||
'History',
|
||||
'Iberian & Latin American Cultures',
|
||||
'Law Library',
|
||||
'Law School',
|
||||
'Linguistics',
|
||||
'Management Science & Engineering',
|
||||
'Materials Science & Engineering',
|
||||
'Mathematics',
|
||||
'Mechanical Engineering',
|
||||
'Medical Library',
|
||||
'Medicine',
|
||||
'Microbiology & Immunology',
|
||||
'Molecular & Cellular Physiology',
|
||||
'Music',
|
||||
'Neurobiology',
|
||||
'Neurology & Neurological Sciences',
|
||||
'Neurosurgery',
|
||||
'Obstetrics and Gynecology',
|
||||
'Ophthalmology',
|
||||
'Orthopaedic Surgery',
|
||||
'Otolaryngology (Head and Neck Surgery)',
|
||||
'Pathology',
|
||||
'Pediatrics',
|
||||
'Philosophy',
|
||||
'Physics',
|
||||
'Political Science',
|
||||
'Psychiatry and Behavioral Sciences',
|
||||
'Psychology',
|
||||
'Radiation Oncology',
|
||||
'Radiology',
|
||||
'Religious Studies',
|
||||
'Slavic Languages and Literature',
|
||||
'Sociology',
|
||||
'University Libraries',
|
||||
'Statistics',
|
||||
'Structural Biology',
|
||||
'Surgery',
|
||||
'Theater and Performance Studies',
|
||||
'Urology',
|
||||
]
|
||||
|
||||
export default departments
|
15
services/web/frontend/js/features/settings/data/roles.ts
Normal file
15
services/web/frontend/js/features/settings/data/roles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const roles = <const>[
|
||||
'Undergraduate Student',
|
||||
'Masters Student (MSc, MA, ...)',
|
||||
'Doctoral Student (PhD, EngD, ...)',
|
||||
'Postdoc',
|
||||
'Lecturer',
|
||||
'Senior Lecturer',
|
||||
'Reader',
|
||||
'Associate Professor ',
|
||||
'Assistant Professor ',
|
||||
'Professor',
|
||||
'Emeritus Professor',
|
||||
]
|
||||
|
||||
export default roles
|
@@ -0,0 +1,63 @@
|
||||
const domainBlocklist = ['overleaf.com']
|
||||
const commonTLDs = [
|
||||
'br',
|
||||
'cn',
|
||||
'co',
|
||||
'co.jp',
|
||||
'co.uk',
|
||||
'com',
|
||||
'com.au',
|
||||
'de',
|
||||
'fr',
|
||||
'in',
|
||||
'info',
|
||||
'io',
|
||||
'net',
|
||||
'no',
|
||||
'ru',
|
||||
'se',
|
||||
'us',
|
||||
'com.tw',
|
||||
'com.br',
|
||||
'pl',
|
||||
'it',
|
||||
'co.in',
|
||||
'com.mx',
|
||||
] as const
|
||||
const commonDomains = [
|
||||
'gmail',
|
||||
'googlemail',
|
||||
'icloud',
|
||||
'me',
|
||||
'yahoo',
|
||||
'ymail',
|
||||
'yahoomail',
|
||||
'hotmail',
|
||||
'live',
|
||||
'msn',
|
||||
'outlook',
|
||||
'gmx',
|
||||
'mail',
|
||||
'aol',
|
||||
'163',
|
||||
'mac',
|
||||
'qq',
|
||||
'o2',
|
||||
'libero',
|
||||
'126',
|
||||
'protonmail',
|
||||
'yandex',
|
||||
'yeah',
|
||||
'web',
|
||||
'foxmail',
|
||||
] as const
|
||||
|
||||
for (const domain of commonDomains) {
|
||||
for (const tld of commonTLDs) {
|
||||
domainBlocklist.push(`${domain}.${tld}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default domainBlocklist as ReadonlyArray<
|
||||
(typeof domainBlocklist)[number]
|
||||
>
|
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Actions,
|
||||
ActionSetData,
|
||||
ActionSetLoading,
|
||||
ActionMakePrimary,
|
||||
ActionDeleteEmail,
|
||||
ActionSetEmailAffiliationBeingEdited,
|
||||
ActionUpdateAffiliation,
|
||||
} from '../context/user-email-context'
|
||||
import { UserEmailData } from '../../../../../types/user-email'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import { Affiliation } from '../../../../../types/affiliation'
|
||||
|
||||
export const setData = (data: UserEmailData[]): ActionSetData => ({
|
||||
type: Actions.SET_DATA,
|
||||
payload: data,
|
||||
})
|
||||
|
||||
export const setLoading = (flag: boolean): ActionSetLoading => ({
|
||||
type: Actions.SET_LOADING_STATE,
|
||||
payload: flag,
|
||||
})
|
||||
|
||||
export const makePrimary = (
|
||||
email: UserEmailData['email']
|
||||
): ActionMakePrimary => ({
|
||||
type: Actions.MAKE_PRIMARY,
|
||||
payload: email,
|
||||
})
|
||||
|
||||
export const deleteEmail = (
|
||||
email: UserEmailData['email']
|
||||
): ActionDeleteEmail => ({
|
||||
type: Actions.DELETE_EMAIL,
|
||||
payload: email,
|
||||
})
|
||||
|
||||
export const setEmailAffiliationBeingEdited = (
|
||||
email: Nullable<UserEmailData['email']>
|
||||
): ActionSetEmailAffiliationBeingEdited => ({
|
||||
type: Actions.SET_EMAIL_AFFILIATION_BEING_EDITED,
|
||||
payload: email,
|
||||
})
|
||||
|
||||
export const updateAffiliation = (
|
||||
email: UserEmailData['email'],
|
||||
role: Affiliation['role'],
|
||||
department: Affiliation['department']
|
||||
): ActionUpdateAffiliation => ({
|
||||
type: Actions.UPDATE_AFFILIATION,
|
||||
payload: { email, role, department },
|
||||
})
|
@@ -0,0 +1,22 @@
|
||||
import { State } from '../context/user-email-context'
|
||||
import { UserEmailData } from '../../../../../types/user-email'
|
||||
|
||||
export const inReconfirmNotificationPeriod = (userEmailData: UserEmailData) => {
|
||||
return userEmailData.affiliation?.inReconfirmNotificationPeriod
|
||||
}
|
||||
|
||||
export const institutionAlreadyLinked = (
|
||||
state: State,
|
||||
userEmailData: UserEmailData
|
||||
) => {
|
||||
const institutionId = userEmailData.affiliation?.institution.id?.toString()
|
||||
|
||||
return institutionId !== undefined
|
||||
? state.data.linkedInstitutionIds.includes(institutionId)
|
||||
: false
|
||||
}
|
||||
|
||||
export const isChangingAffiliation = (
|
||||
state: State,
|
||||
email: UserEmailData['email']
|
||||
) => state.data.emailAffiliationBeingEdited === email
|
27
services/web/frontend/js/features/settings/utils/sso.ts
Normal file
27
services/web/frontend/js/features/settings/utils/sso.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import getMeta from '../../../utils/meta'
|
||||
import { DomainInfo } from '../components/emails/add-email/input'
|
||||
import { Institution } from '../../../../../types/institution'
|
||||
|
||||
export const ssoAvailableForDomain = (
|
||||
domain: DomainInfo | null
|
||||
): domain is DomainInfo => {
|
||||
const { hasSamlBeta, hasSamlFeature } = getMeta('ol-ExposedSettings')
|
||||
if (!hasSamlFeature || !domain || !domain.confirmed || !domain.university) {
|
||||
return false
|
||||
}
|
||||
if (domain.university.ssoEnabled) {
|
||||
return true
|
||||
}
|
||||
return Boolean(hasSamlBeta && domain.university.ssoBeta)
|
||||
}
|
||||
|
||||
export const ssoAvailableForInstitution = (institution: Institution | null) => {
|
||||
const { hasSamlBeta, hasSamlFeature } = getMeta('ol-ExposedSettings')
|
||||
if (!hasSamlFeature || !institution || !institution.confirmed) {
|
||||
return false
|
||||
}
|
||||
if (institution.ssoEnabled) {
|
||||
return true
|
||||
}
|
||||
return hasSamlBeta && institution.ssoBeta
|
||||
}
|
Reference in New Issue
Block a user