@withone/connect 0.11.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 +58 -23
- 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 +12 -10
- package/dist/server/index.esm.js +12 -10
- package/dist/server/types.d.ts +6 -1
- 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 +33 -15
- 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 +12 -10
- package/src/server/types.ts +7 -1
- 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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* spending a refresh token, clears tokens only when One declares the
|
|
26
26
|
* grant dead, and never lets a failing old pair delete a newer one.
|
|
27
27
|
*/
|
|
28
|
-
import { DEFAULT_ONE_API_URL,
|
|
28
|
+
import { DEFAULT_ONE_API_URL, RETURN_ERROR_PARAM, RETURN_STATUS_PARAM } from "../constants";
|
|
29
29
|
import {
|
|
30
30
|
DEFAULT_SCOPES,
|
|
31
31
|
basicAuthorization,
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
OneConnectError,
|
|
42
42
|
type CompleteAuthorizationInput,
|
|
43
43
|
type CompleteAuthorizationResult,
|
|
44
|
+
type ConnectFailureCode,
|
|
44
45
|
type OneConnectServerConfig,
|
|
45
46
|
type OneConnectTokens,
|
|
46
47
|
type PlatformAction,
|
|
@@ -137,10 +138,10 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
137
138
|
* same refresh token trip One's reuse detection. */
|
|
138
139
|
const refreshing = new Map<string, Promise<OneConnectTokens>>();
|
|
139
140
|
|
|
140
|
-
const returnUrl = (status: "success" | "error",
|
|
141
|
+
const returnUrl = (status: "success" | "error", code?: ConnectFailureCode): string => {
|
|
141
142
|
const url = new URL(returnTo, config.redirectUri);
|
|
142
143
|
url.searchParams.set(RETURN_STATUS_PARAM, status);
|
|
143
|
-
if (
|
|
144
|
+
if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
|
|
144
145
|
return url.toString();
|
|
145
146
|
};
|
|
146
147
|
|
|
@@ -231,23 +232,24 @@ export function createOneConnect(config: OneConnectServerConfig): OneConnect {
|
|
|
231
232
|
const verifier = cookieName ? input.getCookie(cookieName) : undefined;
|
|
232
233
|
|
|
233
234
|
const fail = (
|
|
234
|
-
|
|
235
|
+
failure: ConnectFailureCode,
|
|
235
236
|
message: string,
|
|
236
237
|
): CompleteAuthorizationResult => ({
|
|
237
|
-
outcome,
|
|
238
|
+
outcome: failure === "declined" ? "declined" : "failed",
|
|
239
|
+
code: failure,
|
|
238
240
|
message,
|
|
239
|
-
redirectUrl: returnUrl("error",
|
|
241
|
+
redirectUrl: returnUrl("error", failure),
|
|
240
242
|
clearCookieName: cookieName,
|
|
241
243
|
});
|
|
242
244
|
|
|
243
245
|
if (oauthError === "access_denied")
|
|
244
|
-
return fail("declined", "
|
|
246
|
+
return fail("declined", "The user cancelled on One's page.");
|
|
245
247
|
if (oauthError)
|
|
246
248
|
return fail("failed", `One reported an error: ${oauthError}.`);
|
|
247
|
-
// The returned state names its own cookie. No cookie means a
|
|
248
|
-
// or
|
|
249
|
+
// The returned state names its own cookie. No cookie means a stale,
|
|
250
|
+
// foreign or forged state; the code is never exchanged in that case.
|
|
249
251
|
if (!code || !state || !verifier)
|
|
250
|
-
return fail("
|
|
252
|
+
return fail("expired", "The attempt expired, or its state cookie was missing.");
|
|
251
253
|
|
|
252
254
|
try {
|
|
253
255
|
const tokens = toTokens(
|
package/src/server/types.ts
CHANGED
|
@@ -110,11 +110,17 @@ export interface CompleteAuthorizationInput {
|
|
|
110
110
|
getCookie: (name: string) => string | undefined;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
import type { ConnectFailureCode } from "../types";
|
|
114
|
+
export type { ConnectFailureCode };
|
|
115
|
+
|
|
113
116
|
export type AuthorizationOutcome = "connected" | "declined" | "failed";
|
|
114
117
|
|
|
115
118
|
export interface CompleteAuthorizationResult {
|
|
116
119
|
outcome: AuthorizationOutcome;
|
|
117
|
-
/**
|
|
120
|
+
/** Why it failed, as the code the browser receives. Only the code goes
|
|
121
|
+
* on the return URL; the browser shows fixed text for it. */
|
|
122
|
+
code?: ConnectFailureCode;
|
|
123
|
+
/** What happened, for your logs. Never put it in front of the user. */
|
|
118
124
|
message?: string;
|
|
119
125
|
/** Send the browser here with a 302; it carries `?one_connect=…`. */
|
|
120
126
|
redirectUrl: string;
|
package/src/svelte.ts
CHANGED
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
* Svelte compiler or dependency is involved:
|
|
6
6
|
*
|
|
7
7
|
* <div use:connectButton={{ authorizeUrl: "/api/one/authorize",
|
|
8
|
-
* platforms: ["stripe", "notion"],
|
|
8
|
+
* platforms: ["stripe", "notion"], connected: data.hasOneGrant,
|
|
9
9
|
* onSuccess: () => { ... } }} />
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
12
12
|
mountConnectButton,
|
|
13
|
-
optionsFromProps,
|
|
14
13
|
type ConnectButtonHandle,
|
|
15
14
|
type ConnectButtonProps,
|
|
16
15
|
} from "@withone/connect";
|
|
@@ -24,17 +23,10 @@ export function connectButton(
|
|
|
24
23
|
update: (next: ConnectButtonProps) => void;
|
|
25
24
|
destroy: () => void;
|
|
26
25
|
} {
|
|
27
|
-
|
|
28
|
-
node,
|
|
29
|
-
optionsFromProps(props),
|
|
30
|
-
);
|
|
26
|
+
const handle: ConnectButtonHandle = mountConnectButton(node, props);
|
|
31
27
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
destroy() {
|
|
37
|
-
handle.destroy();
|
|
38
|
-
},
|
|
28
|
+
// In place: the button keeps its state and focus across updates.
|
|
29
|
+
update: (next) => handle.update(next),
|
|
30
|
+
destroy: () => handle.destroy(),
|
|
39
31
|
};
|
|
40
32
|
}
|