diff --git a/services/web/frontend/js/features/user-settings/context/sso-context.tsx b/services/web/frontend/js/features/user-settings/context/sso-context.tsx new file mode 100644 index 0000000000..80f10a56e4 --- /dev/null +++ b/services/web/frontend/js/features/user-settings/context/sso-context.tsx @@ -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 + unlink: (id: string, signal?: AbortSignal) => Promise +} + +export const SSOContext = createContext(undefined) + +type SSOProviderProps = { + children: ReactNode +} + +export function SSOProvider({ children }: SSOProviderProps) { + const isMountedRef = useIsMounted() + + const [subscriptions, setSubscriptions] = useState(() => { + const initialSubscriptions: Record = {} + 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( + () => ({ + subscriptions, + unlink, + }), + [subscriptions, unlink] + ) + + return {children} +} + +export function useSSOContext() { + const context = useContext(SSOContext) + if (!context) { + throw new Error('SSOContext is only available inside SSOProvider') + } + return context +} diff --git a/services/web/frontend/js/infrastructure/fetch-json.js b/services/web/frontend/js/infrastructure/fetch-json.js index aa1b42de14..f0e4c66949 100644 --- a/services/web/frontend/js/infrastructure/fetch-json.js +++ b/services/web/frontend/js/infrastructure/fetch-json.js @@ -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 */ /** diff --git a/services/web/test/frontend/features/user-settings/context/sso-context.test.tsx b/services/web/test/frontend/features/user-settings/context/sso-context.test.tsx new file mode 100644 index 0000000000..37c9863262 --- /dev/null +++ b/services/web/test/frontend/features/user-settings/context/sso-context.test.tsx @@ -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 }) => {children}, + }) + + 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() + }) + }) +}) diff --git a/services/web/types/window.ts b/services/web/types/window.ts index 5667e84da2..c427822230 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -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 + thirdPartyIds: Record metaAttributesCache: Map i18n: { currentLangCode: string @@ -12,4 +20,3 @@ declare global { ExposedSettings: Record } } -export {} // pretend this is a module