diff --git a/CHANGELOG.md b/CHANGELOG.md index da57861..0e8ef51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Custom onLoadUrlInBrowser and onLoadUrlInBrowserError props - uiMessageWebviewUrlScheme is no longer supported. The default scheme of the mx widgets will be used which is "mx". clientRedirectUrl has been the recommended method for redirecting from oauth for [years](https://docs.mx.com/release-notes/2022/#february) +- The proxy property has been removed from the connect widget. Before a connect widget is rendered a widget url should be fetched and then provided to the widget. ### Changed diff --git a/example/app.json b/example/app.json index 521138a..89e12a2 100644 --- a/example/app.json +++ b/example/app.json @@ -28,7 +28,12 @@ "favicon": "./assets/images/favicon.png" }, "plugins": [ - "expo-router", + [ + "expo-router", + { + "origin": false + } + ], [ "expo-splash-screen", { diff --git a/example/app/connect.tsx b/example/app/connect.tsx deleted file mode 100644 index c483683..0000000 --- a/example/app/connect.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react" -import { StyleSheet, Platform } from "react-native" -import { SafeAreaView } from "react-native-safe-area-context" -import * as Linking from "expo-linking" - -import { ConnectWidget } from "@mxenabled/react-native-widget-sdk" - -const baseUrl = Platform.OS === "android" ? "http://10.0.2.2:8089" : "http://localhost:8089" -const proxy = `${baseUrl}/user/widget_urls` - -const styles = StyleSheet.create({ - page: { - backgroundColor: "#ffffff", - paddingTop: 10, - }, -}) - -export default function Connect() { - const clientRedirectUrl = Linking.createURL("connect") - - return ( - - { - console.error(`SSO URL load error: ${error}`) - }} - onMessage={(url) => { - console.log(`Got a message: ${url}`) - }} - onInvalidMessageError={(url, _error) => { - console.log(`Got an unknown message: ${url}`) - }} - onLoad={(_payload) => { - console.log("Widget is loading") - }} - onLoaded={(_payload) => { - console.log("Widget has loaded") - }} - onMemberConnected={(payload) => { - console.log(`Member connected with payload: ${JSON.stringify(payload)}`) - }} - onOAuthRequested={(payload) => { - console.log(`OAuth requested with URL: ${payload.url}`) - }} - onStepChange={(payload) => { - console.log(`Moving from ${payload.previous} to ${payload.current}`) - }} - onSelectedInstitution={(payload) => { - console.log(`Selecting ${payload.name}`) - }} - /> - - ) -} diff --git a/example/package-lock.json b/example/package-lock.json index 2bc3ae3..1e2e9ca 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -2330,9 +2330,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/example/app/_layout.tsx b/example/src/app/_layout.tsx similarity index 100% rename from example/app/_layout.tsx rename to example/src/app/_layout.tsx diff --git a/example/app/budgets.tsx b/example/src/app/budgets.tsx similarity index 100% rename from example/app/budgets.tsx rename to example/src/app/budgets.tsx diff --git a/example/src/app/connect.tsx b/example/src/app/connect.tsx new file mode 100644 index 0000000..39744ab --- /dev/null +++ b/example/src/app/connect.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from "react" +import { StyleSheet } from "react-native" +import { SafeAreaView } from "react-native-safe-area-context" +import * as Linking from "expo-linking" + +import { ConnectWidget } from "@mxenabled/react-native-widget-sdk" +import { fetchConnectWidgetUrl } from "../shared/api" + +const styles = StyleSheet.create({ + page: { + backgroundColor: "#ffffff", + paddingTop: 10, + }, +}) + +export default function Connect() { + const clientRedirectUrl = Linking.createURL("connect") + + const [url, setUrl] = useState(null) + + useEffect(() => { + fetchConnectWidgetUrl(clientRedirectUrl).then((url) => { + setUrl(url) + }) + }, [clientRedirectUrl]) + + return ( + + {url && ( + { + console.error(`SSO URL load error: ${error}`) + }} + onMessage={(url) => { + console.log(`Got a message: ${url}`) + }} + onInvalidMessageError={(url, _error) => { + console.log(`Got an unknown message: ${url}`) + }} + onLoad={(_payload) => { + console.log("Widget is loading") + }} + onLoaded={(_payload) => { + console.log("Widget has loaded") + }} + onMemberConnected={(payload) => { + console.log(`Member connected with payload: ${JSON.stringify(payload)}`) + }} + onOAuthError={(payload) => { + console.log(`OAuth error with payload: ${JSON.stringify(payload)}`) + }} + onOAuthRequested={(payload) => { + console.log(`OAuth requested with URL: ${payload.url}`) + }} + onStepChange={(payload) => { + console.log(`Moving from ${payload.previous} to ${payload.current}`) + }} + onSelectedInstitution={(payload) => { + console.log(`Selecting ${payload.name}`) + }} + /> + )} + + ) +} diff --git a/example/app/goals.tsx b/example/src/app/goals.tsx similarity index 100% rename from example/app/goals.tsx rename to example/src/app/goals.tsx diff --git a/example/app/index.tsx b/example/src/app/index.tsx similarity index 100% rename from example/app/index.tsx rename to example/src/app/index.tsx diff --git a/example/app/oauth_complete.tsx b/example/src/app/oauth_complete.tsx similarity index 100% rename from example/app/oauth_complete.tsx rename to example/src/app/oauth_complete.tsx diff --git a/example/app/pulse.tsx b/example/src/app/pulse.tsx similarity index 100% rename from example/app/pulse.tsx rename to example/src/app/pulse.tsx diff --git a/example/app/spending.tsx b/example/src/app/spending.tsx similarity index 100% rename from example/app/spending.tsx rename to example/src/app/spending.tsx diff --git a/example/app/transactions.tsx b/example/src/app/transactions.tsx similarity index 100% rename from example/app/transactions.tsx rename to example/src/app/transactions.tsx diff --git a/example/src/shared/api.ts b/example/src/shared/api.ts new file mode 100644 index 0000000..0e22201 --- /dev/null +++ b/example/src/shared/api.ts @@ -0,0 +1,47 @@ +import { Platform } from "react-native" + +const baseUrl = Platform.OS === "android" ? "http://10.0.2.2:8089" : "http://localhost:8089" +const proxyUrl = `${baseUrl}/user/widget_urls` + +interface WidgetUrlResponse { + widget_url: { + url: string + } +} + +export const fetchConnectWidgetUrl = async (clientRedirectUrl: string) => { + const headers: Record = { + "Accept-Version": "v20250224", + "Content-Type": "application/json", + } + + const method = "POST" + const body = JSON.stringify({ + widget_url: { + client_redirect_url: clientRedirectUrl, + is_mobile_webview: true, + ui_message_version: 4, + widget_type: "connect_widget", + data_request: { products: ["identity_verification"] }, + }, + }) + + try { + const response = await fetch(proxyUrl, { + body, + headers, + method, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch widget URL: ${response.status} ${response.statusText}`) + } + + const data: WidgetUrlResponse = await response.json() + + return data.widget_url.url + } catch (error) { + console.error("Error fetching widget URL:", error) + throw error + } +} diff --git a/package-lock.json b/package-lock.json index 1a237fa..2389fc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3765,9 +3765,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/src/components/ConnectWidgets.test.tsx b/src/components/ConnectWidgets.test.tsx deleted file mode 100644 index 49f09e3..0000000 --- a/src/components/ConnectWidgets.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { render, waitFor } from "@testing-library/react-native" -import { ConnectWidget } from "./ConnectWidgets" -import { fullWidgetComponentTestSuite } from "./widgets.test" -import { triggerUrlChange } from "../../test/mocks/react_native" - -jest.mock("expo-web-browser", () => { - return { - openAuthSessionAsync: jest.fn().mockResolvedValue({ type: "success" }), - } -}) - -describe("ConnectWidget", () => { - fullWidgetComponentTestSuite(ConnectWidget) - - describe("OAuth", () => { - const testCases: { - label: string - url: string - check: (msg: string) => void - }[] = [ - { - label: - "an OAuth deeplink triggers a post message to the web view (public URL with matching host)", - url: "appscheme://oauth_complete?member_guid=MBR-123&status=success", - check: (msg) => expect(msg).toContain("MBR-123"), - }, - { - label: - "an OAuth deeplink triggers a post message to the web view (local URL with matching path)", - url: "exp://127.0.0.1:19000/--/oauth_complete?member_guid=MBR-123&status=success", - check: (msg) => expect(msg).toContain("MBR-123"), - }, - { - label: - "an OAuth deeplink triggers a post message to the web view (public URL with matching path)", - url: "exp://exp.host/@community/with-webbrowser-redirect/--/oauth_complete?member_guid=MBR-123&status=success", - check: (msg) => expect(msg).toContain("MBR-123"), - }, - { - label: - "an OAuth success deeplink includes the right status (public URL with matching host)", - url: "appscheme://oauth_complete?member_guid=MBR-123&status=success", - check: (msg) => expect(msg).toContain("oauthComplete/success"), - }, - { - label: "an OAuth success deeplink includes the right status (local URL with matching path)", - url: "exp://127.0.0.1:19000/--/oauth_complete?member_guid=MBR-123&status=success", - check: (msg) => expect(msg).toContain("oauthComplete/success"), - }, - { - label: - "an OAuth success deeplink includes the right status (public URL with matching path)", - url: "exp://exp.host/@community/with-webbrowser-redirect/--/oauth_complete?member_guid=MBR-123&status=success", - check: (msg) => expect(msg).toContain("oauthComplete/success"), - }, - { - label: - "an OAuth failure deeplink includes the right status (public URL with matching host)", - url: "appscheme://oauth_complete?member_guid=MBR-123&status=error", - check: (msg) => expect(msg).toContain("oauthComplete/error"), - }, - { - label: "an OAuth failure deeplink includes the right status (local URL with matching path)", - url: "exp://127.0.0.1:19000/--/oauth_complete?member_guid=MBR-123&status=error", - check: (msg) => expect(msg).toContain("oauthComplete/error"), - }, - { - label: - "an OAuth failure deeplink includes the right status (public URL with matching path)", - url: "exp://exp.host/@community/with-webbrowser-redirect/--/oauth_complete?member_guid=MBR-123&status=error", - check: (msg) => expect(msg).toContain("oauthComplete/error"), - }, - ] - - testCases.forEach((testCase) => { - test(testCase.label, async () => { - expect.assertions(1) - - const component = render( - { - testCase.check(msg) - }} - />, - ) - - await waitFor(() => component.findByTestId("widget_webview")) - triggerUrlChange(testCase.url) - }) - }) - }) -}) diff --git a/src/components/ConnectWidgets.tsx b/src/components/ConnectWidgets.tsx index 3b05339..0c27b5e 100644 --- a/src/components/ConnectWidgets.tsx +++ b/src/components/ConnectWidgets.tsx @@ -7,12 +7,10 @@ import { import { Type, SsoUrlProps, ConnectWidgetConfigurationProps } from "../sso" import * as WebBrowser from "expo-web-browser" import { makeWidgetComponentWithDefaults } from "./make_component" -import { useOAuthDeeplink, OAuthProps } from "./oauth" -import { useWidgetRendererWithRef, StylingProps } from "./renderer" +import { StylingProps, useWidgetRenderer } from "./renderer" export type ConnectWidgetProps = SsoUrlProps & StylingProps & - OAuthProps & ConnectPostMessageCallbackProps & ConnectWidgetConfigurationProps & JSX.IntrinsicAttributes @@ -40,12 +38,10 @@ export function ConnectWidget(props: ConnectWidgetProps) { onOAuthRequested, } - const [ref, elem] = useWidgetRendererWithRef( + const elem = useWidgetRenderer( { ...modifiedProps, widgetType: Type.ConnectWidget }, dispatchConnectLocationChangeEvent, ) - useOAuthDeeplink(ref, modifiedProps) - return elem } diff --git a/src/components/oauth.ts b/src/components/oauth.ts deleted file mode 100644 index 2b3abf7..0000000 --- a/src/components/oauth.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useState } from "react" -import { parse as parseUrl, UrlWithParsedQuery } from "url" -import { WebViewRef } from "./webview" -import { onUrlChange } from "../platform/deeplink" - -/* Used when handling and parsing an OAuth deeplink. This is what the OAuth - * flow uses to communicate state and status back to the app. - */ -const OAuthCompleteHost = "oauth_complete" -const OAuthStatusSuccess = "success" - -/* Used when sending a postMessage to the widget. This is what the app uses to - * communicate the result of an OAuth request after it is redirected back to - * the app. - */ -const OAuthTypeSuccess = "oauthComplete/success" -const OAuthTypeError = "oauthComplete/error" - -type OAuthSuccessRedirectEvent = { - type: "success" - success: boolean - memberGuid: string -} - -type OAuthErrorRedirectEvent = { - type: "error" - success: boolean -} - -export type OAuthRedirectEvent = OAuthSuccessRedirectEvent | OAuthErrorRedirectEvent - -export type OAuthProps = { - sendOAuthPostMessage?: (webViewRef: WebViewRef, msg: string) => void -} - -export function useOAuthDeeplink( - webViewRef: WebViewRef, - props: OAuthProps, -): OAuthRedirectEvent | null { - const [ev, setEvent] = useState(null) - - useEffect(() => { - return onUrlChange(({ url: rawUrl }) => { - const url = parseUrl(rawUrl, true) - - /** - * When the ui_message_webview_url_scheme setting is used, our backend - * will generate a URL which looks like this: - * - * ://? - * - * For URLs like these, we need to check the host. - * - * However, when a client is using Expo in dev/qa mode and doesn't have - * access to their app's scheme, they rely on the client_redirect_url - * setting which must use the host to specify Expo's application - * location. In this case, the redirect URL has to have the specified - * event as part of the path: - * - * exp:///--/? - * - * For URLs like these, we need to check the path. - */ - const isOAuthEventUrl = - url.host === OAuthCompleteHost || url.pathname?.includes(OAuthCompleteHost) - if (!isOAuthEventUrl) { - return - } - - const event = buildOAuthRedirectEvent(url) - setEvent(event) - postOAuthMessage(event, webViewRef, props) - }) - }, []) - - return ev -} - -function buildOAuthRedirectEvent(url: UrlWithParsedQuery): OAuthRedirectEvent { - const success = url.query["status"] === OAuthStatusSuccess - const memberGuid = url.query["amp;member_guid"] || url.query["member_guid"] - if (success && typeof memberGuid === "string") { - return { type: "success", success, memberGuid } - } - - return { type: "error", success: false } -} - -function postOAuthMessage(ev: OAuthRedirectEvent, webViewRef: WebViewRef, props: OAuthProps) { - if (!webViewRef.current) { - return - } - - let type: string - let metadata: Record - if (ev.type === "success") { - type = OAuthTypeSuccess - metadata = { member_guid: ev.memberGuid } - } else { - type = OAuthTypeError - metadata = {} - } - - const message = JSON.stringify({ mx: true, type, metadata }) - - if (props.sendOAuthPostMessage) { - props.sendOAuthPostMessage(webViewRef, message) - } else { - webViewRef.current.postMessage(message) - } -} diff --git a/src/components/renderer.tsx b/src/components/renderer.tsx index f2016b5..cac668d 100644 --- a/src/components/renderer.tsx +++ b/src/components/renderer.tsx @@ -1,4 +1,4 @@ -import React, { useRef, MutableRefObject, ReactElement } from "react" +import React, { ReactElement } from "react" import { StyleProp, ViewStyle, View } from "react-native" import { WebView } from "react-native-webview" import { Payload } from "@mxenabled/widget-post-message-definitions" @@ -13,28 +13,18 @@ export type StylingProps = { webViewStyle?: StyleProp } -type MaybeWebViewRef = MutableRefObject type BaseProps = Props & StylingProps export function useWidgetRenderer( props: BaseProps, dispatchEvent: (url: string, callbacks: BaseProps) => Payload | undefined, ): ReactElement { - const [_ref, elem] = useWidgetRendererWithRef(props, dispatchEvent) - return elem -} - -export function useWidgetRendererWithRef( - props: BaseProps, - dispatchEvent: (url: string, callbacks: BaseProps) => Payload | undefined, -): [MaybeWebViewRef, ReactElement] { - const ref = useRef(null) const url = useSsoUrl(props) const fullscreenStyles = useFullscreenStyles() const style = props.style || fullscreenStyles if (!url) { - return [ref, ] + return } const handler = makeRequestInterceptor(url, { @@ -47,13 +37,11 @@ export function useWidgetRendererWithRef( window.MXReactNativeSDKVersion = "${sdkVersion}"; ` - return [ - ref, + return ( ( onShouldStartLoadWithRequest={handler} onError={props.onWebViewError} /> - , - ] + + ) }