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,92 @@
import {
createContext,
useCallback,
useContext,
useState,
useMemo,
ReactNode,
} from 'react'
import { postJSON } from '../../../infrastructure/fetch-json'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import { set, cloneDeep } from 'lodash'
import getMeta from '../../../utils/meta'
import type { OAuthProvider } from '../../../../../types/oauth-providers'
export type SSOSubscription = {
providerId: string
provider: OAuthProvider
linked: boolean
}
type SSOContextValue = {
subscriptions: Record<string, SSOSubscription>
unlink: (id: string, signal?: AbortSignal) => Promise<void>
}
export const SSOContext = createContext<SSOContextValue | undefined>(undefined)
type SSOProviderProps = {
children: ReactNode
}
export function SSOProvider({ children }: SSOProviderProps) {
const isMounted = useIsMounted()
const oauthProviders = getMeta('ol-oauthProviders') || {}
const thirdPartyIds = getMeta('ol-thirdPartyIds')
const [subscriptions, setSubscriptions] = useState<
Record<string, SSOSubscription>
>(() => {
const initialSubscriptions: Record<string, SSOSubscription> = {}
for (const [id, provider] of Object.entries(oauthProviders)) {
const linked = !!thirdPartyIds[id]
if (!provider.hideWhenNotLinked || linked) {
initialSubscriptions[id] = {
providerId: id,
provider,
linked,
}
}
}
return initialSubscriptions
})
const unlink = useCallback(
(providerId: string, signal?: AbortSignal) => {
if (!subscriptions[providerId].linked) {
return Promise.resolve()
}
const body = {
link: false,
providerId,
}
return postJSON('/user/oauth-unlink', { body, signal }).then(() => {
if (isMounted.current) {
setSubscriptions(subs =>
set(cloneDeep(subs), `${providerId}.linked`, false)
)
}
})
},
[isMounted, subscriptions]
)
const value = useMemo<SSOContextValue>(
() => ({
subscriptions,
unlink,
}),
[subscriptions, unlink]
)
return <SSOContext.Provider value={value}>{children}</SSOContext.Provider>
}
export function useSSOContext() {
const context = useContext(SSOContext)
if (!context) {
throw new Error('SSOContext is only available inside SSOProvider')
}
return context
}

View File

@@ -0,0 +1,333 @@
import {
createContext,
useEffect,
useContext,
useReducer,
useCallback,
} from 'react'
import useSafeDispatch from '../../../shared/hooks/use-safe-dispatch'
import * as ActionCreators from '../utils/action-creators'
import { UserEmailData } from '../../../../../types/user-email'
import { Nullable } from '../../../../../types/utils'
import { Affiliation } from '../../../../../types/affiliation'
import { normalize, NormalizedObject } from '../../../utils/normalize'
import { getJSON } from '../../../infrastructure/fetch-json'
import useAsync from '../../../shared/hooks/use-async'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000
// eslint-disable-next-line no-unused-vars
export enum Actions {
SET_DATA = 'SET_DATA', // eslint-disable-line no-unused-vars
SET_LOADING_STATE = 'SET_LOADING_STATE', // eslint-disable-line no-unused-vars
MAKE_PRIMARY = 'MAKE_PRIMARY', // eslint-disable-line no-unused-vars
DELETE_EMAIL = 'DELETE_EMAIL', // eslint-disable-line no-unused-vars
SET_EMAIL_AFFILIATION_BEING_EDITED = 'SET_EMAIL_AFFILIATION_BEING_EDITED', // eslint-disable-line no-unused-vars
UPDATE_AFFILIATION = 'UPDATE_AFFILIATION', // eslint-disable-line no-unused-vars
}
export type ActionSetData = {
type: Actions.SET_DATA
payload: UserEmailData[]
}
export type ActionSetLoading = {
type: Actions.SET_LOADING_STATE
payload: boolean
}
export type ActionMakePrimary = {
type: Actions.MAKE_PRIMARY
payload: UserEmailData['email']
}
export type ActionDeleteEmail = {
type: Actions.DELETE_EMAIL
payload: UserEmailData['email']
}
export type ActionSetEmailAffiliationBeingEdited = {
type: Actions.SET_EMAIL_AFFILIATION_BEING_EDITED
payload: Nullable<UserEmailData['email']>
}
export type ActionUpdateAffiliation = {
type: Actions.UPDATE_AFFILIATION
payload: {
email: UserEmailData['email']
role: Affiliation['role']
department: Affiliation['department']
}
}
export type State = {
isLoading: boolean
data: {
byId: NormalizedObject<UserEmailData>
emailCount: number
linkedInstitutionIds: NonNullable<UserEmailData['samlProviderId']>[]
emailAffiliationBeingEdited: Nullable<UserEmailData['email']>
}
}
type Action =
| ActionSetData
| ActionSetLoading
| ActionMakePrimary
| ActionDeleteEmail
| ActionSetEmailAffiliationBeingEdited
| ActionUpdateAffiliation
const setData = (state: State, action: ActionSetData) => {
const normalized = normalize<UserEmailData>(action.payload, {
idAttribute: 'email',
})
const emailCount = action.payload.length
const byId = normalized || {}
const linkedInstitutionIds = action.payload
.filter(email => Boolean(email.samlProviderId))
.map(email => email.samlProviderId) as NonNullable<
UserEmailData['samlProviderId']
>[]
return {
...state,
data: {
...initialState.data,
byId,
emailCount,
linkedInstitutionIds,
},
}
}
const setLoadingAction = (state: State, action: ActionSetLoading) => ({
...state,
isLoading: action.payload,
})
const makePrimaryAction = (state: State, action: ActionMakePrimary) => {
if (!state.data.byId[action.payload]) {
return state
}
const byId: State['data']['byId'] = {}
for (const id of Object.keys(state.data.byId)) {
byId[id] = {
...state.data.byId[id],
default: state.data.byId[id].email === action.payload,
}
}
return {
...state,
data: {
...state.data,
byId,
},
}
}
const deleteEmailAction = (state: State, action: ActionDeleteEmail) => {
const { [action.payload]: _, ...byId } = state.data.byId
return {
...state,
data: {
...state.data,
emailCount: state.data.emailCount - 1,
byId,
},
}
}
const setEmailAffiliationBeingEditedAction = (
state: State,
action: ActionSetEmailAffiliationBeingEdited
) => {
if (action.payload && !state.data.byId[action.payload]) {
return state
}
return {
...state,
data: {
...state.data,
emailAffiliationBeingEdited: action.payload,
},
}
}
const updateAffiliationAction = (
state: State,
action: ActionUpdateAffiliation
) => {
const { email, role, department } = action.payload
if (action.payload && !state.data.byId[email]) {
return state
}
const affiliation = state.data.byId[email].affiliation
return {
...state,
data: {
...state.data,
byId: {
...state.data.byId,
[email]: {
...state.data.byId[email],
...(affiliation && {
affiliation: {
...affiliation,
role,
department,
},
}),
},
},
emailAffiliationBeingEdited: null,
},
}
}
const initialState: State = {
isLoading: false,
data: {
byId: {},
emailCount: 0,
linkedInstitutionIds: [],
emailAffiliationBeingEdited: null,
},
}
const reducer = (state: State, action: Action) => {
switch (action.type) {
case Actions.SET_DATA:
return setData(state, action)
case Actions.SET_LOADING_STATE:
return setLoadingAction(state, action)
case Actions.MAKE_PRIMARY:
return makePrimaryAction(state, action)
case Actions.DELETE_EMAIL:
return deleteEmailAction(state, action)
case Actions.SET_EMAIL_AFFILIATION_BEING_EDITED:
return setEmailAffiliationBeingEditedAction(state, action)
case Actions.UPDATE_AFFILIATION:
return updateAffiliationAction(state, action)
default:
return state
}
}
function useUserEmails() {
const [
showInstitutionalLeaversSurveyUntil,
setShowInstitutionalLeaversSurveyUntil,
] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, true)
const [state, unsafeDispatch] = useReducer(reducer, initialState)
const dispatch = useSafeDispatch(unsafeDispatch)
const { data, isLoading, isError, isSuccess, runAsync } =
useAsync<UserEmailData[]>()
const getEmails = useCallback(() => {
dispatch(ActionCreators.setLoading(true))
runAsync(getJSON('/user/emails?ensureAffiliation=true'))
.then(data => {
dispatch(ActionCreators.setData(data))
})
.catch(() => {})
.finally(() => dispatch(ActionCreators.setLoading(false)))
}, [runAsync, dispatch])
// Get emails on page load
useEffect(() => {
getEmails()
}, [getEmails])
const resetLeaversSurveyExpiration = useCallback(
(deletedEmail: UserEmailData) => {
if (
deletedEmail.emailHasInstitutionLicence ||
deletedEmail.affiliation?.pastReconfirmDate
) {
const stillHasLicenseAccess = Object.values(state.data.byId).some(
userEmail =>
userEmail.email !== deletedEmail.email &&
userEmail.emailHasInstitutionLicence
)
if (!stillHasLicenseAccess) {
setShowInstitutionalLeaversSurveyUntil(Date.now() + ONE_WEEK_IN_MS)
}
}
},
[state, setShowInstitutionalLeaversSurveyUntil]
)
return {
state,
isInitializing: isLoading && !data,
isInitializingSuccess: isSuccess,
isInitializingError: isError,
getEmails,
showInstitutionalLeaversSurveyUntil,
setShowInstitutionalLeaversSurveyUntil,
resetLeaversSurveyExpiration,
setLoading: useCallback(
(flag: boolean) => dispatch(ActionCreators.setLoading(flag)),
[dispatch]
),
makePrimary: useCallback(
(email: UserEmailData['email']) =>
dispatch(ActionCreators.makePrimary(email)),
[dispatch]
),
deleteEmail: useCallback(
(email: UserEmailData['email']) =>
dispatch(ActionCreators.deleteEmail(email)),
[dispatch]
),
setEmailAffiliationBeingEdited: useCallback(
(email: Nullable<UserEmailData['email']>) =>
dispatch(ActionCreators.setEmailAffiliationBeingEdited(email)),
[dispatch]
),
updateAffiliation: useCallback(
(
email: UserEmailData['email'],
role: Affiliation['role'],
department: Affiliation['department']
) => dispatch(ActionCreators.updateAffiliation(email, role, department)),
[dispatch]
),
}
}
const UserEmailsContext = createContext<
ReturnType<typeof useUserEmails> | undefined
>(undefined)
UserEmailsContext.displayName = 'UserEmailsContext'
type UserEmailsProviderProps = {
children: React.ReactNode
} & Record<string, unknown>
function UserEmailsProvider(props: UserEmailsProviderProps) {
const value = useUserEmails()
return <UserEmailsContext.Provider value={value} {...props} />
}
const useUserEmailsContext = () => {
const context = useContext(UserEmailsContext)
if (context === undefined) {
throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
}
return context
}
type EmailContextType = ReturnType<typeof useUserEmailsContext>
export { UserEmailsProvider, useUserEmailsContext, EmailContextType }