@withone/connect 0.1.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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Public types for @withone/connect.
3
+ *
4
+ * The SDK deliberately knows nothing about OAuth internals: state, PKCE
5
+ * and the client secret live on the consumer's backend (see README).
6
+ * The SDK only opens One's connect experience as a modal over the host
7
+ * page and reports how the flow ended.
8
+ */
9
+
10
+ /** Result posted back from the consumer's completion page. */
11
+ export interface OneConnectResult {
12
+ status: "success" | "error";
13
+ /** Human-readable detail for the error case. */
14
+ message?: string;
15
+ }
16
+
17
+ export interface OneConnectProps {
18
+ /**
19
+ * The consumer's OWN backend route that starts the flow. It must
20
+ * generate `state` + PKCE, set them in an httpOnly cookie, and 302
21
+ * to One's /oauth/authorize (full recipe in the README). Must be an
22
+ * absolute URL.
23
+ */
24
+ authorize: {
25
+ url: string;
26
+ };
27
+ /** Theme for One's card. Carried on the URL fragment (#one_theme=…),
28
+ * which survives the redirect chain — the consumer's backend forwards
29
+ * nothing. */
30
+ appTheme?: "dark" | "light";
31
+ /** Fired when the completion page reports success. The token exchange
32
+ * already happened on the consumer's backend by this point. */
33
+ onSuccess?: () => void;
34
+ /** Fired when the completion page reports an error. */
35
+ onError?: (error: string) => void;
36
+ /** Fired when the user closes the card without a result. */
37
+ onClose?: () => void;
38
+ }
39
+
40
+ export interface OneConnectHandle {
41
+ /** Opens One's connect modal over the current page. */
42
+ open: () => void;
43
+ /** Tears everything down: modal frame + listeners. */
44
+ close: () => void;
45
+ }
46
+
47
+ /** Message posted from the completion page up to the host page. */
48
+ export interface OneConnectMessage {
49
+ type: string; // MESSAGE_TYPE constant
50
+ status: "success" | "error";
51
+ message?: string;
52
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ EXIT_MESSAGE_TYPE,
3
+ MESSAGE_TYPE,
4
+ RETURN_MESSAGE_PARAM,
5
+ RETURN_STATUS_PARAM,
6
+ THEME_PARAM,
7
+ } from "./constants";
8
+ import {
9
+ createEmbedIframe,
10
+ getEmbedIframe,
11
+ removeEmbedIframe,
12
+ removeSuccessOverlay,
13
+ showSuccessOverlay,
14
+ } from "./window";
15
+ import type {
16
+ OneConnectHandle,
17
+ OneConnectMessage,
18
+ OneConnectProps,
19
+ } from "./types";
20
+
21
+ // Like useOneAuth, this is a plain function rather than a React hook so
22
+ // it works from any framework.
23
+ export const useOneConnect = (props: OneConnectProps): OneConnectHandle => {
24
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
25
+ let resultDelivered = false;
26
+
27
+ // The theme rides in the URL FRAGMENT: fragments never reach any
28
+ // server and browsers carry them through the whole redirect chain
29
+ // (consumer's authorize route -> One -> the card), so the consumer's
30
+ // backend forwards NOTHING. Embedding needs no signal at all -- the
31
+ // card detects its own iframe with window.self !== window.top.
32
+ const buildUrl = (): string => {
33
+ try {
34
+ const url = new URL(props.authorize.url);
35
+ if (props.appTheme) url.hash = `${THEME_PARAM}=${props.appTheme}`;
36
+ return url.toString();
37
+ } catch {
38
+ return props.authorize.url;
39
+ }
40
+ };
41
+
42
+ const teardown = () => {
43
+ if (typeof window !== "undefined" && messageHandler) {
44
+ window.removeEventListener("message", messageHandler);
45
+ messageHandler = null;
46
+ }
47
+ removeEmbedIframe();
48
+ };
49
+
50
+ const deliver = (status: "success" | "error", message?: string) => {
51
+ if (resultDelivered) return;
52
+ resultDelivered = true;
53
+ teardown();
54
+ // The confirmation beat: by now the frame is gone, so the SDK
55
+ // paints a brief "Access granted" card itself while the host page
56
+ // (already told via onSuccess below) updates underneath it.
57
+ if (status === "success") showSuccessOverlay(props.appTheme);
58
+ try {
59
+ if (status === "success") {
60
+ props.onSuccess?.();
61
+ } else {
62
+ props.onError?.(message ?? "The connection was not completed.");
63
+ }
64
+ } catch {
65
+ /* consumer callback errors are not our problem */
66
+ }
67
+ };
68
+
69
+ // Only trust messages from OUR iframe's browsing context. Exit can
70
+ // come from One's page (cross-origin); results come from the
71
+ // completion page, which is the consumer's own origin because the
72
+ // OAuth redirect brought the frame home.
73
+ const handleMessage = (event: MessageEvent) => {
74
+ const data = event.data as OneConnectMessage | undefined;
75
+ if (!data) return;
76
+
77
+ const iframe = getEmbedIframe();
78
+ if (!iframe || event.source !== iframe.contentWindow) return;
79
+
80
+ if (data.type === EXIT_MESSAGE_TYPE) {
81
+ teardown();
82
+ try {
83
+ props.onClose?.();
84
+ } catch {
85
+ /* ignore */
86
+ }
87
+ return;
88
+ }
89
+ if (
90
+ data.type === MESSAGE_TYPE &&
91
+ event.origin === window.location.origin &&
92
+ (data.status === "success" || data.status === "error")
93
+ ) {
94
+ deliver(data.status, data.message);
95
+ }
96
+ };
97
+
98
+ // Fires on every navigation inside the frame. While the frame is on
99
+ // One's origin, reading its location throws (same-origin policy) and
100
+ // we ignore it. The moment the consumer's callback redirects home —
101
+ // to ANY same-origin URL carrying ?one_connect=success|error — the
102
+ // read succeeds and the flow completes. The consumer writes no
103
+ // completion page and no postMessage; their callback's final
104
+ // redirect IS the completion signal.
105
+ const handleFrameLoad = () => {
106
+ const iframe = getEmbedIframe();
107
+ if (!iframe) return;
108
+ let href: string;
109
+ try {
110
+ href = iframe.contentWindow?.location.href ?? "";
111
+ } catch {
112
+ return; // still cross-origin — not home yet
113
+ }
114
+ let params: URLSearchParams;
115
+ try {
116
+ params = new URL(href).searchParams;
117
+ } catch {
118
+ return;
119
+ }
120
+ const status = params.get(RETURN_STATUS_PARAM);
121
+ if (status !== "success" && status !== "error") return;
122
+ iframe.style.visibility = "hidden"; // no flash of the landing page
123
+ deliver(status, params.get(RETURN_MESSAGE_PARAM) ?? undefined);
124
+ };
125
+
126
+ const open = () => {
127
+ if (typeof window === "undefined") return;
128
+ resultDelivered = false;
129
+
130
+ messageHandler = handleMessage;
131
+ window.addEventListener("message", messageHandler);
132
+ const iframe = createEmbedIframe(buildUrl());
133
+ iframe.addEventListener("load", handleFrameLoad);
134
+ };
135
+
136
+ const close = () => {
137
+ removeSuccessOverlay();
138
+ teardown();
139
+ };
140
+
141
+ return { open, close };
142
+ };
@@ -0,0 +1,136 @@
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 "Access granted" confirmation shown after the grant completes —
46
+ * the same beat as authkit's "Connection established" screen, and
47
+ * dismissed the same way: the user closes it with the ✕ or the Close
48
+ * button, never a timer. By this point the card's iframe has already
49
+ * navigated home and been removed, so the SDK paints this itself.
50
+ * Pure inline styles — the SDK ships no CSS and loads no assets. */
51
+ export function showSuccessOverlay(theme?: "dark" | "light"): void {
52
+ removeSuccessOverlay();
53
+ const dark = theme === "dark";
54
+ const overlay = document.createElement("div");
55
+ overlay.id = SUCCESS_ID;
56
+ Object.assign(overlay.style, {
57
+ position: "fixed",
58
+ inset: "0",
59
+ zIndex: "2147483000",
60
+ display: "flex",
61
+ alignItems: "center",
62
+ justifyContent: "center",
63
+ background: "rgba(8, 8, 8, 0.5)",
64
+ opacity: "0",
65
+ transition: "opacity 160ms ease",
66
+ } as Partial<CSSStyleDeclaration>);
67
+
68
+ const card = document.createElement("div");
69
+ Object.assign(card.style, {
70
+ width: "320px",
71
+ maxWidth: "calc(100vw - 32px)",
72
+ padding: "40px 32px",
73
+ borderRadius: "28px",
74
+ background: dark ? "rgba(25, 25, 25, 0.97)" : "rgba(255, 255, 255, 0.97)",
75
+ border: `1px solid ${dark ? "rgba(255,255,255,0.08)" : "rgba(228,228,223,0.9)"}`,
76
+ display: "flex",
77
+ flexDirection: "column",
78
+ alignItems: "center",
79
+ gap: "16px",
80
+ textAlign: "center",
81
+ fontFamily:
82
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
83
+ } as Partial<CSSStyleDeclaration>);
84
+ const muted = dark ? "#a1a1aa" : "#6b7280";
85
+ card.style.position = "relative";
86
+ card.innerHTML =
87
+ `<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;">` +
88
+ '<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>' +
89
+ "</button>" +
90
+ '<div style="width:56px;height:56px;border-radius:50%;background:#10b981;display:flex;align-items:center;justify-content:center;">' +
91
+ '<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>' +
92
+ "</div>" +
93
+ `<div style="font-size:17px;font-weight:600;letter-spacing:-0.01em;color:${dark ? "#fafafa" : "#111114"};">Access granted</div>` +
94
+ `<div style="font-size:13px;line-height:1.5;max-width:240px;color:${muted};">Your tools are connected. You can pick up right where you left off.</div>` +
95
+ `<button data-one-close style="width:100%;margin-top:8px;padding:10px 0;border:0;border-radius:12px;cursor:pointer;font-size:14px;font-weight:500;background:${dark ? "#fafafa" : "#111114"};color:${dark ? "#111114" : "#fafafa"};">Close</button>`;
96
+
97
+ overlay.appendChild(card);
98
+ document.body.appendChild(overlay);
99
+ requestAnimationFrame(() => {
100
+ overlay.style.opacity = "1";
101
+ });
102
+
103
+ const dismiss = () => {
104
+ window.removeEventListener("keydown", onKeydown);
105
+ overlay.style.opacity = "0";
106
+ window.setTimeout(() => overlay.remove(), 180);
107
+ };
108
+ function onKeydown(event: KeyboardEvent) {
109
+ if (event.key === "Escape") dismiss();
110
+ }
111
+
112
+ for (const button of card.querySelectorAll("[data-one-close]")) {
113
+ button.addEventListener("click", dismiss);
114
+ }
115
+ // The card is dismissed deliberately, never on a timer — but this
116
+ // overlay covers the host page at the top of the stacking context, so
117
+ // it must never be able to strand the app. Clicking the scrim outside
118
+ // the card and pressing Escape are both floors under the buttons: if a
119
+ // button ever fails to render or bind, the user is still not trapped.
120
+ overlay.addEventListener("click", (event) => {
121
+ if (event.target === overlay) dismiss();
122
+ });
123
+ window.addEventListener("keydown", onKeydown);
124
+
125
+ // Send focus somewhere sane for keyboard and screen-reader users, who
126
+ // otherwise land on a full-viewport overlay with no reachable control.
127
+ (card.querySelector("[data-one-close]") as HTMLElement | null)?.focus();
128
+ }
129
+
130
+ export function removeSuccessOverlay(): void {
131
+ document.getElementById(SUCCESS_ID)?.remove();
132
+ }
133
+
134
+ export function getEmbedIframe(): HTMLIFrameElement | null {
135
+ return document.getElementById(IFRAME_ID) as HTMLIFrameElement | null;
136
+ }