Skip to content
Open
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
36 changes: 19 additions & 17 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(memberGuid, member = null) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be clearer if we explicitly distinguished between the inbound member and the original member here. Better naming would make the flow easier to follow and maintain for future devs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The memberGuid and the member are always going to be related to each other. If member is populated we can update our store with that data for that memberGuid. It may be the outbound or inbound member in other words.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so does that mean we can’t tell which one is which at this point?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can see in the code, memberGuid looks like it's a different member that may be returned from the backend during the OAuth process, while member is the original member. Might be worth renaming these to make the distinction clearer, since the current names are a bit ambiguous.

closeOAuthWindow()
dispatch(connectActions.handleOAuthSuccess(memberGuid))

if (member) {
dispatch(connectActions.updateMemberSuccess(member))
} else {
dispatch(connectActions.handleOAuthSuccess(memberGuid))
}
}

function handleOAuthError(memberGuid, errorReason = null) {
Expand Down
Loading
Loading