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