@withone/connect 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -26
- package/dist/button.d.ts +20 -14
- package/dist/constants.d.ts +5 -1
- package/dist/flow.d.ts +18 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.esm.js +1 -1
- package/dist/platforms.d.ts +1 -1
- package/dist/react.cjs.js +2 -1
- package/dist/react.d.ts +20 -1
- package/dist/react.esm.js +2 -1
- package/dist/return.d.ts +5 -2
- package/dist/server/index.cjs.js +157 -45
- package/dist/server/index.d.ts +11 -3
- package/dist/server/index.esm.js +157 -46
- package/dist/server/oauth.d.ts +4 -0
- package/dist/server/types.d.ts +47 -4
- package/dist/svelte.cjs.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.esm.js +1 -1
- package/dist/types.d.ts +71 -29
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +71 -22
- package/dist/vue.esm.js +1 -1
- package/package.json +4 -1
- package/skills/one-connect/SKILL.md +55 -19
- package/src/button.ts +450 -295
- package/src/constants.ts +5 -1
- package/src/flow.ts +171 -0
- package/src/index.ts +17 -19
- package/src/platforms.ts +41 -6
- package/src/react.ts +119 -21
- package/src/return.ts +31 -9
- package/src/server/index.ts +188 -51
- package/src/server/oauth.ts +16 -0
- package/src/server/types.ts +49 -4
- package/src/svelte.ts +5 -13
- package/src/types.ts +73 -29
- package/src/vue.ts +42 -26
- package/dist/useOneConnect.d.ts +0 -13
- package/dist/wrapper-options.d.ts +0 -28
- package/src/useOneConnect.ts +0 -72
- package/src/wrapper-options.ts +0 -68
package/src/constants.ts
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* The SDK reads them off the page URL when the tab comes home, so
|
|
3
3
|
* there is no completion page to build. */
|
|
4
4
|
export const RETURN_STATUS_PARAM = "one_connect";
|
|
5
|
-
|
|
5
|
+
/** Why the flow failed, as a code (see ConnectFailureCode). */
|
|
6
|
+
export const RETURN_ERROR_PARAM = "one_connect_error";
|
|
7
|
+
/** Free text that routes before 0.12 put on the URL. Stripped from the
|
|
8
|
+
* address bar, never shown: anyone can write a link that carries it. */
|
|
9
|
+
export const LEGACY_MESSAGE_PARAM = "one_connect_message";
|
|
6
10
|
|
|
7
11
|
/** Fragment key on the authorize URL carrying the app-chosen theme.
|
|
8
12
|
* A fragment never reaches any server and survives the whole redirect
|
package/src/flow.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { THEME_PARAM } from "./constants";
|
|
2
|
+
import { hasReturnParams, parseReturn, stripReturnParams } from "./return";
|
|
3
|
+
import type {
|
|
4
|
+
OneConnectFlow,
|
|
5
|
+
OneConnectFlowOptions,
|
|
6
|
+
OneConnectReturn,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The browser half of the flow, shared by every button and hook on the
|
|
11
|
+
* page. The flow is a full-page redirect: the tab goes to the app's
|
|
12
|
+
* authorize route, on to One's hosted page, back to the app's callback
|
|
13
|
+
* route, and home with `?one_connect=…`.
|
|
14
|
+
*
|
|
15
|
+
* That return is a fact about the page load, not about one button, so it
|
|
16
|
+
* is read once here. Every button renders it; the callbacks go to the
|
|
17
|
+
* first flow still mounted when they fire, which is what makes React's
|
|
18
|
+
* StrictMode (mount, unmount, mount) and a second button on the page
|
|
19
|
+
* behave.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface Subscriber {
|
|
23
|
+
deliver: (outcome: OneConnectReturn) => void;
|
|
24
|
+
restored: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** undefined until read; null when this load is not a return. */
|
|
28
|
+
let pageReturn: OneConnectReturn | null | undefined;
|
|
29
|
+
let delivered = false;
|
|
30
|
+
let deliveryScheduled = false;
|
|
31
|
+
let pageshowBound = false;
|
|
32
|
+
const subscribers: Subscriber[] = [];
|
|
33
|
+
|
|
34
|
+
/** How this page load ended a flow, if it did: `{ status: "success" }`,
|
|
35
|
+
* `{ status: "error", code, message }`, or null. Read once per load, so
|
|
36
|
+
* every caller sees the same answer after the address bar is cleaned. */
|
|
37
|
+
export function readConnectReturn(): OneConnectReturn | null {
|
|
38
|
+
return readPageReturn();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readPageReturn(): OneConnectReturn | null {
|
|
42
|
+
if (typeof window === "undefined") return null;
|
|
43
|
+
if (pageReturn === undefined) {
|
|
44
|
+
pageReturn = parseReturn(window.location.search);
|
|
45
|
+
if (pageReturn || hasReturnParams(window.location.search))
|
|
46
|
+
scrubAddressBar();
|
|
47
|
+
}
|
|
48
|
+
return pageReturn;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Removes the params so a refresh does not replay the outcome.
|
|
52
|
+
* Frameworks that manage history themselves (the Next.js App Router)
|
|
53
|
+
* put their own URL back when hydration completes, undoing a single
|
|
54
|
+
* replaceState, so scrub now and re-check a few beats later. */
|
|
55
|
+
function scrubAddressBar(): void {
|
|
56
|
+
const scrub = () => {
|
|
57
|
+
if (!hasReturnParams(window.location.search)) return;
|
|
58
|
+
window.history.replaceState(
|
|
59
|
+
window.history.state,
|
|
60
|
+
"",
|
|
61
|
+
stripReturnParams(window.location),
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
scrub();
|
|
65
|
+
for (const delay of [50, 500, 2000]) window.setTimeout(scrub, delay);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function scheduleDelivery(): void {
|
|
69
|
+
if (delivered || deliveryScheduled) return;
|
|
70
|
+
const outcome = readPageReturn();
|
|
71
|
+
if (!outcome) return;
|
|
72
|
+
deliveryScheduled = true;
|
|
73
|
+
window.setTimeout(() => {
|
|
74
|
+
deliveryScheduled = false;
|
|
75
|
+
const target = subscribers[0];
|
|
76
|
+
// Nobody mounted yet: the next flow to subscribe receives it.
|
|
77
|
+
if (!target || delivered) return;
|
|
78
|
+
delivered = true;
|
|
79
|
+
target.deliver(outcome);
|
|
80
|
+
}, 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A page restored from the back-forward cache keeps its JavaScript
|
|
84
|
+
* state, so a flow that was mid-redirect would still say Connecting. */
|
|
85
|
+
function bindPageshow(): void {
|
|
86
|
+
if (pageshowBound || typeof window === "undefined") return;
|
|
87
|
+
pageshowBound = true;
|
|
88
|
+
window.addEventListener("pageshow", (event) => {
|
|
89
|
+
if (!event.persisted) return;
|
|
90
|
+
for (const subscriber of [...subscribers]) subscriber.restored();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const authorizeUrlFor = (options: OneConnectFlowOptions): string => {
|
|
95
|
+
const theme = options.connectTheme ?? options.appTheme;
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(options.authorizeUrl, window.location.origin);
|
|
98
|
+
if (theme) url.hash = `${THEME_PARAM}=${theme}`;
|
|
99
|
+
return url.toString();
|
|
100
|
+
} catch {
|
|
101
|
+
return options.authorizeUrl;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Wires the connect flow to any element: call it once where your button
|
|
107
|
+
* lives, pass `open` to a click, and `destroy` when the element goes.
|
|
108
|
+
* A plain function, so it works from every framework; React apps can use
|
|
109
|
+
* the `useOneConnect` hook from `@withone/connect/react` instead.
|
|
110
|
+
*/
|
|
111
|
+
export function createConnectFlow(
|
|
112
|
+
options: OneConnectFlowOptions,
|
|
113
|
+
): OneConnectFlow {
|
|
114
|
+
let current = options;
|
|
115
|
+
let connecting = false;
|
|
116
|
+
|
|
117
|
+
const subscriber: Subscriber = {
|
|
118
|
+
deliver: (outcome) => {
|
|
119
|
+
try {
|
|
120
|
+
if (outcome.status === "success") current.onSuccess?.();
|
|
121
|
+
else current.onError?.(outcome.message ?? "", outcome.code ?? "failed");
|
|
122
|
+
} catch {
|
|
123
|
+
/* the app's own callback threw; not ours to handle */
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
restored: () => {
|
|
127
|
+
if (!connecting) return;
|
|
128
|
+
connecting = false;
|
|
129
|
+
try {
|
|
130
|
+
current.onCancel?.();
|
|
131
|
+
} catch {
|
|
132
|
+
/* the app's own callback threw; not ours to handle */
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (typeof window !== "undefined") {
|
|
138
|
+
subscribers.push(subscriber);
|
|
139
|
+
bindPageshow();
|
|
140
|
+
scheduleDelivery();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
open: () => {
|
|
145
|
+
if (typeof window === "undefined") return;
|
|
146
|
+
connecting = true;
|
|
147
|
+
window.location.assign(authorizeUrlFor(current));
|
|
148
|
+
},
|
|
149
|
+
update: (next) => {
|
|
150
|
+
current = next;
|
|
151
|
+
},
|
|
152
|
+
destroy: () => {
|
|
153
|
+
const index = subscribers.indexOf(subscriber);
|
|
154
|
+
if (index >= 0) subscribers.splice(index, 1);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @deprecated Renamed to `createConnectFlow` (it is not a React hook;
|
|
160
|
+
* React apps can use `useOneConnect` from `@withone/connect/react`).
|
|
161
|
+
* Removed in the next minor. */
|
|
162
|
+
export const useOneConnect = (options: OneConnectFlowOptions): OneConnectFlow =>
|
|
163
|
+
createConnectFlow(options);
|
|
164
|
+
|
|
165
|
+
/** Test seam: forget this page load's outcome. */
|
|
166
|
+
export function resetPageReturnForTests(): void {
|
|
167
|
+
pageReturn = undefined;
|
|
168
|
+
delivered = false;
|
|
169
|
+
deliveryScheduled = false;
|
|
170
|
+
subscribers.length = 0;
|
|
171
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
|
-
export { useOneConnect } from "./
|
|
1
|
+
export { createConnectFlow, readConnectReturn, useOneConnect } from "./flow";
|
|
2
2
|
export { mountConnectButton, registerConnectButton } from "./button";
|
|
3
|
-
export {
|
|
4
|
-
optionsFromProps,
|
|
5
|
-
propsIdentity,
|
|
6
|
-
type ConnectButtonProps,
|
|
7
|
-
type ConnectButtonCallbacks,
|
|
8
|
-
} from "./wrapper-options";
|
|
9
|
-
export {
|
|
10
|
-
normalizePlatform,
|
|
11
|
-
normalizePlatforms,
|
|
12
|
-
parsePlatformsAttribute,
|
|
13
|
-
} from "./platforms";
|
|
14
3
|
export type {
|
|
15
|
-
|
|
16
|
-
OneConnectHandle,
|
|
17
|
-
OneConnectReturn,
|
|
18
|
-
OneConnectTheme,
|
|
19
|
-
ConnectButtonOptions,
|
|
4
|
+
ConnectButtonHandle,
|
|
20
5
|
ConnectButtonPlatform,
|
|
21
6
|
ConnectButtonPlatformInput,
|
|
22
|
-
|
|
7
|
+
ConnectButtonProps,
|
|
8
|
+
ConnectButtonSize,
|
|
23
9
|
ConnectButtonState,
|
|
24
|
-
|
|
10
|
+
ConnectButtonTheme,
|
|
11
|
+
ConnectButtonVariant,
|
|
12
|
+
ConnectFailureCode,
|
|
13
|
+
OneConnectFlow,
|
|
14
|
+
OneConnectFlowOptions,
|
|
15
|
+
OneConnectReturn,
|
|
16
|
+
OneConnectTheme,
|
|
25
17
|
} from "./types";
|
|
18
|
+
|
|
19
|
+
import type { OneConnectFlow, OneConnectFlowOptions } from "./types";
|
|
20
|
+
/** @deprecated Renamed to `OneConnectFlowOptions`; removed in the next minor. */
|
|
21
|
+
export type OneConnectOptions = OneConnectFlowOptions;
|
|
22
|
+
/** @deprecated Renamed to `OneConnectFlow`; removed in the next minor. */
|
|
23
|
+
export type OneConnectHandle = OneConnectFlow;
|
package/src/platforms.ts
CHANGED
|
@@ -13,13 +13,48 @@ export function slugFromName(name: string): string {
|
|
|
13
13
|
.replace(/^-+|-+$/g, "");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/** Brands whose name is not their slug in title case. Everything else
|
|
17
|
+
* reads right from the slug ("google-calendar" -> "Google Calendar"). */
|
|
18
|
+
const BRAND_NAMES: Record<string, string> = {
|
|
19
|
+
"cal-com": "Cal.com",
|
|
20
|
+
"click-up": "ClickUp",
|
|
21
|
+
clickup: "ClickUp",
|
|
22
|
+
docusign: "DocuSign",
|
|
23
|
+
elevenlabs: "ElevenLabs",
|
|
24
|
+
"fireflies-ai": "Fireflies.ai",
|
|
25
|
+
github: "GitHub",
|
|
26
|
+
gitlab: "GitLab",
|
|
27
|
+
hubspot: "HubSpot",
|
|
28
|
+
"linked-in": "LinkedIn",
|
|
29
|
+
linkedin: "LinkedIn",
|
|
30
|
+
"one-drive": "OneDrive",
|
|
31
|
+
onedrive: "OneDrive",
|
|
32
|
+
openai: "OpenAI",
|
|
33
|
+
"otter-ai": "Otter.ai",
|
|
34
|
+
paypal: "PayPal",
|
|
35
|
+
posthog: "PostHog",
|
|
36
|
+
quickbooks: "QuickBooks",
|
|
37
|
+
sharepoint: "SharePoint",
|
|
38
|
+
"spotify-web": "Spotify",
|
|
39
|
+
teams: "Microsoft Teams",
|
|
40
|
+
"telegram-bot": "Telegram",
|
|
41
|
+
"whatsapp-business": "WhatsApp Business",
|
|
42
|
+
woocommerce: "WooCommerce",
|
|
43
|
+
x: "X",
|
|
44
|
+
"youtube-data": "YouTube",
|
|
45
|
+
youtube: "YouTube",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** "google-calendar" -> "Google Calendar"; "hubspot" -> "HubSpot". */
|
|
17
49
|
export function nameFromSlug(slug: string): string {
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
return (
|
|
51
|
+
BRAND_NAMES[slug] ??
|
|
52
|
+
slug
|
|
53
|
+
.split(/[-_]+/)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
56
|
+
.join(" ")
|
|
57
|
+
);
|
|
23
58
|
}
|
|
24
59
|
|
|
25
60
|
/** One's logo for a connector slug. */
|
package/src/react.ts
CHANGED
|
@@ -1,56 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React: `import { ConnectButton } from "@withone/connect/react"`.
|
|
2
|
+
* React: `import { ConnectButton, useOneConnect } from "@withone/connect/react"`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Ships with a "use client" directive, so it imports straight into a
|
|
5
|
+
* Next.js Server Component tree. React is a peer dependency of this
|
|
6
|
+
* subpath only.
|
|
7
7
|
*/
|
|
8
|
-
import { createElement, useEffect, useRef } from "react";
|
|
8
|
+
import { createElement, useCallback, useEffect, useRef, useState } from "react";
|
|
9
9
|
import type { CSSProperties, ReactElement } from "react";
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
createConnectFlow,
|
|
12
13
|
mountConnectButton,
|
|
13
|
-
|
|
14
|
-
propsIdentity,
|
|
14
|
+
readConnectReturn,
|
|
15
15
|
type ConnectButtonHandle,
|
|
16
16
|
type ConnectButtonProps as ConnectButtonCoreProps,
|
|
17
|
+
type ConnectFailureCode,
|
|
18
|
+
type OneConnectFlow,
|
|
19
|
+
type OneConnectFlowOptions,
|
|
17
20
|
} from "@withone/connect";
|
|
18
21
|
|
|
19
22
|
export interface ConnectButtonProps extends ConnectButtonCoreProps {
|
|
23
|
+
/** On the wrapper element around the button. */
|
|
20
24
|
className?: string;
|
|
21
25
|
style?: CSSProperties;
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
/** The props that change what the button draws; callbacks are read
|
|
29
|
+
* through a ref, so a new function each render costs nothing. */
|
|
30
|
+
const visualKey = (props: ConnectButtonCoreProps): string =>
|
|
31
|
+
JSON.stringify([
|
|
32
|
+
props.authorizeUrl,
|
|
33
|
+
props.platforms ?? [],
|
|
34
|
+
props.connected,
|
|
35
|
+
props.disabled,
|
|
36
|
+
props.variant,
|
|
37
|
+
props.size,
|
|
38
|
+
props.fullWidth,
|
|
39
|
+
props.theme,
|
|
40
|
+
props.connectTheme ?? props.appTheme,
|
|
41
|
+
props.accentColor,
|
|
42
|
+
props.label,
|
|
43
|
+
props.connectedLabel,
|
|
44
|
+
props.description,
|
|
45
|
+
]);
|
|
46
|
+
|
|
24
47
|
export function ConnectButton(props: ConnectButtonProps): ReactElement {
|
|
25
48
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
26
49
|
const handleRef = useRef<ConnectButtonHandle | null>(null);
|
|
50
|
+
const keyRef = useRef<string>("");
|
|
51
|
+
const latest = useRef(props);
|
|
52
|
+
latest.current = props;
|
|
27
53
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
onSuccess:
|
|
31
|
-
onError:
|
|
54
|
+
const withLatestCallbacks = (): ConnectButtonCoreProps => ({
|
|
55
|
+
...latest.current,
|
|
56
|
+
onSuccess: () => latest.current.onSuccess?.(),
|
|
57
|
+
onError: (message, code) => latest.current.onError?.(message, code),
|
|
58
|
+
onCancel: () => latest.current.onCancel?.(),
|
|
32
59
|
});
|
|
33
|
-
callbacksRef.current = { onSuccess: props.onSuccess, onError: props.onError };
|
|
34
|
-
|
|
35
|
-
const identity = propsIdentity(props);
|
|
36
60
|
|
|
37
61
|
useEffect(() => {
|
|
38
62
|
if (!containerRef.current) return;
|
|
39
63
|
handleRef.current = mountConnectButton(
|
|
40
64
|
containerRef.current,
|
|
41
|
-
|
|
42
|
-
onSuccess: () => callbacksRef.current.onSuccess?.(),
|
|
43
|
-
onError: (message) => callbacksRef.current.onError?.(message),
|
|
44
|
-
}),
|
|
65
|
+
withLatestCallbacks(),
|
|
45
66
|
);
|
|
67
|
+
keyRef.current = visualKey(latest.current);
|
|
46
68
|
return () => {
|
|
47
69
|
handleRef.current?.destroy();
|
|
48
70
|
handleRef.current = null;
|
|
49
71
|
};
|
|
50
|
-
//
|
|
51
|
-
// captures exactly those.
|
|
72
|
+
// Mounted once; later changes go through update() below.
|
|
52
73
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
|
-
}, [
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const key = visualKey(props);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!handleRef.current || key === keyRef.current) return;
|
|
79
|
+
keyRef.current = key;
|
|
80
|
+
handleRef.current.update(withLatestCallbacks());
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [key]);
|
|
54
83
|
|
|
55
84
|
return createElement("div", {
|
|
56
85
|
ref: containerRef,
|
|
@@ -58,3 +87,72 @@ export function ConnectButton(props: ConnectButtonProps): ReactElement {
|
|
|
58
87
|
style: props.style,
|
|
59
88
|
});
|
|
60
89
|
}
|
|
90
|
+
|
|
91
|
+
export type OneConnectStatus = "idle" | "connecting" | "connected" | "error";
|
|
92
|
+
|
|
93
|
+
export interface UseOneConnectResult {
|
|
94
|
+
/** Sends the tab to One's hosted connect flow. */
|
|
95
|
+
open: () => void;
|
|
96
|
+
/** "connected" and "error" reflect how this page load ended a flow. */
|
|
97
|
+
status: OneConnectStatus;
|
|
98
|
+
error: { message: string; code: ConnectFailureCode } | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The flow for your own button:
|
|
103
|
+
*
|
|
104
|
+
* const { open, status, error } = useOneConnect({ authorizeUrl: "/api/one/authorize" });
|
|
105
|
+
* <button onClick={open} disabled={status === "connecting"}>Connect</button>
|
|
106
|
+
*/
|
|
107
|
+
export function useOneConnect(
|
|
108
|
+
options: OneConnectFlowOptions,
|
|
109
|
+
): UseOneConnectResult {
|
|
110
|
+
const [status, setStatus] = useState<OneConnectStatus>("idle");
|
|
111
|
+
const [error, setError] = useState<UseOneConnectResult["error"]>(null);
|
|
112
|
+
const latest = useRef(options);
|
|
113
|
+
latest.current = options;
|
|
114
|
+
const flowRef = useRef<OneConnectFlow | null>(null);
|
|
115
|
+
|
|
116
|
+
const withHandlers = (): OneConnectFlowOptions => ({
|
|
117
|
+
...latest.current,
|
|
118
|
+
onSuccess: () => latest.current.onSuccess?.(),
|
|
119
|
+
onError: (message, code) => latest.current.onError?.(message, code),
|
|
120
|
+
onCancel: () => {
|
|
121
|
+
setStatus("idle");
|
|
122
|
+
latest.current.onCancel?.();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const flow = createConnectFlow(withHandlers());
|
|
128
|
+
flowRef.current = flow;
|
|
129
|
+
// Read after mount, not during render, so server and client agree.
|
|
130
|
+
const outcome = readConnectReturn();
|
|
131
|
+
if (outcome?.status === "success") setStatus("connected");
|
|
132
|
+
else if (outcome?.status === "error") {
|
|
133
|
+
setStatus("error");
|
|
134
|
+
setError({
|
|
135
|
+
message: outcome.message ?? "",
|
|
136
|
+
code: outcome.code ?? "failed",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return () => {
|
|
140
|
+
flow.destroy();
|
|
141
|
+
flowRef.current = null;
|
|
142
|
+
};
|
|
143
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
flowRef.current?.update(withHandlers());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const open = useCallback(() => {
|
|
151
|
+
if (!flowRef.current) return;
|
|
152
|
+
setError(null);
|
|
153
|
+
setStatus("connecting");
|
|
154
|
+
flowRef.current.open();
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return { open, status, error };
|
|
158
|
+
}
|
package/src/return.ts
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
LEGACY_MESSAGE_PARAM,
|
|
3
|
+
RETURN_ERROR_PARAM,
|
|
4
|
+
RETURN_STATUS_PARAM,
|
|
5
|
+
} from "./constants";
|
|
6
|
+
import type { ConnectFailureCode, OneConnectReturn } from "./types";
|
|
3
7
|
|
|
4
|
-
/**
|
|
8
|
+
/** The only text the SDK ever shows for a failed flow. */
|
|
9
|
+
export const ERROR_MESSAGES: Record<ConnectFailureCode, string> = {
|
|
10
|
+
declined: "You cancelled the connection.",
|
|
11
|
+
expired: "The connection attempt expired. Please try again.",
|
|
12
|
+
failed: "The connection could not be completed. Please try again.",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const isErrorCode = (value: string | null): value is ConnectFailureCode =>
|
|
16
|
+
value !== null && Object.prototype.hasOwnProperty.call(ERROR_MESSAGES, value);
|
|
17
|
+
|
|
18
|
+
/** Reads the outcome the callback route put on the return URL. The
|
|
19
|
+
* message always comes from ERROR_MESSAGES, never from the URL. */
|
|
5
20
|
export function parseReturn(search: string): OneConnectReturn | null {
|
|
6
21
|
const params = new URLSearchParams(search);
|
|
7
22
|
const status = params.get(RETURN_STATUS_PARAM);
|
|
8
|
-
if (status
|
|
9
|
-
|
|
10
|
-
|
|
23
|
+
if (status === "success") return { status };
|
|
24
|
+
if (status !== "error") return null;
|
|
25
|
+
const raw = params.get(RETURN_ERROR_PARAM);
|
|
26
|
+
const code: ConnectFailureCode = isErrorCode(raw) ? raw : "failed";
|
|
27
|
+
return { status, code, message: ERROR_MESSAGES[code] };
|
|
11
28
|
}
|
|
12
29
|
|
|
30
|
+
const OWN_PARAMS = [
|
|
31
|
+
RETURN_STATUS_PARAM,
|
|
32
|
+
RETURN_ERROR_PARAM,
|
|
33
|
+
LEGACY_MESSAGE_PARAM,
|
|
34
|
+
];
|
|
35
|
+
|
|
13
36
|
/** The same URL without the return params, so a refresh does not
|
|
14
37
|
* re-fire the callbacks. */
|
|
15
38
|
export function stripReturnParams(location: {
|
|
@@ -18,13 +41,12 @@ export function stripReturnParams(location: {
|
|
|
18
41
|
hash: string;
|
|
19
42
|
}): string {
|
|
20
43
|
const params = new URLSearchParams(location.search);
|
|
21
|
-
params.delete(
|
|
22
|
-
params.delete(RETURN_MESSAGE_PARAM);
|
|
44
|
+
for (const name of OWN_PARAMS) params.delete(name);
|
|
23
45
|
const query = params.toString();
|
|
24
46
|
return `${location.pathname}${query ? `?${query}` : ""}${location.hash}`;
|
|
25
47
|
}
|
|
26
48
|
|
|
27
49
|
export function hasReturnParams(search: string): boolean {
|
|
28
50
|
const params = new URLSearchParams(search);
|
|
29
|
-
return
|
|
51
|
+
return OWN_PARAMS.some((name) => params.has(name));
|
|
30
52
|
}
|