mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
[Settings] React SSO Context (#7324)
React SSO Context GitOrigin-RevId: 391bf2ba86bb9e112180cffd603b99218594b868
This commit is contained in:
parent
a8fdf6269e
commit
ae633b1b44
4 changed files with 173 additions and 3 deletions
|
@ -0,0 +1,85 @@
|
|||
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'
|
||||
|
||||
type SSOSubscription = {
|
||||
name: string
|
||||
descriptionKey: string
|
||||
linked?: boolean
|
||||
linkPath: string
|
||||
}
|
||||
|
||||
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 isMountedRef = useIsMounted()
|
||||
|
||||
const [subscriptions, setSubscriptions] = useState(() => {
|
||||
const initialSubscriptions: Record<string, SSOSubscription> = {}
|
||||
for (const [id, provider] of Object.entries(window.oauthProviders)) {
|
||||
initialSubscriptions[id] = {
|
||||
descriptionKey: provider.descriptionKey,
|
||||
name: provider.name,
|
||||
linkPath: provider.linkPath,
|
||||
linked: !!window.thirdPartyIds[id],
|
||||
}
|
||||
}
|
||||
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 (isMountedRef.current) {
|
||||
setSubscriptions(subs =>
|
||||
set(cloneDeep(subs), `${providerId}.linked`, false)
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[isMountedRef, 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
|
||||
}
|
|
@ -8,8 +8,9 @@ import OError from '@overleaf/o-error'
|
|||
/**
|
||||
* @typedef {Object} FetchOptions
|
||||
* @extends RequestInit
|
||||
* @property {Object} body
|
||||
* @property {Boolean} swallowAbortError Set to false for throwing AbortErrors.
|
||||
* @property {Object} [body]
|
||||
* @property {Boolean} [swallowAbortError] Set to false for throwing AbortErrors.
|
||||
* @property {AbortSignal} [signal] Allows aborting a request via AbortController
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import { expect } from 'chai'
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import {
|
||||
SSOProvider,
|
||||
useSSOContext,
|
||||
} from '../../../../../frontend/js/features/user-settings/context/sso-context'
|
||||
import fetchMock from 'fetch-mock'
|
||||
|
||||
describe('SSOContext', function () {
|
||||
const renderSSOContext = () =>
|
||||
renderHook(() => useSSOContext(), {
|
||||
wrapper: ({ children }) => <SSOProvider>{children}</SSOProvider>,
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
window.oauthProviders = {
|
||||
google: {
|
||||
descriptionKey: 'login_google',
|
||||
name: 'Google',
|
||||
linkPath: '/auth/google',
|
||||
},
|
||||
orcid: {
|
||||
descriptionKey: 'login_orcid',
|
||||
name: 'Google',
|
||||
linkPath: '/auth/google',
|
||||
},
|
||||
}
|
||||
window.thirdPartyIds = {
|
||||
google: 'googleId',
|
||||
}
|
||||
fetchMock.reset()
|
||||
})
|
||||
|
||||
it('should initialise subscriptions with their linked status', function () {
|
||||
const { result } = renderSSOContext()
|
||||
expect(result.current.subscriptions).to.deep.equal({
|
||||
google: {
|
||||
descriptionKey: 'login_google',
|
||||
linkPath: '/auth/google',
|
||||
linked: true,
|
||||
name: 'Google',
|
||||
},
|
||||
orcid: {
|
||||
descriptionKey: 'login_orcid',
|
||||
linkPath: '/auth/google',
|
||||
linked: false,
|
||||
name: 'Google',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('unlink', function () {
|
||||
beforeEach(function () {
|
||||
fetchMock.post('express:/user/oauth-unlink', 200)
|
||||
})
|
||||
|
||||
it('should unlink an existing subscription', async function () {
|
||||
const { result, waitForNextUpdate } = renderSSOContext()
|
||||
result.current.unlink('google')
|
||||
await waitForNextUpdate()
|
||||
expect(result.current.subscriptions.google.linked).to.be.false
|
||||
})
|
||||
|
||||
it('when the provider is not linked, should do nothing', function () {
|
||||
const { result } = renderSSOContext()
|
||||
result.current.unlink('orcid')
|
||||
expect(fetchMock.called()).to.be.false
|
||||
})
|
||||
|
||||
it('supports unmounting the component while the request is inflight', async function () {
|
||||
const { result, unmount } = renderSSOContext()
|
||||
result.current.unlink('google')
|
||||
expect(fetchMock.called()).to.be.true
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,3 +1,9 @@
|
|||
export type OAuthProvider = {
|
||||
name: string
|
||||
descriptionKey: string
|
||||
linkPath: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
interface Window {
|
||||
|
@ -5,6 +11,8 @@ declare global {
|
|||
user: {
|
||||
id: string
|
||||
}
|
||||
oauthProviders: Record<string, OAuthProvider>
|
||||
thirdPartyIds: Record<string, string>
|
||||
metaAttributesCache: Map<string, unknown>
|
||||
i18n: {
|
||||
currentLangCode: string
|
||||
|
@ -12,4 +20,3 @@ declare global {
|
|||
ExposedSettings: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
export {} // pretend this is a module
|
||||
|
|
Loading…
Reference in a new issue