Просмотр исходного кода

[Settings] React SSO Context (#7324)

React SSO Context

GitOrigin-RevId: 391bf2ba86bb9e112180cffd603b99218594b868
Miguel Serrano 4 лет назад
Родитель
Сommit
ae633b1b44

+ 85 - 0
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<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
+}

+ 3 - 2
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
  */
 
 /**

+ 77 - 0
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 }) => <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()
+    })
+  })
+})

+ 8 - 1
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<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