@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/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
- export const RETURN_MESSAGE_PARAM = "one_connect_message";
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 "./useOneConnect";
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
- OneConnectOptions,
16
- OneConnectHandle,
17
- OneConnectReturn,
18
- OneConnectTheme,
19
- ConnectButtonOptions,
4
+ ConnectButtonHandle,
20
5
  ConnectButtonPlatform,
21
6
  ConnectButtonPlatformInput,
22
- ConnectButtonVariant,
7
+ ConnectButtonProps,
8
+ ConnectButtonSize,
23
9
  ConnectButtonState,
24
- ConnectButtonHandle,
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
- /** "google-calendar" -> "Google Calendar". */
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 slug
19
- .split(/[-_]+/)
20
- .filter(Boolean)
21
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
22
- .join(" ");
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
- * A component over the framework-agnostic core, so React apps get real
5
- * props (arrays, functions) instead of the custom element's string
6
- * attributes. React is a peer dependency of this subpath only.
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
- optionsFromProps,
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
- // Callbacks stay fresh without remounting the button.
29
- const callbacksRef = useRef({
30
- onSuccess: props.onSuccess,
31
- onError: props.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
- optionsFromProps(props, {
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
- // Remount only when a visual or config input changes; `identity`
51
- // captures exactly those.
72
+ // Mounted once; later changes go through update() below.
52
73
  // eslint-disable-next-line react-hooks/exhaustive-deps
53
- }, [identity]);
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 { RETURN_MESSAGE_PARAM, RETURN_STATUS_PARAM } from "./constants";
2
- import type { OneConnectReturn } from "./types";
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
- /** Reads the outcome the callback route put on the return URL. */
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 !== "success" && status !== "error") return null;
9
- const message = params.get(RETURN_MESSAGE_PARAM) ?? undefined;
10
- return message ? { status, message } : { status };
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(RETURN_STATUS_PARAM);
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 params.has(RETURN_STATUS_PARAM) || params.has(RETURN_MESSAGE_PARAM);
51
+ return OWN_PARAMS.some((name) => params.has(name));
30
52
  }
@@ -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, RETURN_MESSAGE_PARAM, RETURN_STATUS_PARAM } from "../constants";
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", message?: string): string => {
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 (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
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
- outcome: "declined" | "failed",
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", message),
241
+ redirectUrl: returnUrl("error", failure),
240
242
  clearCookieName: cookieName,
241
243
  });
242
244
 
243
245
  if (oauthError === "access_denied")
244
- return fail("declined", "You cancelled the request.");
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 forged
248
- // or stale state; the code is never exchanged in that case.
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("failed", "The sign-in attempt expired or was tampered with.");
252
+ return fail("expired", "The attempt expired, or its state cookie was missing.");
251
253
 
252
254
  try {
253
255
  const tokens = toTokens(
@@ -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
- /** Safe to show to the user when the outcome is not "connected". */
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
- let handle: ConnectButtonHandle = mountConnectButton(
28
- node,
29
- optionsFromProps(props),
30
- );
26
+ const handle: ConnectButtonHandle = mountConnectButton(node, props);
31
27
  return {
32
- update(next: ConnectButtonProps) {
33
- handle.destroy();
34
- handle = mountConnectButton(node, optionsFromProps(next));
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
  }