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