@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.
- package/LICENSE +674 -0
- package/README.md +361 -0
- package/dist/complete.d.ts +12 -0
- package/dist/constants.d.ts +15 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.esm.js +1 -0
- package/dist/index.umd.js +1 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/useOneConnect.d.ts +2 -0
- package/dist/window/index.d.ts +13 -0
- package/package.json +63 -0
- package/src/complete.ts +36 -0
- package/src/constants.ts +23 -0
- package/src/index.ts +7 -0
- package/src/types/index.d.ts +52 -0
- package/src/useOneConnect.ts +142 -0
- package/src/window/index.ts +136 -0
|
@@ -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
|
+
}
|