Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ dist-ssr
*.sln
*.sw?
.tool-versions

tmp
6 changes: 5 additions & 1 deletion docs/APIDOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/USER_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). | <pre>{<br>&nbsp;feature_name: 'CONNECT_COMBO_JOBS',<br>&nbsp;guid: 'FTR-123', <br>&nbsp;is_enabled: true <br>&nbsp;}</pre> |

</details>

## 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.
<br />

[<-- Back to README](../README.md#props)
40 changes: 21 additions & 19 deletions src/ConnectWidget.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -19,11 +19,7 @@ interface PostMessageContextType {

export const PostMessageContext = createContext<PostMessageContextType>({ onPostMessage: () => {} })

function setupLocalizedContent(localizedContent: Record<string, any>) {
Store.dispatch(setLocalizedContent(localizedContent))
}

export const ConnectWidget = ({
export const ConnectWidgetWithoutReduxProvider = ({
onPostMessage = () => {},
onAnalyticPageview = () => {},
postMessageEventOverrides,
Expand All @@ -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 (
<Provider store={Store}>
<ConnectedTokenProvider>
<WebSocketProvider value={webSocketConnection}>
<PostMessageContext.Provider value={{ onPostMessage, postMessageEventOverrides }}>
<WidgetDimensionObserver heightOffset={0}>
{showTooSmallDialog && <TooSmallDialog onAnalyticPageview={onAnalyticPageview} />}
<Connect onAnalyticPageview={onAnalyticPageview} {...props} />
</WidgetDimensionObserver>
</PostMessageContext.Provider>
</WebSocketProvider>
</ConnectedTokenProvider>
</Provider>
<ConnectedTokenProvider>
<WebSocketProvider value={webSocketConnection}>
<PostMessageContext.Provider value={{ onPostMessage, postMessageEventOverrides }}>
<WidgetDimensionObserver heightOffset={0}>
{showTooSmallDialog && <TooSmallDialog onAnalyticPageview={onAnalyticPageview} />}
<Connect onAnalyticPageview={onAnalyticPageview} {...props} />
</WidgetDimensionObserver>
</PostMessageContext.Provider>
</WebSocketProvider>
</ConnectedTokenProvider>
)
}

export const ConnectWidget = (props: any) => (
<Provider store={Store}>
<ConnectWidgetWithoutReduxProvider {...props} />
</Provider>
)

export default ConnectWidget
101 changes: 95 additions & 6 deletions src/__tests__/ConnectWidget-test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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 = {
Expand All @@ -45,14 +54,14 @@ describe('ConnectWidget', () => {
const clientConfig = { mode: 'aggregation', current_member_guid: 'MBR-123' }

render(
<ConnectWidget
<ConnectWidgetWithoutReduxProvider
{...defaultProps}
clientConfig={clientConfig}
experimentalFeatures={{ useWebSockets: true }}
onSuccessfulAggregation={onSuccessfulAggregation}
webSocketConnection={mockWS}
/>,
{ apiValue: mockApiValue },
{ apiValue: mockApiValue, store: activeStore },
)

// The widget should enter the Connecting state
Expand All @@ -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
Comment thread
codingLogan marked this conversation as resolved.
.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(
<ConnectWidgetWithoutReduxProvider
{...defaultProps}
clientConfig={clientConfig}
profiles={masterData}
/>,
{
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)
})
1 change: 1 addition & 0 deletions src/const/apiProviderMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
5 changes: 5 additions & 0 deletions src/redux/actions/Connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions src/utilities/testingLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const renderWithUser = (
...options,
}),
user: userEvent.setup(),
store,
Comment thread
wesrisenmay-mx marked this conversation as resolved.
}
}

Expand Down
38 changes: 20 additions & 18 deletions src/views/oauth/OAuthStep.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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, {
Expand Down Expand Up @@ -216,9 +213,14 @@ export const OAuthStep = React.forwardRef((props, navigationRef) => {
setIsWaitingForOAuth(false)
}

function handleOAuthSuccess(memberGuid) {
function handleOAuthSuccess(inboundMemberGuid, inboundMember = null) {
closeOAuthWindow()
dispatch(connectActions.handleOAuthSuccess(memberGuid))

if (inboundMember) {
dispatch(connectActions.updateMemberSuccess(inboundMember))
} else {
dispatch(connectActions.handleOAuthSuccess(inboundMemberGuid))
}
}

function handleOAuthError(memberGuid, errorReason = null) {
Expand Down Expand Up @@ -257,10 +259,10 @@ export const OAuthStep = React.forwardRef((props, navigationRef) => {
oauthView = (
<WaitingForOAuth
institution={institution}
member={member}
onOAuthError={handleOAuthError}
onOAuthRetry={handleOAuthRetry}
onOAuthSuccess={handleOAuthSuccess}
outboundMember={member}
/>
)
} else if (oauthStartError) {
Expand Down
Loading
Loading