@withone/connect 0.7.0 → 0.8.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.
@@ -1,41 +1,24 @@
1
1
  import {
2
- EXIT_MESSAGE_TYPE,
3
- MESSAGE_TYPE,
4
2
  RETURN_MESSAGE_PARAM,
5
3
  RETURN_STATUS_PARAM,
6
4
  THEME_PARAM,
7
5
  } from "./constants";
8
- import {
9
- createEmbedIframe,
10
- getEmbedIframe,
11
- removeEmbedIframe,
12
- removeSuccessOverlay,
13
- showErrorOverlay,
14
- showSuccessOverlay,
15
- } from "./window";
16
- import type {
17
- OneConnectHandle,
18
- OneConnectMessage,
19
- OneConnectProps,
20
- } from "./types";
6
+ import type { OneConnectHandle, OneConnectProps } from "./types";
21
7
 
22
8
  // Like useOneAuth, this is a plain function rather than a React hook so
23
9
  // it works from any framework.
24
10
 
25
- /** The return leg is a page LOAD (redirect mode) — only the first hook
26
- * instance on the page consumes it. */
11
+ /** The return leg is a page LOAD — only the first hook instance on the
12
+ * page consumes it. */
27
13
  let returnConsumed = false;
28
14
 
29
15
  export const useOneConnect = (props: OneConnectProps): OneConnectHandle => {
30
- let messageHandler: ((event: MessageEvent) => void) | null = null;
31
16
  let resultDelivered = false;
32
- const mode = props.mode ?? "redirect";
33
17
 
34
18
  // The theme rides in the URL FRAGMENT: fragments never reach any
35
19
  // server and browsers carry them through the whole redirect chain
36
- // (consumer's authorize route -> One -> the card), so the consumer's
37
- // backend forwards NOTHING. Embedding needs no signal at all -- the
38
- // card detects its own iframe with window.self !== window.top.
20
+ // (consumer's authorize route -> One -> the hosted page), so the
21
+ // consumer's backend forwards NOTHING.
39
22
  const buildUrl = (): string => {
40
23
  try {
41
24
  // Relative paths ("/api/one/authorize") resolve against the host
@@ -51,136 +34,70 @@ export const useOneConnect = (props: OneConnectProps): OneConnectHandle => {
51
34
  }
52
35
  };
53
36
 
54
- const teardown = () => {
55
- if (typeof window !== "undefined" && messageHandler) {
56
- window.removeEventListener("message", messageHandler);
57
- messageHandler = null;
58
- }
59
- removeEmbedIframe();
60
- };
61
-
62
- const deliver = (status: "success" | "error", message?: string) => {
63
- if (resultDelivered) return;
64
- resultDelivered = true;
65
- teardown();
66
- // The confirmation beat: by now the frame is gone, so the SDK
67
- // paints the result card itself — success AND error both live in
68
- // the widget surface (same as authkit), never only in the host UI.
69
- if (status === "success") showSuccessOverlay(props.appTheme);
70
- else showErrorOverlay(props.appTheme, message);
71
- try {
72
- if (status === "success") {
73
- props.onSuccess?.();
74
- } else {
75
- props.onError?.(message ?? "The connection was not completed.");
76
- }
77
- } catch {
78
- /* consumer callback errors are not our problem */
79
- }
80
- };
81
-
82
- // Only trust messages from OUR iframe's browsing context. Exit can
83
- // come from One's page (cross-origin); results come from the
84
- // completion page, which is the consumer's own origin because the
85
- // OAuth redirect brought the frame home.
86
- const handleMessage = (event: MessageEvent) => {
87
- const data = event.data as OneConnectMessage | undefined;
88
- if (!data) return;
89
-
90
- const iframe = getEmbedIframe();
91
- if (!iframe || event.source !== iframe.contentWindow) return;
92
-
93
- if (data.type === EXIT_MESSAGE_TYPE) {
94
- teardown();
95
- try {
96
- props.onClose?.();
97
- } catch {
98
- /* ignore */
99
- }
100
- return;
101
- }
102
- if (
103
- data.type === MESSAGE_TYPE &&
104
- event.origin === window.location.origin &&
105
- (data.status === "success" || data.status === "error")
106
- ) {
107
- deliver(data.status, data.message);
108
- }
109
- };
110
-
111
- // Fires on every navigation inside the frame. While the frame is on
112
- // One's origin, reading its location throws (same-origin policy) and
113
- // we ignore it. The moment the consumer's callback redirects home —
114
- // to ANY same-origin URL carrying ?one_connect=success|error — the
115
- // read succeeds and the flow completes. The consumer writes no
116
- // completion page and no postMessage; their callback's final
117
- // redirect IS the completion signal.
118
- const handleFrameLoad = () => {
119
- const iframe = getEmbedIframe();
120
- if (!iframe) return;
121
- let href: string;
122
- try {
123
- href = iframe.contentWindow?.location.href ?? "";
124
- } catch {
125
- return; // still cross-origin — not home yet
126
- }
127
- let params: URLSearchParams;
128
- try {
129
- params = new URL(href).searchParams;
130
- } catch {
131
- return;
132
- }
133
- const status = params.get(RETURN_STATUS_PARAM);
134
- if (status !== "success" && status !== "error") return;
135
- iframe.style.visibility = "hidden"; // no flash of the landing page
136
- deliver(status, params.get(RETURN_MESSAGE_PARAM) ?? undefined);
137
- };
138
-
37
+ // Full-page hosted flow, Stripe-Checkout style: same tab, One's own
38
+ // domain (first-party cookies — works in every browser). The app's
39
+ // callback redirect brings the user home; detection below picks it
40
+ // up on the next load.
139
41
  const open = () => {
140
42
  if (typeof window === "undefined") return;
141
- if (mode === "redirect") {
142
- // Full-page hosted flow, Stripe-Checkout style: same tab, One's
143
- // own domain (first-party — works in every browser). The app's
144
- // callback redirect brings the user home; detection below picks
145
- // it up on the next load.
146
- window.location.assign(buildUrl());
147
- return;
148
- }
149
- resultDelivered = false;
150
-
151
- messageHandler = handleMessage;
152
- window.addEventListener("message", messageHandler);
153
- const iframe = createEmbedIframe(buildUrl());
154
- iframe.addEventListener("load", handleFrameLoad);
43
+ window.location.assign(buildUrl());
155
44
  };
156
45
 
157
- const close = (options?: { keepResult?: boolean }) => {
158
- // A shown result card must be able to OUTLIVE the trigger: hosts
159
- // naturally unmount the button the moment onSuccess flips their
160
- // state, and that unmount must not eat the confirmation. The
161
- // overlay dismisses only via its own controls (X / Close / Esc /
162
- // scrim) unless the host explicitly closes everything.
163
- if (!options?.keepResult) removeSuccessOverlay();
164
- teardown();
165
- };
46
+ // Nothing to dismantle in the full-page flow — kept so hosts (and the
47
+ // button's destroy path) can call it unconditionally.
48
+ const close = (_options?: { keepResult?: boolean }) => {};
166
49
 
167
- // Redirect-mode return detection: the app's callback redirected the
168
- // TAB to a URL carrying ?one_connect=… — deliver it once, scrub the
169
- // params so a refresh doesn't re-fire, and paint the result card.
50
+ // Return detection: the app's callback redirected the TAB to a URL
51
+ // carrying ?one_connect=… — deliver it once and scrub the params so a
52
+ // refresh doesn't re-fire.
170
53
  if (typeof window !== "undefined" && !returnConsumed) {
171
54
  const search = new URLSearchParams(window.location.search);
172
55
  const returnStatus = search.get(RETURN_STATUS_PARAM);
173
56
  if (returnStatus === "success" || returnStatus === "error") {
174
57
  returnConsumed = true;
175
58
  const returnMessage = search.get(RETURN_MESSAGE_PARAM) ?? undefined;
176
- search.delete(RETURN_STATUS_PARAM);
177
- search.delete(RETURN_MESSAGE_PARAM);
178
- const clean =
179
- window.location.pathname +
180
- (search.toString() ? `?${search.toString()}` : "") +
181
- window.location.hash;
182
- window.history.replaceState(null, "", clean);
183
- window.setTimeout(() => deliver(returnStatus, returnMessage), 0);
59
+ // Scrub-and-verify: this code runs during the app's first render,
60
+ // and frameworks that manage the history themselves (Next.js App
61
+ // Router) sync the address bar back to THEIR canonical URL when
62
+ // hydration completes — silently undoing a one-shot replaceState.
63
+ // So scrub now, then re-check a few beats later and scrub again if
64
+ // the params were restored.
65
+ const scrub = () => {
66
+ const params = new URLSearchParams(window.location.search);
67
+ if (
68
+ !params.has(RETURN_STATUS_PARAM) &&
69
+ !params.has(RETURN_MESSAGE_PARAM)
70
+ )
71
+ return;
72
+ params.delete(RETURN_STATUS_PARAM);
73
+ params.delete(RETURN_MESSAGE_PARAM);
74
+ window.history.replaceState(
75
+ null,
76
+ "",
77
+ window.location.pathname +
78
+ (params.toString() ? `?${params.toString()}` : "") +
79
+ window.location.hash,
80
+ );
81
+ };
82
+ scrub();
83
+ for (const delay of [50, 500, 2000]) window.setTimeout(scrub, delay);
84
+ // No overlay: One's HOSTED page already showed the result beat
85
+ // ("You're all set" + countdown / the failure screen) before
86
+ // sending the user home — painting a second card would
87
+ // double-announce. Callbacks fire so the app updates its state.
88
+ window.setTimeout(() => {
89
+ if (resultDelivered) return;
90
+ resultDelivered = true;
91
+ try {
92
+ if (returnStatus === "success") props.onSuccess?.();
93
+ else
94
+ props.onError?.(
95
+ returnMessage ?? "The connection was not completed.",
96
+ );
97
+ } catch {
98
+ /* consumer callback errors are not our problem */
99
+ }
100
+ }, 0);
184
101
  }
185
102
  }
186
103
 
@@ -5,7 +5,6 @@ import type { ConnectButtonOptions, ConnectButtonPlatform } from "./types";
5
5
  * the core mountConnectButton options. */
6
6
  export interface ConnectButtonWrapperProps {
7
7
  authorizeUrl: string;
8
- mode?: "redirect" | "modal";
9
8
  appTheme?: "light" | "dark";
10
9
  onSuccess?: () => void;
11
10
  onError?: (error: string) => void;
@@ -33,7 +32,6 @@ export function optionsFromWrapperProps(
33
32
  connect: {
34
33
  authorize: { url: props.authorizeUrl },
35
34
  appTheme: props.appTheme,
36
- mode: props.mode,
37
35
  onSuccess: () => cb.onSuccess?.(),
38
36
  onError: (error) => cb.onError?.(error),
39
37
  onClose: () => cb.onClose?.(),
@@ -1,12 +0,0 @@
1
- import type { OneConnectResult } from "./types";
2
- /**
3
- * OPTIONAL. The standard integration needs no completion page at all:
4
- * the callback route's final redirect carries ?one_connect=success (or
5
- * error) on any same-origin URL, and the SDK reads it off the frame's
6
- * location directly. Use this helper only if you render a custom
7
- * completion page and want to signal the SDK from it explicitly.
8
- *
9
- * Returns false when it had nothing to do (not inside a frame) — e.g.
10
- * the user opened the page directly. Render fallback UI in that case.
11
- */
12
- export declare function completeOneConnect(result?: OneConnectResult): boolean;
@@ -1,15 +0,0 @@
1
- export declare const IFRAME_ID = "one-connect-frame";
2
- export declare function createEmbedIframe(url: string): HTMLIFrameElement;
3
- export declare function removeEmbedIframe(): void;
4
- export declare const SUCCESS_ID = "one-connect-success";
5
- /** The result confirmation shown after the flow ends — the same beat as
6
- * authkit's in-frame result screens (success AND error both render in
7
- * the widget surface, never only in the host page), dismissed the same
8
- * way: the user closes it with the ✕ or the Close button, never a
9
- * timer. By this point the card's iframe has already navigated home
10
- * and been removed, so the SDK paints this itself. Pure inline styles
11
- * — the SDK ships no CSS and loads no assets. */
12
- export declare function showSuccessOverlay(theme?: "dark" | "light"): void;
13
- export declare function showErrorOverlay(theme?: "dark" | "light", message?: string): void;
14
- export declare function removeSuccessOverlay(): void;
15
- export declare function getEmbedIframe(): HTMLIFrameElement | null;
package/src/complete.ts DELETED
@@ -1,36 +0,0 @@
1
- import { MESSAGE_TYPE } from "./constants";
2
- import type { OneConnectMessage, OneConnectResult } from "./types";
3
-
4
- /**
5
- * OPTIONAL. The standard integration needs no completion page at all:
6
- * the callback route's final redirect carries ?one_connect=success (or
7
- * error) on any same-origin URL, and the SDK reads it off the frame's
8
- * location directly. Use this helper only if you render a custom
9
- * completion page and want to signal the SDK from it explicitly.
10
- *
11
- * Returns false when it had nothing to do (not inside a frame) — e.g.
12
- * the user opened the page directly. Render fallback UI in that case.
13
- */
14
- export function completeOneConnect(
15
- result: OneConnectResult = { status: "success" }
16
- ): boolean {
17
- if (typeof window === "undefined") return false;
18
-
19
- if (window.parent && window.parent !== window) {
20
- const message: OneConnectMessage = {
21
- type: MESSAGE_TYPE,
22
- status: result.status,
23
- message: result.message,
24
- };
25
- try {
26
- // Target the consumer origin only — never "*". The host page
27
- // additionally checks event.origin before trusting the message.
28
- window.parent.postMessage(message, window.location.origin);
29
- return true;
30
- } catch {
31
- return false;
32
- }
33
- }
34
-
35
- return false;
36
- }
@@ -1,171 +0,0 @@
1
- // Frame management for @withone/connect.
2
- //
3
- // The SDK has exactly one presentation: an authkit-style modal. A
4
- // full-viewport transparent iframe sits over the host page; One's
5
- // connect page renders a scrim + centered card inside it, so the host
6
- // app stays visible and dimmed underneath. Works at every viewport
7
- // size — the card is responsive and the frame is the viewport.
8
- //
9
- // Transport note: every step of the flow rides on the user's One
10
- // session cookie, which is a THIRD-PARTY cookie when the host page is
11
- // on a different site than One. Production embedding therefore relies
12
- // on One serving that cookie as `Partitioned` (CHIPS) and allowing the
13
- // client's domain via frame-ancestors (RFC 6749 §10.13). Same-site
14
- // setups (e.g. localhost dev) work everywhere as-is.
15
-
16
- export const IFRAME_ID = "one-connect-frame";
17
-
18
- export function createEmbedIframe(url: string): HTMLIFrameElement {
19
- removeEmbedIframe();
20
- const iframe = document.createElement("iframe");
21
- iframe.id = IFRAME_ID;
22
- iframe.src = url;
23
- iframe.setAttribute("allowtransparency", "true");
24
- Object.assign(iframe.style, {
25
- position: "fixed",
26
- inset: "0",
27
- width: "100%",
28
- height: "100%",
29
- border: "0",
30
- zIndex: "2147483000",
31
- background: "transparent",
32
- colorScheme: "normal", // keep the transparent viewport from being painted
33
- } as Partial<CSSStyleDeclaration>);
34
- document.body.appendChild(iframe);
35
- return iframe;
36
- }
37
-
38
- export function removeEmbedIframe(): void {
39
- const existing = document.getElementById(IFRAME_ID);
40
- if (existing) existing.remove();
41
- }
42
-
43
- export const SUCCESS_ID = "one-connect-success";
44
-
45
- /** The result confirmation shown after the flow ends — the same beat as
46
- * authkit's in-frame result screens (success AND error both render in
47
- * the widget surface, never only in the host page), dismissed the same
48
- * way: the user closes it with the ✕ or the Close button, never a
49
- * timer. By this point the card's iframe has already navigated home
50
- * and been removed, so the SDK paints this itself. Pure inline styles
51
- * — the SDK ships no CSS and loads no assets. */
52
- export function showSuccessOverlay(theme?: "dark" | "light"): void {
53
- showResultOverlay("success", theme);
54
- }
55
-
56
- export function showErrorOverlay(
57
- theme?: "dark" | "light",
58
- message?: string,
59
- ): void {
60
- showResultOverlay("error", theme, message);
61
- }
62
-
63
- function showResultOverlay(
64
- status: "success" | "error",
65
- theme?: "dark" | "light",
66
- message?: string,
67
- ): void {
68
- removeSuccessOverlay();
69
- const dark = theme === "dark";
70
- const overlay = document.createElement("div");
71
- overlay.id = SUCCESS_ID;
72
- Object.assign(overlay.style, {
73
- position: "fixed",
74
- inset: "0",
75
- zIndex: "2147483000",
76
- display: "flex",
77
- alignItems: "center",
78
- justifyContent: "center",
79
- background: "rgba(8, 8, 8, 0.5)",
80
- opacity: "0",
81
- transition: "opacity 160ms ease",
82
- } as Partial<CSSStyleDeclaration>);
83
-
84
- // Same geometry as every other screen in the flow: the authkit card
85
- // is 520×520 (body) with a 48px "Secured by" strip under it — the
86
- // success screen must not read as a different, smaller surface.
87
- const card = document.createElement("div");
88
- Object.assign(card.style, {
89
- width: "520px",
90
- maxWidth: "calc(100vw - 16px)",
91
- height: "520px",
92
- maxHeight: "calc(100vh - 16px)",
93
- display: "flex",
94
- flexDirection: "column",
95
- textAlign: "center",
96
- fontFamily:
97
- "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
98
- } as Partial<CSSStyleDeclaration>);
99
- const muted = dark ? "#a1a1aa" : "#6b7280";
100
- const cardBg = dark ? "rgba(25, 25, 25, 0.97)" : "rgba(255, 255, 255, 0.97)";
101
- const cardBorder = dark ? "rgba(255,255,255,0.08)" : "rgba(228,228,223,0.9)";
102
- const footerBg = dark ? "rgba(45, 45, 44, 0.9)" : "rgba(232, 232, 228, 0.95)";
103
- card.innerHTML =
104
- `<div style="position:relative;flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px 20px;border-radius:28px 28px 0 0;background:${cardBg};border:1px solid ${cardBorder};border-bottom:0;">` +
105
- `<button data-one-close aria-label="Close" style="position:absolute;top:16px;right:16px;width:20px;height:20px;padding:0;border:0;background:none;cursor:pointer;color:${muted};line-height:0;">` +
106
- '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>' +
107
- "</button>" +
108
- `<div style="width:56px;height:56px;border-radius:50%;background:${status === "success" ? "#10b981" : "#ef4444"};display:flex;align-items:center;justify-content:center;">` +
109
- (status === "success"
110
- ? '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>'
111
- : '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>') +
112
- "</div>" +
113
- `<div style="font-size:18px;font-weight:600;letter-spacing:-0.01em;color:${dark ? "#fafafa" : "#111114"};">${status === "success" ? "Access granted" : "Connection failed"}</div>` +
114
- `<div data-one-result-body style="font-size:13px;line-height:1.5;max-width:280px;color:${muted};"></div>` +
115
- `<button data-one-close style="width:100%;margin-top:8px;padding:11px 0;border:0;border-radius:12px;cursor:pointer;font-size:14px;font-weight:500;background:${dark ? "#fafafa" : "#111114"};color:${dark ? "#111114" : "#fafafa"};">Close</button>` +
116
- "</div>" +
117
- `<div style="height:48px;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:0 0 28px 28px;background:${footerBg};border:1px solid ${cardBorder};border-top:0;">` +
118
- `<span style="font-size:11px;color:${muted};">Secured by</span>` +
119
- `<span style="display:inline-flex;color:${dark ? "#fafafa" : "#111114"};"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4"><circle cx="12" cy="12" r="8.5"/></svg></span>` +
120
- `<span style="font-size:12px;font-weight:600;letter-spacing:-0.02em;color:${dark ? "#fafafa" : "#111114"};">one</span>` +
121
- "</div>";
122
-
123
- // Message text arrives from a URL param — set as TEXT, never HTML.
124
- const body = card.querySelector("[data-one-result-body]");
125
- if (body)
126
- body.textContent =
127
- status === "success"
128
- ? "Your tools are connected. You can pick up right where you left off."
129
- : (message ??
130
- "The connection was not completed. You can close this and try again.");
131
-
132
- overlay.appendChild(card);
133
- document.body.appendChild(overlay);
134
- requestAnimationFrame(() => {
135
- overlay.style.opacity = "1";
136
- });
137
-
138
- const dismiss = () => {
139
- window.removeEventListener("keydown", onKeydown);
140
- overlay.style.opacity = "0";
141
- window.setTimeout(() => overlay.remove(), 180);
142
- };
143
- function onKeydown(event: KeyboardEvent) {
144
- if (event.key === "Escape") dismiss();
145
- }
146
-
147
- for (const button of card.querySelectorAll("[data-one-close]")) {
148
- button.addEventListener("click", dismiss);
149
- }
150
- // The card is dismissed deliberately, never on a timer — but this
151
- // overlay covers the host page at the top of the stacking context, so
152
- // it must never be able to strand the app. Clicking the scrim outside
153
- // the card and pressing Escape are both floors under the buttons: if a
154
- // button ever fails to render or bind, the user is still not trapped.
155
- overlay.addEventListener("click", (event) => {
156
- if (event.target === overlay) dismiss();
157
- });
158
- window.addEventListener("keydown", onKeydown);
159
-
160
- // Send focus somewhere sane for keyboard and screen-reader users, who
161
- // otherwise land on a full-viewport overlay with no reachable control.
162
- (card.querySelector("[data-one-close]") as HTMLElement | null)?.focus();
163
- }
164
-
165
- export function removeSuccessOverlay(): void {
166
- document.getElementById(SUCCESS_ID)?.remove();
167
- }
168
-
169
- export function getEmbedIframe(): HTMLIFrameElement | null {
170
- return document.getElementById(IFRAME_ID) as HTMLIFrameElement | null;
171
- }