diff --git a/.gitignore b/.gitignore index 6e4f3ce9a5..aea1036b67 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ dist-ssr *.sln *.sw? .tool-versions + +tmp \ No newline at end of file diff --git a/docs/APIDOCUMENTATION.md b/docs/APIDOCUMENTATION.md index 50a10cf11f..ed330d3445 100644 --- a/docs/APIDOCUMENTATION.md +++ b/docs/APIDOCUMENTATION.md @@ -132,6 +132,10 @@ > | `memberGuid` | required | string | The specific member guid | > | `clientLocale` | optional | string | The locale for the widget | +##### Notes + +> This callback is also used during OAuth flows to synchronize member data when the backend returns a different `inbound_member_guid` than the one used to start the flow (e.g., during non-OAuth to OAuth migrations). When this happens, the widget will fetch the new member record and update its internal state to use the new GUID. + ##### Responses > | http code | content-type | response | @@ -619,7 +623,7 @@ xee > | name | type | data type | description | > | ------------ | -------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -> | `memberGuid` | optional | string | | +> | `memberGuid` | optional | string | The GUID of the member to update. If provided, the widget will initiate an OAuth update flow for this member. | > | `config` | required | [`ClientConfigType`](../typings/connectProps.d.ts#L19) | The connect widget uses the config to set the initial state and behavior of the widget. [More details](./CLIENT_CONFIG.md) | ##### Responses diff --git a/docs/USER_FEATURES.md b/docs/USER_FEATURES.md index 649018a58a..f9977c878b 100644 --- a/docs/USER_FEATURES.md +++ b/docs/USER_FEATURES.md @@ -27,6 +27,16 @@ const userFeatures = [ | `CONNECT_COMBO_JOBS` | When enabled, the Connect widget will create COMBINATION jobs instead of individual jobs (aggregate, verification, reward, etc). |
{
 feature_name: 'CONNECT_COMBO_JOBS',
 guid: 'FTR-123',
 is_enabled: true
 }
| + +## OAuth Member Synchronization + +When updating a member via OAuth, it is possible for the backend to return a different member GUID (`inbound_member_guid`) than the one used to initiate the flow. This commonly occurs during migrations from non-OAuth to OAuth connections, or when a user signs in with a different set of credentials at the same institution. + +The Connect Widget handles this synchronization automatically by: +1. Detecting the GUID change upon successful completion of the OAuth flow. +2. Fetching the new member's full record using the `loadMemberByGuid` callback. +3. Updating the internal Redux state to reflect the new `currentMemberGuid` and including the new member record in the list of active members. +4. Seamlessly transitioning the user to the `Connecting` step with the synchronized member data.
[<-- Back to README](../README.md#props) diff --git a/src/ConnectWidget.tsx b/src/ConnectWidget.tsx index 97acf4bf03..13a6b193cd 100644 --- a/src/ConnectWidget.tsx +++ b/src/ConnectWidget.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React, { createContext, useEffect } from 'react' -import { Provider } from 'react-redux' +import { Provider, useDispatch } from 'react-redux' import Store from 'src/redux/Store' import Connect from 'src/Connect' @@ -19,11 +19,7 @@ interface PostMessageContextType { export const PostMessageContext = createContext({ onPostMessage: () => {} }) -function setupLocalizedContent(localizedContent: Record) { - Store.dispatch(setLocalizedContent(localizedContent)) -} - -export const ConnectWidget = ({ +export const ConnectWidgetWithoutReduxProvider = ({ onPostMessage = () => {}, onAnalyticPageview = () => {}, postMessageEventOverrides, @@ -33,24 +29,30 @@ export const ConnectWidget = ({ }: any) => { initGettextLocaleData(props.language) + const dispatch = useDispatch() + useEffect(() => { - setupLocalizedContent(props?.language?.localizedContent || {}) + dispatch(setLocalizedContent(props?.language?.localizedContent || {})) }, []) return ( - - - - - - {showTooSmallDialog && } - - - - - - + + + + + {showTooSmallDialog && } + + + + + ) } +export const ConnectWidget = (props: any) => ( + + + +) + export default ConnectWidget diff --git a/src/__tests__/ConnectWidget-test.tsx b/src/__tests__/ConnectWidget-test.tsx index 6bb41a5ad5..9c36b31e2f 100644 --- a/src/__tests__/ConnectWidget-test.tsx +++ b/src/__tests__/ConnectWidget-test.tsx @@ -1,13 +1,15 @@ import React from 'react' -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { Subject } from 'rxjs' import { act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' -import { ConnectWidget } from '../ConnectWidget' -import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { ConnectWidgetWithoutReduxProvider } from '../ConnectWidget' +import { render, screen, waitFor, createTestReduxStore } from 'src/utilities/testingLibrary' import { apiValue as apiValueMock } from 'src/const/apiProviderMock' -import { member, JOB_DATA } from 'src/services/mockedData' +import { member, JOB_DATA, OAUTH_STATE, initialState, masterData } from 'src/services/mockedData' import { ReadableStatuses } from 'src/const/Statuses' +import { STEPS } from 'src/const/Connect' // Mock react-confetti to avoid Canvas issues in JSDOM vi.mock('react-confetti', () => ({ @@ -22,6 +24,13 @@ describe('ConnectWidget', () => { language: { locale: 'en', localizedContent: {} }, } + let activeStore = createTestReduxStore() + + beforeEach(() => { + // Reset the active store before each test + activeStore = createTestReduxStore() + }) + it('renders the real Connect widget and handles WebSocket messages correctly', async () => { const webSocketMessages$ = new Subject() const mockWS = { @@ -45,14 +54,14 @@ describe('ConnectWidget', () => { const clientConfig = { mode: 'aggregation', current_member_guid: 'MBR-123' } render( - , - { apiValue: mockApiValue }, + { apiValue: mockApiValue, store: activeStore }, ) // The widget should enter the Connecting state @@ -79,4 +88,84 @@ describe('ConnectWidget', () => { ) }) }) + + it('handles OAuth migration flow where member GUID changes', async () => { + const oldMember = { + ...member.member, + guid: 'MBR-OLD', + is_oauth: true, + connection_status: ReadableStatuses.DENIED, + } + const newMember = { ...member.member, guid: 'MBR-NEW', is_oauth: true, name: 'New Member' } + + const loadOAuthStates = vi + .fn() + .mockResolvedValue([{ ...OAUTH_STATE.oauth_state, guid: 'OAS-123', auth_status: 1 }]) + const loadOAuthState = vi.fn().mockResolvedValue({ + ...OAUTH_STATE.oauth_state, + guid: 'OAS-123', + auth_status: 2, + inbound_member_guid: 'MBR-NEW', + }) + const loadMemberByGuid = vi.fn().mockImplementation((guid) => { + if (guid === 'MBR-OLD') return Promise.resolve(oldMember) + if (guid === 'MBR-NEW') return Promise.resolve(newMember) + return Promise.resolve({}) + }) + + const mockApiValue = { + ...apiValueMock, + loadOAuthStates, + loadOAuthState, + loadMemberByGuid, + } + + const clientConfig = { mode: 'aggregation', current_member_guid: 'MBR-OLD' } + + const preloadedState = { + profiles: initialState.profiles, + connect: { + ...initialState.connect, + members: [oldMember], + currentMemberGuid: 'MBR-OLD', + location: [{ step: STEPS.ENTER_CREDENTIALS }], // Start at OAuth Step + }, + } + + // Create a new store with preloaded state + activeStore = createTestReduxStore(preloadedState) + + render( + , + { + apiValue: mockApiValue, + store: activeStore, + }, + ) + + // Verify we are on OAuth Step + expect(await screen.findByText(/Log in at/i)).toBeInTheDocument() + + // Click continue to start waiting for OAuth + const loginButton = await screen.findByTestId('continue-button') + await userEvent.click(loginButton) + + // Now it should be in WaitingForOAuth, then finish polling, then fetch new member, then go to Connecting + expect(await screen.findByText(/Waiting for permission/i)).toBeInTheDocument() + + // Verify it transitions to Connecting with the NEW GUID + await waitFor( + () => { + expect(screen.getByText(/Connecting to/i)).toBeInTheDocument() + const state = activeStore.getState() + expect(state.connect.currentMemberGuid).toBe('MBR-NEW') + expect(state.connect.members).toContainEqual(newMember) + }, + { timeout: 15000 }, + ) + }, 35000) }) diff --git a/src/const/apiProviderMock.ts b/src/const/apiProviderMock.ts index 8b6d1e12e0..fae532e437 100644 --- a/src/const/apiProviderMock.ts +++ b/src/const/apiProviderMock.ts @@ -22,6 +22,7 @@ export const apiValue: ApiContextTypes = { loadInstitutionByGuid: () => Promise.resolve(institutionData.institution), oAuthStart: () => Promise.resolve(), updateMFA: () => Promise.resolve(member.member), + loadMemberByGuid: () => Promise.resolve(member.member), loadJob: () => Promise.resolve(JOB_DATA), runJob: () => Promise.resolve(member.member), loadOAuthStates: () => Promise.resolve([OAUTH_STATE.oauth_state]), diff --git a/src/redux/actions/Connect.js b/src/redux/actions/Connect.js index e772d1aae8..f8a436b222 100644 --- a/src/redux/actions/Connect.js +++ b/src/redux/actions/Connect.js @@ -102,6 +102,11 @@ export const handleOAuthSuccess = (memberGuid) => ({ payload: memberGuid, }) +export const updateMemberSuccess = (member) => ({ + type: ActionTypes.UPDATE_MEMBER_SUCCESS, + payload: { item: member }, +}) + export const deleteMemberSuccess = (memberGuid) => ({ type: ActionTypes.DELETE_MEMBER_SUCCESS, payload: { memberGuid }, diff --git a/src/utilities/testingLibrary.tsx b/src/utilities/testingLibrary.tsx index 3927686f7a..382ba554e7 100644 --- a/src/utilities/testingLibrary.tsx +++ b/src/utilities/testingLibrary.tsx @@ -86,6 +86,7 @@ const renderWithUser = ( ...options, }), user: userEvent.setup(), + store, } } diff --git a/src/views/oauth/OAuthStep.js b/src/views/oauth/OAuthStep.js index 24ef42d581..4c4107e2c3 100644 --- a/src/views/oauth/OAuthStep.js +++ b/src/views/oauth/OAuthStep.js @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef, useImperativeHandle, useContext } f import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux' import { defer, of } from 'rxjs' -import { mergeMap, map, pluck } from 'rxjs/operators' +import { mergeMap, map } from 'rxjs/operators' import { useApi } from 'src/context/ApiContext' import { ReadableStatuses } from 'src/const/Statuses' @@ -123,22 +123,19 @@ export const OAuthStep = React.forwardRef((props, navigationRef) => { let member$ /** - * WARNING: don't change this area without data to back up your changes + * NOTE: We are re-enabling the use of existing member GUIDs for OAuth flows (Update flow). * - * There has been a flip-flop of problems in this area, so this note is being written as a warning. - * Using existing OAuth members causes problems, because if a new set of credentials is used for - * an existing member, our system ends up in a bad state, where the old member gets mangled up with - * the new credentials. + * Historically, this caused "member combination" issues. We now mitigate this by + * detecting if the backend returns a different inbound_member_guid in WaitingForOAuth.js. + * If a change is detected, we fetch the new member record and update the Redux state + * accordingly, ensuring session integrity even if the GUID migrates during the flow. * - * We tried to reduce the amount of members created by re-using existing oauth members, but that caused - * a regression of a client reported bug, so we had to move this back to always creating new members, - * or using existing pending oauth members. - * - * Previous code attempt that was used to reduce member creation, but reintroduced the bug: - * if (member && member.is_oauth && api.getOAuthWindowURI) { - * member$ = of(member) + * The backend will ultimately decide when to send us back the same member guid, or a new one */ - if (pendingOauthMember) { + if (member?.is_oauth && api.getOAuthWindowURI) { + // If there is an existing member, don't create a new one, use that one (restores update flow) + member$ = of(member) + } else if (pendingOauthMember) { // If there is a pending oauth member, don't create a new one, use that one member$ = of(pendingOauthMember) } else { @@ -155,7 +152,7 @@ export const OAuthStep = React.forwardRef((props, navigationRef) => { config, ), ) - .pipe(pluck('member')) + .pipe(map((resp) => resp.member)) .subscribe( (member) => { sendAnalyticsEvent(AnalyticEvents.OAUTH_PENDING_MEMBER_CREATED, { @@ -216,9 +213,14 @@ export const OAuthStep = React.forwardRef((props, navigationRef) => { setIsWaitingForOAuth(false) } - function handleOAuthSuccess(memberGuid) { + function handleOAuthSuccess(memberGuid, member = null) { closeOAuthWindow() - dispatch(connectActions.handleOAuthSuccess(memberGuid)) + + if (member) { + dispatch(connectActions.updateMemberSuccess(member)) + } else { + dispatch(connectActions.handleOAuthSuccess(memberGuid)) + } } function handleOAuthError(memberGuid, errorReason = null) { diff --git a/src/views/oauth/WaitingForOAuth.js b/src/views/oauth/WaitingForOAuth.js index 80ae02cc48..8020775aa4 100644 --- a/src/views/oauth/WaitingForOAuth.js +++ b/src/views/oauth/WaitingForOAuth.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' import { of, defer } from 'rxjs' -import { map, mergeMap, delay, pluck } from 'rxjs/operators' +import { map, mergeMap, first, filter, catchError } from 'rxjs/operators' import { Text } from '@mxenabled/mxui' import { useTokens } from '@kyper/tokenprovider' @@ -39,6 +39,8 @@ export const WaitingForOAuth = ({ const getNextDelay = getDelay() const { api } = useApi() + const clientLocale = document.querySelector('html')?.getAttribute('lang') || 'en' + useEffect(() => { /** * This gets the most recent PENDING oauth state for the member and polls that @@ -55,26 +57,55 @@ export const WaitingForOAuth = ({ * the oauth state created and know which oauth state to retreive ahead of time. */ const oauthStateCompleted$ = of(member).pipe( - delay(1500), mergeMap(() => defer(() => api.loadOAuthStates({ outbound_member_guid: member.guid, auth_status: OauthState.AuthStatus.PENDING, }), + ).pipe( + map((states) => states?.[0]), + catchError(() => of(null)), ), ), - pluck(0), // get the first response. Should be sorted by newest first + filter((latestState) => !!latestState), mergeMap((latestState) => pollOauthState(latestState.guid, api)), - map((pollingState) => { + mergeMap((pollingState) => { const oauthState = pollingState.currentResponse + const memberGuid = oauthState.inbound_member_guid + + if ( + oauthState.auth_status === OauthState.AuthStatus.SUCCESS && + memberGuid !== member.guid + ) { + /** + * If the inbound member guid is different from our current member guid, + * we need to fetch the new member's record so that we can sync it into + * our redux state. + */ + return defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe( + map((fetchedMember) => ({ + error: false, + memberGuid, + member: fetchedMember, + })), + catchError(() => + of({ + error: true, + errorReason: __('Failed to synchronize member data'), + memberGuid, + }), + ), + ) + } - return { + return of({ error: oauthState.auth_status === OauthState.AuthStatus.ERRORED, errorReason: OauthState.ReadableErrorReason[oauthState.error_reason], - memberGuid: oauthState.inbound_member_guid, - } + memberGuid, + }) }), + first(), ) /** @@ -84,7 +115,7 @@ export const WaitingForOAuth = ({ const sub$ = oauthStateCompleted$.subscribe( (resp) => { if (!resp.error) { - onOAuthSuccess(resp.memberGuid) + onOAuthSuccess(resp.memberGuid, resp.member) } else { onOAuthError(resp.memberGuid, resp.errorReason) } diff --git a/src/views/oauth/__tests__/OAuthStep-test.tsx b/src/views/oauth/__tests__/OAuthStep-test.tsx index 2caf4e9c10..c70460582c 100644 --- a/src/views/oauth/__tests__/OAuthStep-test.tsx +++ b/src/views/oauth/__tests__/OAuthStep-test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from 'src/utilities/testingLibrary' import { OAuthStep } from 'src/views/oauth/OAuthStep' import { apiValue } from 'src/const/apiProviderMock' import { ApiProvider } from 'src/context/ApiContext' +import { OAUTH_STATE } from 'src/services/mockedData' describe('OauthStep view', () => { describe('Ensure OAuthDefault is rendered', () => { @@ -21,6 +22,7 @@ describe('OauthStep view', () => { connect: { members: [], currentMemberGuid: null, + location: [{ step: 'SEARCH' }], }, }, }, @@ -32,16 +34,81 @@ describe('OauthStep view', () => { await user.click(loginButton) const tryAgainButton = await screen.findByRole('button', { name: 'Try again' }) expect(tryAgainButton).toBeInTheDocument() - waitFor( + await waitFor( async () => { expect(tryAgainButton).not.toBeDisabled() await user.click(tryAgainButton) }, - { timeout: 2500 }, + { timeout: 5000 }, ) - waitFor(() => { - expect(loginButton).toBeInTheDocument() + await screen.findByTestId('continue-button') + }) + + it('should prioritize existing member for OAuth URI generation when currentMemberGuid is present', async () => { + const getOAuthWindowURISpy = vi.spyOn(apiValue, 'getOAuthWindowURI') + const addMemberSpy = vi.spyOn(apiValue, 'addMember') + + const existingMember = { + guid: 'MBR-EXISTING', + institution_guid: 'INS-123', + is_oauth: true, + } + + render( + + + , + { + preloadedState: { + connect: { + members: [existingMember], + currentMemberGuid: 'MBR-EXISTING', + location: [{ step: 'SEARCH' }], + }, + }, + }, + ) + + await waitFor(() => { + expect(getOAuthWindowURISpy).toHaveBeenCalledWith('MBR-EXISTING', expect.anything()) }) + expect(addMemberSpy).not.toHaveBeenCalled() }) + + it('should update Redux state when handleOAuthSuccess is called with a member object from WaitingForOAuth', async () => { + const loadOAuthStates = () => + Promise.resolve([{ ...OAUTH_STATE.oauth_state, guid: 'OAS-123', auth_status: 1 }]) + const loadOAuthState = () => + Promise.resolve({ + ...OAUTH_STATE.oauth_state, + guid: 'OAS-123', + auth_status: 2, + inbound_member_guid: 'MBR-NEW', + }) + const loadMemberByGuid = () => Promise.resolve({ guid: 'MBR-NEW', name: 'New Member' }) + + const { user, store } = render(, { + apiValue: { ...apiValue, loadOAuthStates, loadOAuthState, loadMemberByGuid }, + preloadedState: { + connect: { + members: [], + currentMemberGuid: null, + location: [{ step: 'SEARCH' }], + }, + }, + }) + + const loginButton = await screen.findByTestId('continue-button') + await user.click(loginButton) + + await waitFor( + () => { + const state = store.getState() + expect(state.connect.currentMemberGuid).toBe('MBR-NEW') + expect(state.connect.members).toContainEqual({ guid: 'MBR-NEW', name: 'New Member' }) + }, + { timeout: 30000 }, + ) + }, 35000) }) }) diff --git a/src/views/oauth/__tests__/WaitingForOAuth-test.tsx b/src/views/oauth/__tests__/WaitingForOAuth-test.tsx index 1e72a4e691..5c09f908bb 100644 --- a/src/views/oauth/__tests__/WaitingForOAuth-test.tsx +++ b/src/views/oauth/__tests__/WaitingForOAuth-test.tsx @@ -69,5 +69,31 @@ describe('WaitingForOAuth view', () => { { timeout: 3000 }, ) }) + + it('should call api.loadMemberByGuid and onOAuthSuccess with member if the inbound_member_guid differs from the current member guid', async () => { + const loadOAuthState = () => + Promise.resolve({ + ...OAUTH_STATE.oauth_state, + auth_status: 2, + inbound_member_guid: 'MBR-NEW', + }) + const loadMemberByGuidSpy = vi + .spyOn(apiValue, 'loadMemberByGuid') + .mockResolvedValue({ guid: 'MBR-NEW' }) + + render( + + + , + ) + + await waitFor( + async () => { + expect(loadMemberByGuidSpy).toHaveBeenCalledWith('MBR-NEW', expect.anything()) + expect(defaultProps.onOAuthSuccess).toHaveBeenCalledWith('MBR-NEW', { guid: 'MBR-NEW' }) + }, + { timeout: 3000 }, + ) + }) }) })