@withone/connect 0.8.3 → 0.9.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 +158 -370
- package/dist/button.d.ts +9 -10
- package/dist/constants.d.ts +8 -4
- package/dist/index.cjs.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.esm.js +1 -1
- package/dist/next.cjs.js +104 -0
- package/dist/next.d.ts +36 -0
- package/dist/next.esm.js +98 -0
- package/dist/node.cjs.js +69 -0
- package/dist/node.d.ts +33 -0
- package/dist/node.esm.js +67 -0
- package/dist/platforms.d.ts +21 -0
- package/dist/react.cjs.js +1 -1
- package/dist/react.d.ts +2 -19
- package/dist/react.esm.js +1 -1
- package/dist/return.d.ts +11 -0
- package/dist/server/index.cjs.js +349 -0
- package/dist/server/index.d.ts +32 -0
- package/dist/server/index.esm.js +344 -0
- package/dist/server/oauth.d.ts +24 -0
- package/dist/server/types.d.ts +136 -0
- package/dist/svelte.cjs.js +1 -1
- package/dist/svelte.d.ts +14 -4
- package/dist/svelte.esm.js +1 -1
- package/dist/types.d.ts +80 -0
- package/dist/useOneConnect.d.ts +13 -2
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +20 -18
- package/dist/vue.esm.js +1 -1
- package/dist/wrapper-options.d.ts +20 -15
- package/package.json +37 -15
- package/skills/one-connect/SKILL.md +194 -0
- package/src/button.ts +106 -157
- package/src/constants.ts +10 -4
- package/src/index.ts +17 -2
- package/src/next.ts +133 -0
- package/src/node.ts +107 -0
- package/src/platforms.ts +89 -0
- package/src/react.ts +23 -66
- package/src/return.ts +30 -0
- package/src/server/index.ts +382 -0
- package/src/server/oauth.ts +98 -0
- package/src/server/types.ts +155 -0
- package/src/svelte.ts +15 -15
- package/src/types.ts +87 -0
- package/src/useOneConnect.ts +36 -69
- package/src/vue.ts +23 -29
- package/src/wrapper-options.ts +46 -23
- package/dist/index.umd.js +0 -1
- package/dist/types/index.d.ts +0 -92
- package/src/react-types.d.ts +0 -23
- package/src/svelte-types.d.ts +0 -27
- package/src/types/index.d.ts +0 -92
- package/src/vue-types.d.ts +0 -16
package/src/button.ts
CHANGED
|
@@ -1,78 +1,63 @@
|
|
|
1
|
+
import { normalizePlatforms, parsePlatformsAttribute } from "./platforms";
|
|
1
2
|
import { useOneConnect } from "./useOneConnect";
|
|
2
3
|
import type {
|
|
3
4
|
ConnectButtonHandle,
|
|
4
5
|
ConnectButtonOptions,
|
|
6
|
+
ConnectButtonState,
|
|
5
7
|
OneConnectHandle,
|
|
6
8
|
} from "./types";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the same way. Zero dependencies, zero One URLs — provider icons are
|
|
14
|
-
* passed in by the consumer.
|
|
11
|
+
* The pre-built trigger for the connect flow. Optional: `useOneConnect`
|
|
12
|
+
* with any element remains fully supported. Framework-agnostic: it
|
|
13
|
+
* renders real DOM into a container, so React, Vue, Svelte and plain
|
|
14
|
+
* HTML all mount it the same way.
|
|
15
15
|
*
|
|
16
|
-
* The button owns the flow wiring: click opens the
|
|
17
|
-
* turns "Connecting
|
|
18
|
-
*
|
|
19
|
-
* verb never changes).
|
|
16
|
+
* The button owns the flow wiring: a click opens the flow, the label
|
|
17
|
+
* turns "Connecting" while the tab is away, and lands on "Connected"
|
|
18
|
+
* when the return carries a success.
|
|
20
19
|
*/
|
|
21
20
|
|
|
22
21
|
const STYLE_ID = "one-connect-button-styles";
|
|
23
22
|
const EASE = "cubic-bezier(.2,.9,.25,1)";
|
|
23
|
+
const MAX_VISIBLE_CHIPS = 3;
|
|
24
|
+
|
|
25
|
+
/** One's palette (see the Clockwork design system): lime is the brand
|
|
26
|
+
* call-to-action, spring is success, carbon is the ink. */
|
|
27
|
+
const LIME = "#CCFF00";
|
|
28
|
+
const SPRING = "#3FE3A5";
|
|
29
|
+
const CARBON = "#0A0C0B";
|
|
24
30
|
|
|
25
31
|
interface Palette {
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
buttonBackground: string;
|
|
33
|
+
buttonForeground: string;
|
|
28
34
|
surface: string;
|
|
29
|
-
|
|
35
|
+
foreground: string;
|
|
30
36
|
muted: string;
|
|
31
37
|
line: string;
|
|
32
38
|
shadow: string;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
const LIGHT: Palette = {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
surface: "#
|
|
39
|
-
|
|
42
|
+
buttonBackground: CARBON,
|
|
43
|
+
buttonForeground: "#FFFFFF",
|
|
44
|
+
surface: "#FFFFFF",
|
|
45
|
+
foreground: CARBON,
|
|
40
46
|
muted: "#6B7280",
|
|
41
47
|
line: "#D1D5DB",
|
|
42
48
|
shadow: "0 1px 2px rgba(10,12,11,.08), 0 4px 14px rgba(10,12,11,.06)",
|
|
43
49
|
};
|
|
44
50
|
|
|
45
51
|
const DARK: Palette = {
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
buttonBackground: "#F2F5F3",
|
|
53
|
+
buttonForeground: CARBON,
|
|
48
54
|
surface: "#101312",
|
|
49
|
-
|
|
55
|
+
foreground: "#F2F5F3",
|
|
50
56
|
muted: "#8A938E",
|
|
51
57
|
line: "#2A302D",
|
|
52
58
|
shadow: "0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35)",
|
|
53
59
|
};
|
|
54
60
|
|
|
55
|
-
const LIME = "#CCFF00";
|
|
56
|
-
const SPRING = "#3FE3A5";
|
|
57
|
-
const CARBON = "#0A0C0B";
|
|
58
|
-
|
|
59
|
-
/** One's connector-asset logo for a platform NAME — so a developer can
|
|
60
|
-
* pass `{ name: "Stripe" }` with no imageUrl and still get the real
|
|
61
|
-
* logo. "Google Calendar" → google-calendar.svg. The chip's own onError
|
|
62
|
-
* falls back to the first letter if a name has no matching asset. */
|
|
63
|
-
function logoUrlFromName(name: string): string {
|
|
64
|
-
const slug = name
|
|
65
|
-
.trim()
|
|
66
|
-
.toLowerCase()
|
|
67
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
68
|
-
.replace(/^-+|-+$/g, "");
|
|
69
|
-
return slug
|
|
70
|
-
? `https://assets.withone.ai/connectors/${slug}.svg`
|
|
71
|
-
: "";
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The One ring mark — same glyph the card footer pairs with the
|
|
75
|
-
* wordmark. Inline so the SDK stays free of One URLs. */
|
|
76
61
|
const ONE_MARK_SVG =
|
|
77
62
|
'<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" style="flex-shrink:0"><circle cx="12" cy="12" r="8.5"/></svg>';
|
|
78
63
|
const ARROW_SVG =
|
|
@@ -92,16 +77,17 @@ function ensureStyles(): void {
|
|
|
92
77
|
`.owcb:focus-visible{outline:2px solid ${SPRING};outline-offset:3px}`,
|
|
93
78
|
".owcb[disabled]{cursor:default;opacity:.75;transform:none}",
|
|
94
79
|
".owcb-stack{display:inline-flex;align-items:center;flex-shrink:0}",
|
|
95
|
-
`.owcb-chip{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;overflow:hidden;margin-left:-7px;transition:margin-left .28s ${EASE};position:relative}`,
|
|
80
|
+
`.owcb-chip{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;overflow:hidden;margin-left:-7px;transition:margin-left .28s ${EASE};position:relative;background:#FFFFFF}`,
|
|
96
81
|
".owcb-chip:first-child{margin-left:0}",
|
|
97
82
|
".owcb-chip img{width:14px;height:14px;display:block;object-fit:contain}",
|
|
98
|
-
|
|
83
|
+
`.owcb-chip.owcb-more{font-family:ui-monospace,monospace;font-size:9px;font-weight:500;letter-spacing:-.02em;color:${CARBON}}`,
|
|
84
|
+
`.owcb-chip.owcb-letter{font:600 10px ui-monospace,monospace;color:${CARBON}}`,
|
|
99
85
|
".owcb:hover .owcb-chip{margin-left:-2px}",
|
|
100
86
|
".owcb:hover .owcb-chip:first-child{margin-left:0}",
|
|
101
87
|
".owcb-label{white-space:nowrap}",
|
|
102
88
|
`.owcb-arrow{opacity:.5;transition:transform .2s ${EASE},opacity .2s ${EASE}}`,
|
|
103
89
|
".owcb:hover .owcb-arrow{transform:translateX(2px);opacity:.8}",
|
|
104
|
-
|
|
90
|
+
".owcb-spinner{width:16px;height:16px;border-radius:50%;border:2px solid currentColor;border-top-color:transparent;opacity:.7;animation:owcb-spin .7s linear infinite}",
|
|
105
91
|
"@keyframes owcb-spin{to{transform:rotate(360deg)}}",
|
|
106
92
|
".owcb.owcb-block{display:flex;flex-direction:column;align-items:stretch;width:100%;max-width:420px;text-align:left;padding:16px;border-radius:14px;gap:0}",
|
|
107
93
|
".owcb-block-top{display:flex;align-items:center;justify-content:space-between;gap:12px}",
|
|
@@ -116,12 +102,8 @@ function ensureStyles(): void {
|
|
|
116
102
|
function buildStack(
|
|
117
103
|
options: ConnectButtonOptions,
|
|
118
104
|
chipRing: string,
|
|
119
|
-
moreColor: string,
|
|
120
105
|
): HTMLElement | null {
|
|
121
|
-
|
|
122
|
-
// into a single "+N" chip so the button stays clean and uncrowded.
|
|
123
|
-
const MAX_VISIBLE_CHIPS = 3;
|
|
124
|
-
const platforms = options.platforms ?? [];
|
|
106
|
+
const platforms = normalizePlatforms(options.platforms);
|
|
125
107
|
if (platforms.length === 0 && !options.moreCount) return null;
|
|
126
108
|
const stack = document.createElement("span");
|
|
127
109
|
stack.className = "owcb-stack";
|
|
@@ -129,29 +111,17 @@ function buildStack(
|
|
|
129
111
|
for (const platform of platforms.slice(0, MAX_VISIBLE_CHIPS)) {
|
|
130
112
|
const chip = document.createElement("span");
|
|
131
113
|
chip.className = "owcb-chip";
|
|
132
|
-
chip.
|
|
114
|
+
chip.title = platform.name;
|
|
133
115
|
chip.style.boxShadow = `0 0 0 1.5px ${chipRing}`;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
116
|
+
const img = document.createElement("img");
|
|
117
|
+
img.alt = "";
|
|
118
|
+
img.src = platform.imageUrl;
|
|
119
|
+
img.addEventListener("error", () => {
|
|
120
|
+
img.remove();
|
|
121
|
+
chip.classList.add("owcb-letter");
|
|
138
122
|
chip.textContent = platform.name.charAt(0).toUpperCase();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
};
|
|
142
|
-
const src = platform.imageUrl ?? logoUrlFromName(platform.name);
|
|
143
|
-
if (src) {
|
|
144
|
-
const img = document.createElement("img");
|
|
145
|
-
img.alt = "";
|
|
146
|
-
img.src = src;
|
|
147
|
-
img.addEventListener("error", () => {
|
|
148
|
-
img.remove();
|
|
149
|
-
letterFallback();
|
|
150
|
-
});
|
|
151
|
-
chip.appendChild(img);
|
|
152
|
-
} else {
|
|
153
|
-
letterFallback();
|
|
154
|
-
}
|
|
123
|
+
});
|
|
124
|
+
chip.appendChild(img);
|
|
155
125
|
stack.appendChild(chip);
|
|
156
126
|
}
|
|
157
127
|
const extra =
|
|
@@ -160,9 +130,7 @@ function buildStack(
|
|
|
160
130
|
if (extra > 0) {
|
|
161
131
|
const more = document.createElement("span");
|
|
162
132
|
more.className = "owcb-chip owcb-more";
|
|
163
|
-
more.style.background = "#ffffff";
|
|
164
133
|
more.style.boxShadow = `0 0 0 1.5px ${chipRing}`;
|
|
165
|
-
more.style.color = moreColor;
|
|
166
134
|
more.textContent = `+${extra}`;
|
|
167
135
|
stack.appendChild(more);
|
|
168
136
|
}
|
|
@@ -178,23 +146,20 @@ export const mountConnectButton = (
|
|
|
178
146
|
const palette = options.theme === "dark" ? DARK : LIGHT;
|
|
179
147
|
const variant = options.variant ?? "default";
|
|
180
148
|
const label = options.label ?? "Connect your apps";
|
|
149
|
+
const connectedLabel = options.connectedLabel ?? "Connected";
|
|
181
150
|
|
|
182
|
-
let state:
|
|
151
|
+
let state: ConnectButtonState = "idle";
|
|
183
152
|
let destroyed = false;
|
|
184
153
|
|
|
185
|
-
const
|
|
154
|
+
const flow: OneConnectHandle = useOneConnect({
|
|
186
155
|
...options.connect,
|
|
187
156
|
onSuccess: () => {
|
|
188
157
|
setState("connected");
|
|
189
158
|
options.connect.onSuccess?.();
|
|
190
159
|
},
|
|
191
|
-
onError: (
|
|
192
|
-
setState("idle");
|
|
193
|
-
options.connect.onError?.(error);
|
|
194
|
-
},
|
|
195
|
-
onClose: () => {
|
|
160
|
+
onError: (message) => {
|
|
196
161
|
setState("idle");
|
|
197
|
-
options.connect.
|
|
162
|
+
options.connect.onError?.(message);
|
|
198
163
|
},
|
|
199
164
|
});
|
|
200
165
|
|
|
@@ -202,52 +167,54 @@ export const mountConnectButton = (
|
|
|
202
167
|
button.type = "button";
|
|
203
168
|
button.className = variant === "block" ? "owcb owcb-block" : "owcb";
|
|
204
169
|
button.addEventListener("click", () => {
|
|
205
|
-
if (state
|
|
170
|
+
if (state === "connecting") return;
|
|
206
171
|
setState("connecting");
|
|
207
|
-
|
|
172
|
+
flow.open();
|
|
208
173
|
});
|
|
209
174
|
|
|
175
|
+
const titleFor = (): string =>
|
|
176
|
+
state === "connecting"
|
|
177
|
+
? "Connecting…"
|
|
178
|
+
: state === "connected"
|
|
179
|
+
? connectedLabel
|
|
180
|
+
: label;
|
|
181
|
+
|
|
210
182
|
const paint = () => {
|
|
211
183
|
const accent = variant === "accent";
|
|
212
184
|
const block = variant === "block";
|
|
213
|
-
const
|
|
185
|
+
const background = block
|
|
214
186
|
? palette.surface
|
|
215
187
|
: accent
|
|
216
188
|
? (options.accentColor ?? LIME)
|
|
217
|
-
: palette.
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
button.style.color = state === "connected" && !block ? CARBON :
|
|
189
|
+
: palette.buttonBackground;
|
|
190
|
+
const foreground = block
|
|
191
|
+
? palette.foreground
|
|
192
|
+
: accent
|
|
193
|
+
? CARBON
|
|
194
|
+
: palette.buttonForeground;
|
|
195
|
+
const chipRing = block ? palette.surface : background;
|
|
196
|
+
|
|
197
|
+
button.style.background =
|
|
198
|
+
state === "connected" && !block ? SPRING : background;
|
|
199
|
+
button.style.color = state === "connected" && !block ? CARBON : foreground;
|
|
228
200
|
button.style.boxShadow = block
|
|
229
201
|
? `inset 0 0 0 1px ${palette.line}`
|
|
230
202
|
: state === "connected"
|
|
231
203
|
? "none"
|
|
232
204
|
: accent
|
|
233
|
-
? `0 1px 2px rgba(10,12,11,.1), 0 6px 20px ${
|
|
205
|
+
? `0 1px 2px rgba(10,12,11,.1), 0 6px 20px ${options.accentColor ?? LIME}38`
|
|
234
206
|
: palette.shadow;
|
|
235
207
|
button.disabled = state === "connecting";
|
|
236
208
|
button.innerHTML = "";
|
|
237
209
|
|
|
238
|
-
const stack = buildStack(options, chipRing
|
|
210
|
+
const stack = buildStack(options, chipRing);
|
|
239
211
|
|
|
240
212
|
if (block) {
|
|
241
213
|
const top = document.createElement("span");
|
|
242
214
|
top.className = "owcb-block-top";
|
|
243
215
|
const title = document.createElement("span");
|
|
244
216
|
title.className = "owcb-block-title";
|
|
245
|
-
title.textContent =
|
|
246
|
-
state === "connecting"
|
|
247
|
-
? "Connecting…"
|
|
248
|
-
: state === "connected"
|
|
249
|
-
? (options.connectedLabel ?? "Connected")
|
|
250
|
-
: label;
|
|
217
|
+
title.textContent = titleFor();
|
|
251
218
|
top.appendChild(title);
|
|
252
219
|
if (state === "connecting") {
|
|
253
220
|
const spinner = document.createElement("span");
|
|
@@ -264,17 +231,14 @@ export const mountConnectButton = (
|
|
|
264
231
|
sub.textContent = options.description;
|
|
265
232
|
button.appendChild(sub);
|
|
266
233
|
}
|
|
267
|
-
// Mirrors the card/overlay footer strip: "Secured by" + ring mark
|
|
268
|
-
// + "one" wordmark, so the button and the surface it opens read as
|
|
269
|
-
// the same product.
|
|
270
234
|
const foot = document.createElement("span");
|
|
271
235
|
foot.className = "owcb-block-foot";
|
|
272
236
|
foot.style.color = palette.muted;
|
|
273
237
|
foot.style.borderTop = `1px solid ${palette.line}`;
|
|
274
238
|
foot.innerHTML =
|
|
275
239
|
`<span style="font-size:11px;color:${palette.muted}">Secured by</span>` +
|
|
276
|
-
`<span style="display:inline-flex;color:${palette.
|
|
277
|
-
`<span style="font-size:12px;font-weight:600;letter-spacing:-0.02em;color:${palette.
|
|
240
|
+
`<span style="display:inline-flex;color:${palette.foreground}">${ONE_MARK_SVG}</span>` +
|
|
241
|
+
`<span style="font-size:12px;font-weight:600;letter-spacing:-0.02em;color:${palette.foreground}">one</span>`;
|
|
278
242
|
button.appendChild(foot);
|
|
279
243
|
return;
|
|
280
244
|
}
|
|
@@ -294,12 +258,7 @@ export const mountConnectButton = (
|
|
|
294
258
|
|
|
295
259
|
const text = document.createElement("span");
|
|
296
260
|
text.className = "owcb-label";
|
|
297
|
-
text.textContent =
|
|
298
|
-
state === "connecting"
|
|
299
|
-
? "Connecting…"
|
|
300
|
-
: state === "connected"
|
|
301
|
-
? (options.connectedLabel ?? "Connected")
|
|
302
|
-
: label;
|
|
261
|
+
text.textContent = titleFor();
|
|
303
262
|
button.appendChild(text);
|
|
304
263
|
|
|
305
264
|
if (state === "idle") {
|
|
@@ -310,7 +269,7 @@ export const mountConnectButton = (
|
|
|
310
269
|
}
|
|
311
270
|
};
|
|
312
271
|
|
|
313
|
-
const setState = (next:
|
|
272
|
+
const setState = (next: ConnectButtonState) => {
|
|
314
273
|
if (destroyed) return;
|
|
315
274
|
state = next;
|
|
316
275
|
paint();
|
|
@@ -323,26 +282,24 @@ export const mountConnectButton = (
|
|
|
323
282
|
setState,
|
|
324
283
|
destroy: () => {
|
|
325
284
|
destroyed = true;
|
|
326
|
-
handle.close({ keepResult: true });
|
|
327
285
|
button.remove();
|
|
328
286
|
},
|
|
329
287
|
};
|
|
330
288
|
};
|
|
331
289
|
|
|
332
290
|
/**
|
|
333
|
-
* <one-connect-button
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* (Next.js SSR) never touches HTMLElement.
|
|
291
|
+
* <one-connect-button>: one tag for any framework. The element wires the
|
|
292
|
+
* whole flow from its attributes and manages Connect, Connecting and
|
|
293
|
+
* Connected. Registered automatically in the browser on import; defined
|
|
294
|
+
* inside a function so importing this module on a server never touches
|
|
295
|
+
* HTMLElement.
|
|
339
296
|
*
|
|
340
297
|
* Attributes: authorize-url (required), app-theme, label, variant,
|
|
341
|
-
* theme, platforms (
|
|
298
|
+
* theme, platforms ("stripe, notion" or a JSON array), more-count,
|
|
342
299
|
* description, accent-color, connected-label.
|
|
343
|
-
* Events: "success"
|
|
344
|
-
*
|
|
345
|
-
*
|
|
300
|
+
* Events: "success" and "error" (detail: message), plus the matching
|
|
301
|
+
* onSuccess and onError function properties that React 19, Vue and
|
|
302
|
+
* Svelte set naturally as props.
|
|
346
303
|
*/
|
|
347
304
|
export function registerConnectButton(): void {
|
|
348
305
|
if (typeof window === "undefined" || typeof customElements === "undefined")
|
|
@@ -364,8 +321,7 @@ export function registerConnectButton(): void {
|
|
|
364
321
|
];
|
|
365
322
|
|
|
366
323
|
onSuccess: (() => void) | null = null;
|
|
367
|
-
onError: ((
|
|
368
|
-
onClose: (() => void) | null = null;
|
|
324
|
+
onError: ((message: string) => void) | null = null;
|
|
369
325
|
|
|
370
326
|
private handle: ConnectButtonHandle | null = null;
|
|
371
327
|
|
|
@@ -386,48 +342,28 @@ export function registerConnectButton(): void {
|
|
|
386
342
|
this.handle?.destroy();
|
|
387
343
|
this.handle = null;
|
|
388
344
|
const authorizeUrl = this.getAttribute("authorize-url");
|
|
389
|
-
if (!authorizeUrl) return;
|
|
390
|
-
|
|
391
|
-
let platforms: ConnectButtonOptions["platforms"];
|
|
392
|
-
const rawPlatforms = this.getAttribute("platforms");
|
|
393
|
-
if (rawPlatforms) {
|
|
394
|
-
try {
|
|
395
|
-
const parsed = JSON.parse(rawPlatforms) as unknown;
|
|
396
|
-
if (Array.isArray(parsed)) platforms = parsed;
|
|
397
|
-
} catch {
|
|
398
|
-
/* malformed platforms JSON — render without chips */
|
|
399
|
-
}
|
|
400
|
-
}
|
|
345
|
+
if (!authorizeUrl) return;
|
|
401
346
|
|
|
402
347
|
const moreCountRaw = this.getAttribute("more-count");
|
|
403
|
-
const moreCount = moreCountRaw ? parseInt(moreCountRaw, 10) :
|
|
348
|
+
const moreCount = moreCountRaw ? parseInt(moreCountRaw, 10) : NaN;
|
|
404
349
|
|
|
405
350
|
this.handle = mountConnectButton(this, {
|
|
406
351
|
connect: {
|
|
407
|
-
|
|
408
|
-
appTheme:
|
|
409
|
-
(this.getAttribute("app-theme") as "light" | "dark" | null) ??
|
|
410
|
-
undefined,
|
|
352
|
+
authorizeUrl,
|
|
353
|
+
appTheme: attributeAs(this, "app-theme", ["light", "dark"]),
|
|
411
354
|
onSuccess: () => {
|
|
412
355
|
this.onSuccess?.();
|
|
413
356
|
this.dispatchEvent(new CustomEvent("success"));
|
|
414
357
|
},
|
|
415
|
-
onError: (
|
|
416
|
-
this.onError?.(
|
|
417
|
-
this.dispatchEvent(new CustomEvent("error", { detail:
|
|
418
|
-
},
|
|
419
|
-
onClose: () => {
|
|
420
|
-
this.onClose?.();
|
|
421
|
-
this.dispatchEvent(new CustomEvent("close"));
|
|
358
|
+
onError: (message) => {
|
|
359
|
+
this.onError?.(message);
|
|
360
|
+
this.dispatchEvent(new CustomEvent("error", { detail: message }));
|
|
422
361
|
},
|
|
423
362
|
},
|
|
424
363
|
label: this.getAttribute("label") ?? undefined,
|
|
425
|
-
variant:
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
theme:
|
|
429
|
-
(this.getAttribute("theme") as "light" | "dark" | null) ?? undefined,
|
|
430
|
-
platforms,
|
|
364
|
+
variant: attributeAs(this, "variant", ["default", "accent", "block"]),
|
|
365
|
+
theme: attributeAs(this, "theme", ["light", "dark"]),
|
|
366
|
+
platforms: parsePlatformsAttribute(this.getAttribute("platforms")),
|
|
431
367
|
moreCount: Number.isFinite(moreCount) ? moreCount : undefined,
|
|
432
368
|
description: this.getAttribute("description") ?? undefined,
|
|
433
369
|
accentColor: this.getAttribute("accent-color") ?? undefined,
|
|
@@ -439,4 +375,17 @@ export function registerConnectButton(): void {
|
|
|
439
375
|
customElements.define("one-connect-button", OneConnectButtonElement);
|
|
440
376
|
}
|
|
441
377
|
|
|
378
|
+
/** An attribute narrowed to a known set of values; anything else is
|
|
379
|
+
* treated as unset rather than passed through. */
|
|
380
|
+
function attributeAs<T extends string>(
|
|
381
|
+
element: HTMLElement,
|
|
382
|
+
name: string,
|
|
383
|
+
allowed: readonly T[],
|
|
384
|
+
): T | undefined {
|
|
385
|
+
const value = element.getAttribute(name);
|
|
386
|
+
return value && (allowed as readonly string[]).includes(value)
|
|
387
|
+
? (value as T)
|
|
388
|
+
: undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
442
391
|
registerConnectButton();
|
package/src/constants.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
/** Query params the
|
|
2
|
-
*
|
|
3
|
-
*
|
|
1
|
+
/** Query params the app's callback route puts on its final redirect.
|
|
2
|
+
* The SDK reads them off the page URL when the tab comes home, so
|
|
3
|
+
* there is no completion page to build. */
|
|
4
4
|
export const RETURN_STATUS_PARAM = "one_connect";
|
|
5
5
|
export const RETURN_MESSAGE_PARAM = "one_connect_message";
|
|
6
6
|
|
|
7
7
|
/** Fragment key on the authorize URL carrying the app-chosen theme.
|
|
8
8
|
* A fragment never reaches any server and survives the whole redirect
|
|
9
|
-
* chain, so the
|
|
9
|
+
* chain, so the app's backend forwards nothing. */
|
|
10
10
|
export const THEME_PARAM = "one_theme";
|
|
11
|
+
|
|
12
|
+
/** One's connector logos, by slug. */
|
|
13
|
+
export const CONNECTOR_ASSETS_URL = "https://assets.withone.ai/connectors";
|
|
14
|
+
|
|
15
|
+
/** One's production API. Point `oneApiUrl` elsewhere for development. */
|
|
16
|
+
export const DEFAULT_ONE_API_URL = "https://api.withone.ai";
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
export { useOneConnect } from "./useOneConnect";
|
|
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";
|
|
3
14
|
export type {
|
|
4
|
-
|
|
15
|
+
OneConnectOptions,
|
|
5
16
|
OneConnectHandle,
|
|
6
|
-
|
|
17
|
+
OneConnectReturn,
|
|
18
|
+
OneConnectTheme,
|
|
7
19
|
ConnectButtonOptions,
|
|
8
20
|
ConnectButtonPlatform,
|
|
21
|
+
ConnectButtonPlatformInput,
|
|
22
|
+
ConnectButtonVariant,
|
|
23
|
+
ConnectButtonState,
|
|
9
24
|
ConnectButtonHandle,
|
|
10
25
|
} from "./types";
|
package/src/next.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@withone/connect/next`: the two routes as one file, for any server
|
|
3
|
+
* that speaks the web `Request` and `Response` (Next.js App Router,
|
|
4
|
+
* Remix, SvelteKit, Nuxt/h3 via its adapters, Hono, Bun).
|
|
5
|
+
*
|
|
6
|
+
* // app/api/one/[action]/route.ts
|
|
7
|
+
* import { createOneConnectRoutes } from "@withone/connect/next";
|
|
8
|
+
* import { oneConnect } from "@/lib/one";
|
|
9
|
+
*
|
|
10
|
+
* export const { GET } = createOneConnectRoutes(oneConnect, {
|
|
11
|
+
* identifyUser: async (request) => getSessionUserId(request),
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* That serves /api/one/authorize and /api/one/callback. Register
|
|
15
|
+
* `https://yourapp.com/api/one/callback` as the app's redirect URI.
|
|
16
|
+
*/
|
|
17
|
+
import type { OneConnect } from "./server";
|
|
18
|
+
|
|
19
|
+
export interface OneConnectRoutesOptions {
|
|
20
|
+
/** The app's own id for the signed-in user, or null when nobody is
|
|
21
|
+
* signed in. The grant is stored under this id. */
|
|
22
|
+
identifyUser: (request: Request) => Promise<string | null> | string | null;
|
|
23
|
+
/** Pre-fills the sign-in email on One's page for this user. */
|
|
24
|
+
loginHintFor?: (request: Request) => Promise<string | null> | string | null;
|
|
25
|
+
/** Where to send the browser when nobody is signed in. Answers 401
|
|
26
|
+
* when omitted. */
|
|
27
|
+
signInUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OneConnectRoutes {
|
|
31
|
+
GET: (request: Request) => Promise<Response>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const AUTHORIZE_ROUTE = "authorize";
|
|
35
|
+
export const CALLBACK_ROUTE = "callback";
|
|
36
|
+
|
|
37
|
+
function redirectWithCookies(
|
|
38
|
+
location: string,
|
|
39
|
+
setCookies: string[],
|
|
40
|
+
): Response {
|
|
41
|
+
const headers = new Headers({ Location: location });
|
|
42
|
+
for (const cookie of setCookies) headers.append("Set-Cookie", cookie);
|
|
43
|
+
return new Response(null, { status: 302, headers });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function serializeCookie(
|
|
47
|
+
name: string,
|
|
48
|
+
value: string,
|
|
49
|
+
options: { httpOnly: boolean; secure: boolean; sameSite: "lax"; maxAge: number; path: string },
|
|
50
|
+
): string {
|
|
51
|
+
const parts = [
|
|
52
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
53
|
+
`Path=${options.path}`,
|
|
54
|
+
`Max-Age=${options.maxAge}`,
|
|
55
|
+
`SameSite=Lax`,
|
|
56
|
+
];
|
|
57
|
+
if (options.httpOnly) parts.push("HttpOnly");
|
|
58
|
+
if (options.secure) parts.push("Secure");
|
|
59
|
+
return parts.join("; ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expireCookie(name: string, path: string): string {
|
|
63
|
+
return `${name}=; Path=${path}; Max-Age=0; SameSite=Lax; HttpOnly`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readCookie(request: Request, name: string): string | undefined {
|
|
67
|
+
const header = request.headers.get("cookie");
|
|
68
|
+
if (!header) return undefined;
|
|
69
|
+
for (const part of header.split(";")) {
|
|
70
|
+
const [rawName, ...rest] = part.trim().split("=");
|
|
71
|
+
if (rawName === name) return decodeURIComponent(rest.join("="));
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The last path segment names the leg: ".../authorize" or ".../callback". */
|
|
77
|
+
export function routeFor(url: string): string {
|
|
78
|
+
const pathname = new URL(url).pathname.replace(/\/+$/, "");
|
|
79
|
+
return pathname.slice(pathname.lastIndexOf("/") + 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createOneConnectRoutes(
|
|
83
|
+
oneConnect: OneConnect,
|
|
84
|
+
options: OneConnectRoutesOptions,
|
|
85
|
+
): OneConnectRoutes {
|
|
86
|
+
const cookiePath = (): string =>
|
|
87
|
+
oneConnect.startAuthorization().cookie.options.path;
|
|
88
|
+
|
|
89
|
+
const requireUser = async (request: Request): Promise<string | Response> => {
|
|
90
|
+
const userId = await options.identifyUser(request);
|
|
91
|
+
if (userId) return userId;
|
|
92
|
+
if (options.signInUrl)
|
|
93
|
+
return redirectWithCookies(new URL(options.signInUrl, request.url).toString(), []);
|
|
94
|
+
return new Response("Sign in to your account before connecting One.", {
|
|
95
|
+
status: 401,
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const authorize = async (request: Request): Promise<Response> => {
|
|
100
|
+
const user = await requireUser(request);
|
|
101
|
+
if (user instanceof Response) return user;
|
|
102
|
+
const loginHint = (await options.loginHintFor?.(request)) ?? undefined;
|
|
103
|
+
const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
|
|
104
|
+
return redirectWithCookies(redirectUrl, [
|
|
105
|
+
serializeCookie(cookie.name, cookie.value, cookie.options),
|
|
106
|
+
]);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const callback = async (request: Request): Promise<Response> => {
|
|
110
|
+
const user = await requireUser(request);
|
|
111
|
+
if (user instanceof Response) return user;
|
|
112
|
+
const result = await oneConnect.completeAuthorization({
|
|
113
|
+
userId: user,
|
|
114
|
+
url: request.url,
|
|
115
|
+
getCookie: (name) => readCookie(request, name),
|
|
116
|
+
});
|
|
117
|
+
return redirectWithCookies(
|
|
118
|
+
result.redirectUrl,
|
|
119
|
+
result.clearCookieName
|
|
120
|
+
? [expireCookie(result.clearCookieName, cookiePath())]
|
|
121
|
+
: [],
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
GET: async (request: Request): Promise<Response> => {
|
|
127
|
+
const route = routeFor(request.url);
|
|
128
|
+
if (route === AUTHORIZE_ROUTE) return authorize(request);
|
|
129
|
+
if (route === CALLBACK_ROUTE) return callback(request);
|
|
130
|
+
return new Response("Not found", { status: 404 });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|