first commit

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

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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
}