first commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { postJSON } from '../../../infrastructure/fetch-json'
|
||||
import useIsMounted from '../../../shared/hooks/use-is-mounted'
|
||||
import { set, cloneDeep } from 'lodash'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import type { OAuthProvider } from '../../../../../types/oauth-providers'
|
||||
|
||||
export type SSOSubscription = {
|
||||
providerId: string
|
||||
provider: OAuthProvider
|
||||
linked: boolean
|
||||
}
|
||||
|
||||
type SSOContextValue = {
|
||||
subscriptions: Record<string, SSOSubscription>
|
||||
unlink: (id: string, signal?: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
export const SSOContext = createContext<SSOContextValue | undefined>(undefined)
|
||||
|
||||
type SSOProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SSOProvider({ children }: SSOProviderProps) {
|
||||
const isMounted = useIsMounted()
|
||||
const oauthProviders = getMeta('ol-oauthProviders') || {}
|
||||
const thirdPartyIds = getMeta('ol-thirdPartyIds')
|
||||
|
||||
const [subscriptions, setSubscriptions] = useState<
|
||||
Record<string, SSOSubscription>
|
||||
>(() => {
|
||||
const initialSubscriptions: Record<string, SSOSubscription> = {}
|
||||
for (const [id, provider] of Object.entries(oauthProviders)) {
|
||||
const linked = !!thirdPartyIds[id]
|
||||
if (!provider.hideWhenNotLinked || linked) {
|
||||
initialSubscriptions[id] = {
|
||||
providerId: id,
|
||||
provider,
|
||||
linked,
|
||||
}
|
||||
}
|
||||
}
|
||||
return initialSubscriptions
|
||||
})
|
||||
|
||||
const unlink = useCallback(
|
||||
(providerId: string, signal?: AbortSignal) => {
|
||||
if (!subscriptions[providerId].linked) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const body = {
|
||||
link: false,
|
||||
providerId,
|
||||
}
|
||||
|
||||
return postJSON('/user/oauth-unlink', { body, signal }).then(() => {
|
||||
if (isMounted.current) {
|
||||
setSubscriptions(subs =>
|
||||
set(cloneDeep(subs), `${providerId}.linked`, false)
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[isMounted, subscriptions]
|
||||
)
|
||||
|
||||
const value = useMemo<SSOContextValue>(
|
||||
() => ({
|
||||
subscriptions,
|
||||
unlink,
|
||||
}),
|
||||
[subscriptions, unlink]
|
||||
)
|
||||
|
||||
return <SSOContext.Provider value={value}>{children}</SSOContext.Provider>
|
||||
}
|
||||
|
||||
export function useSSOContext() {
|
||||
const context = useContext(SSOContext)
|
||||
if (!context) {
|
||||
throw new Error('SSOContext is only available inside SSOProvider')
|
||||
}
|
||||
return context
|
||||
}
|
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import useSafeDispatch from '../../../shared/hooks/use-safe-dispatch'
|
||||
import * as ActionCreators from '../utils/action-creators'
|
||||
import { UserEmailData } from '../../../../../types/user-email'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import { Affiliation } from '../../../../../types/affiliation'
|
||||
import { normalize, NormalizedObject } from '../../../utils/normalize'
|
||||
import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import useAsync from '../../../shared/hooks/use-async'
|
||||
import usePersistedState from '../../../shared/hooks/use-persisted-state'
|
||||
|
||||
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export enum Actions {
|
||||
SET_DATA = 'SET_DATA', // eslint-disable-line no-unused-vars
|
||||
SET_LOADING_STATE = 'SET_LOADING_STATE', // eslint-disable-line no-unused-vars
|
||||
MAKE_PRIMARY = 'MAKE_PRIMARY', // eslint-disable-line no-unused-vars
|
||||
DELETE_EMAIL = 'DELETE_EMAIL', // eslint-disable-line no-unused-vars
|
||||
SET_EMAIL_AFFILIATION_BEING_EDITED = 'SET_EMAIL_AFFILIATION_BEING_EDITED', // eslint-disable-line no-unused-vars
|
||||
UPDATE_AFFILIATION = 'UPDATE_AFFILIATION', // eslint-disable-line no-unused-vars
|
||||
}
|
||||
|
||||
export type ActionSetData = {
|
||||
type: Actions.SET_DATA
|
||||
payload: UserEmailData[]
|
||||
}
|
||||
|
||||
export type ActionSetLoading = {
|
||||
type: Actions.SET_LOADING_STATE
|
||||
payload: boolean
|
||||
}
|
||||
|
||||
export type ActionMakePrimary = {
|
||||
type: Actions.MAKE_PRIMARY
|
||||
payload: UserEmailData['email']
|
||||
}
|
||||
|
||||
export type ActionDeleteEmail = {
|
||||
type: Actions.DELETE_EMAIL
|
||||
payload: UserEmailData['email']
|
||||
}
|
||||
|
||||
export type ActionSetEmailAffiliationBeingEdited = {
|
||||
type: Actions.SET_EMAIL_AFFILIATION_BEING_EDITED
|
||||
payload: Nullable<UserEmailData['email']>
|
||||
}
|
||||
|
||||
export type ActionUpdateAffiliation = {
|
||||
type: Actions.UPDATE_AFFILIATION
|
||||
payload: {
|
||||
email: UserEmailData['email']
|
||||
role: Affiliation['role']
|
||||
department: Affiliation['department']
|
||||
}
|
||||
}
|
||||
|
||||
export type State = {
|
||||
isLoading: boolean
|
||||
data: {
|
||||
byId: NormalizedObject<UserEmailData>
|
||||
emailCount: number
|
||||
linkedInstitutionIds: NonNullable<UserEmailData['samlProviderId']>[]
|
||||
emailAffiliationBeingEdited: Nullable<UserEmailData['email']>
|
||||
}
|
||||
}
|
||||
|
||||
type Action =
|
||||
| ActionSetData
|
||||
| ActionSetLoading
|
||||
| ActionMakePrimary
|
||||
| ActionDeleteEmail
|
||||
| ActionSetEmailAffiliationBeingEdited
|
||||
| ActionUpdateAffiliation
|
||||
|
||||
const setData = (state: State, action: ActionSetData) => {
|
||||
const normalized = normalize<UserEmailData>(action.payload, {
|
||||
idAttribute: 'email',
|
||||
})
|
||||
const emailCount = action.payload.length
|
||||
const byId = normalized || {}
|
||||
const linkedInstitutionIds = action.payload
|
||||
.filter(email => Boolean(email.samlProviderId))
|
||||
.map(email => email.samlProviderId) as NonNullable<
|
||||
UserEmailData['samlProviderId']
|
||||
>[]
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...initialState.data,
|
||||
byId,
|
||||
emailCount,
|
||||
linkedInstitutionIds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const setLoadingAction = (state: State, action: ActionSetLoading) => ({
|
||||
...state,
|
||||
isLoading: action.payload,
|
||||
})
|
||||
|
||||
const makePrimaryAction = (state: State, action: ActionMakePrimary) => {
|
||||
if (!state.data.byId[action.payload]) {
|
||||
return state
|
||||
}
|
||||
const byId: State['data']['byId'] = {}
|
||||
for (const id of Object.keys(state.data.byId)) {
|
||||
byId[id] = {
|
||||
...state.data.byId[id],
|
||||
default: state.data.byId[id].email === action.payload,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
byId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEmailAction = (state: State, action: ActionDeleteEmail) => {
|
||||
const { [action.payload]: _, ...byId } = state.data.byId
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
emailCount: state.data.emailCount - 1,
|
||||
byId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const setEmailAffiliationBeingEditedAction = (
|
||||
state: State,
|
||||
action: ActionSetEmailAffiliationBeingEdited
|
||||
) => {
|
||||
if (action.payload && !state.data.byId[action.payload]) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
emailAffiliationBeingEdited: action.payload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const updateAffiliationAction = (
|
||||
state: State,
|
||||
action: ActionUpdateAffiliation
|
||||
) => {
|
||||
const { email, role, department } = action.payload
|
||||
|
||||
if (action.payload && !state.data.byId[email]) {
|
||||
return state
|
||||
}
|
||||
|
||||
const affiliation = state.data.byId[email].affiliation
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...state.data,
|
||||
byId: {
|
||||
...state.data.byId,
|
||||
[email]: {
|
||||
...state.data.byId[email],
|
||||
...(affiliation && {
|
||||
affiliation: {
|
||||
...affiliation,
|
||||
role,
|
||||
department,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
emailAffiliationBeingEdited: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
isLoading: false,
|
||||
data: {
|
||||
byId: {},
|
||||
emailCount: 0,
|
||||
linkedInstitutionIds: [],
|
||||
emailAffiliationBeingEdited: null,
|
||||
},
|
||||
}
|
||||
|
||||
const reducer = (state: State, action: Action) => {
|
||||
switch (action.type) {
|
||||
case Actions.SET_DATA:
|
||||
return setData(state, action)
|
||||
case Actions.SET_LOADING_STATE:
|
||||
return setLoadingAction(state, action)
|
||||
case Actions.MAKE_PRIMARY:
|
||||
return makePrimaryAction(state, action)
|
||||
case Actions.DELETE_EMAIL:
|
||||
return deleteEmailAction(state, action)
|
||||
case Actions.SET_EMAIL_AFFILIATION_BEING_EDITED:
|
||||
return setEmailAffiliationBeingEditedAction(state, action)
|
||||
case Actions.UPDATE_AFFILIATION:
|
||||
return updateAffiliationAction(state, action)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function useUserEmails() {
|
||||
const [
|
||||
showInstitutionalLeaversSurveyUntil,
|
||||
setShowInstitutionalLeaversSurveyUntil,
|
||||
] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, true)
|
||||
const [state, unsafeDispatch] = useReducer(reducer, initialState)
|
||||
const dispatch = useSafeDispatch(unsafeDispatch)
|
||||
const { data, isLoading, isError, isSuccess, runAsync } =
|
||||
useAsync<UserEmailData[]>()
|
||||
|
||||
const getEmails = useCallback(() => {
|
||||
dispatch(ActionCreators.setLoading(true))
|
||||
runAsync(getJSON('/user/emails?ensureAffiliation=true'))
|
||||
.then(data => {
|
||||
dispatch(ActionCreators.setData(data))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => dispatch(ActionCreators.setLoading(false)))
|
||||
}, [runAsync, dispatch])
|
||||
|
||||
// Get emails on page load
|
||||
useEffect(() => {
|
||||
getEmails()
|
||||
}, [getEmails])
|
||||
|
||||
const resetLeaversSurveyExpiration = useCallback(
|
||||
(deletedEmail: UserEmailData) => {
|
||||
if (
|
||||
deletedEmail.emailHasInstitutionLicence ||
|
||||
deletedEmail.affiliation?.pastReconfirmDate
|
||||
) {
|
||||
const stillHasLicenseAccess = Object.values(state.data.byId).some(
|
||||
userEmail =>
|
||||
userEmail.email !== deletedEmail.email &&
|
||||
userEmail.emailHasInstitutionLicence
|
||||
)
|
||||
if (!stillHasLicenseAccess) {
|
||||
setShowInstitutionalLeaversSurveyUntil(Date.now() + ONE_WEEK_IN_MS)
|
||||
}
|
||||
}
|
||||
},
|
||||
[state, setShowInstitutionalLeaversSurveyUntil]
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
isInitializing: isLoading && !data,
|
||||
isInitializingSuccess: isSuccess,
|
||||
isInitializingError: isError,
|
||||
getEmails,
|
||||
showInstitutionalLeaversSurveyUntil,
|
||||
setShowInstitutionalLeaversSurveyUntil,
|
||||
resetLeaversSurveyExpiration,
|
||||
setLoading: useCallback(
|
||||
(flag: boolean) => dispatch(ActionCreators.setLoading(flag)),
|
||||
[dispatch]
|
||||
),
|
||||
makePrimary: useCallback(
|
||||
(email: UserEmailData['email']) =>
|
||||
dispatch(ActionCreators.makePrimary(email)),
|
||||
[dispatch]
|
||||
),
|
||||
deleteEmail: useCallback(
|
||||
(email: UserEmailData['email']) =>
|
||||
dispatch(ActionCreators.deleteEmail(email)),
|
||||
[dispatch]
|
||||
),
|
||||
setEmailAffiliationBeingEdited: useCallback(
|
||||
(email: Nullable<UserEmailData['email']>) =>
|
||||
dispatch(ActionCreators.setEmailAffiliationBeingEdited(email)),
|
||||
[dispatch]
|
||||
),
|
||||
updateAffiliation: useCallback(
|
||||
(
|
||||
email: UserEmailData['email'],
|
||||
role: Affiliation['role'],
|
||||
department: Affiliation['department']
|
||||
) => dispatch(ActionCreators.updateAffiliation(email, role, department)),
|
||||
[dispatch]
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const UserEmailsContext = createContext<
|
||||
ReturnType<typeof useUserEmails> | undefined
|
||||
>(undefined)
|
||||
UserEmailsContext.displayName = 'UserEmailsContext'
|
||||
|
||||
type UserEmailsProviderProps = {
|
||||
children: React.ReactNode
|
||||
} & Record<string, unknown>
|
||||
|
||||
function UserEmailsProvider(props: UserEmailsProviderProps) {
|
||||
const value = useUserEmails()
|
||||
|
||||
return <UserEmailsContext.Provider value={value} {...props} />
|
||||
}
|
||||
|
||||
const useUserEmailsContext = () => {
|
||||
const context = useContext(UserEmailsContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type EmailContextType = ReturnType<typeof useUserEmailsContext>
|
||||
|
||||
export { UserEmailsProvider, useUserEmailsContext, EmailContextType }
|
Reference in New Issue
Block a user