[Settings] React SSO Context (#7324)

React SSO Context

GitOrigin-RevId: 391bf2ba86bb9e112180cffd603b99218594b868
This commit is contained in:
Miguel Serrano 2022-04-08 13:02:17 +02:00 committed by Copybot
parent a8fdf6269e
commit ae633b1b44
4 changed files with 173 additions and 3 deletions

View file

@ -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
}

View file

@ -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
*/
/**

View file

@ -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()
})
})
})

View file

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