@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.
Files changed (55) hide show
  1. package/README.md +158 -370
  2. package/dist/button.d.ts +9 -10
  3. package/dist/constants.d.ts +8 -4
  4. package/dist/index.cjs.js +1 -0
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.esm.js +1 -1
  7. package/dist/next.cjs.js +104 -0
  8. package/dist/next.d.ts +36 -0
  9. package/dist/next.esm.js +98 -0
  10. package/dist/node.cjs.js +69 -0
  11. package/dist/node.d.ts +33 -0
  12. package/dist/node.esm.js +67 -0
  13. package/dist/platforms.d.ts +21 -0
  14. package/dist/react.cjs.js +1 -1
  15. package/dist/react.d.ts +2 -19
  16. package/dist/react.esm.js +1 -1
  17. package/dist/return.d.ts +11 -0
  18. package/dist/server/index.cjs.js +349 -0
  19. package/dist/server/index.d.ts +32 -0
  20. package/dist/server/index.esm.js +344 -0
  21. package/dist/server/oauth.d.ts +24 -0
  22. package/dist/server/types.d.ts +136 -0
  23. package/dist/svelte.cjs.js +1 -1
  24. package/dist/svelte.d.ts +14 -4
  25. package/dist/svelte.esm.js +1 -1
  26. package/dist/types.d.ts +80 -0
  27. package/dist/useOneConnect.d.ts +13 -2
  28. package/dist/vue.cjs.js +1 -1
  29. package/dist/vue.d.ts +20 -18
  30. package/dist/vue.esm.js +1 -1
  31. package/dist/wrapper-options.d.ts +20 -15
  32. package/package.json +37 -15
  33. package/skills/one-connect/SKILL.md +194 -0
  34. package/src/button.ts +106 -157
  35. package/src/constants.ts +10 -4
  36. package/src/index.ts +17 -2
  37. package/src/next.ts +133 -0
  38. package/src/node.ts +107 -0
  39. package/src/platforms.ts +89 -0
  40. package/src/react.ts +23 -66
  41. package/src/return.ts +30 -0
  42. package/src/server/index.ts +382 -0
  43. package/src/server/oauth.ts +98 -0
  44. package/src/server/types.ts +155 -0
  45. package/src/svelte.ts +15 -15
  46. package/src/types.ts +87 -0
  47. package/src/useOneConnect.ts +36 -69
  48. package/src/vue.ts +23 -29
  49. package/src/wrapper-options.ts +46 -23
  50. package/dist/index.umd.js +0 -1
  51. package/dist/types/index.d.ts +0 -92
  52. package/src/react-types.d.ts +0 -23
  53. package/src/svelte-types.d.ts +0 -27
  54. package/src/types/index.d.ts +0 -92
  55. 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
- * Optional pre-built trigger for the connect flow. Entirely opt-in —
10
- * `useOneConnect` alone with any element the consumer likes remains
11
- * fully supported. Framework-agnostic like the rest of the SDK: it
12
- * renders real DOM into a container, so React/Vue/vanilla all mount it
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 card, the label
17
- * turns "Connecting…" while the card is up, and lands on a spring
18
- * "Connected" state on success (Connect → Connecting → Connected; the
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
- btnBg: string;
27
- btnFg: string;
32
+ buttonBackground: string;
33
+ buttonForeground: string;
28
34
  surface: string;
29
- fg: string;
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
- btnBg: "#0A0C0B",
37
- btnFg: "#ffffff",
38
- surface: "#ffffff",
39
- fg: "#0A0C0B",
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
- btnBg: "#F2F5F3",
47
- btnFg: "#0A0C0B",
52
+ buttonBackground: "#F2F5F3",
53
+ buttonForeground: CARBON,
48
54
  surface: "#101312",
49
- fg: "#F2F5F3",
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
- ".owcb-chip.owcb-more{font-family:ui-monospace,monospace;font-size:9px;font-weight:500;letter-spacing:-.02em}",
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
- `.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}`,
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
- // At most this many provider chips render; everything past it folds
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.style.background = "#ffffff";
114
+ chip.title = platform.name;
133
115
  chip.style.boxShadow = `0 0 0 1.5px ${chipRing}`;
134
- // imageUrl is OPTIONAL: a developer may pass just a name. When they
135
- // do, derive One's connector-asset logo from the name; if that 404s
136
- // (or the passed URL fails), fall back to the name's first letter.
137
- const letterFallback = () => {
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
- chip.style.font = "600 10px ui-monospace,monospace";
140
- chip.style.color = CARBON;
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: "idle" | "connecting" | "connected" = "idle";
151
+ let state: ConnectButtonState = "idle";
183
152
  let destroyed = false;
184
153
 
185
- const handle: OneConnectHandle = useOneConnect({
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: (error) => {
192
- setState("idle");
193
- options.connect.onError?.(error);
194
- },
195
- onClose: () => {
160
+ onError: (message) => {
196
161
  setState("idle");
197
- options.connect.onClose?.();
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 !== "idle" && state !== "connected") return;
170
+ if (state === "connecting") return;
206
171
  setState("connecting");
207
- handle.open();
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 bg = block
185
+ const background = block
214
186
  ? palette.surface
215
187
  : accent
216
188
  ? (options.accentColor ?? LIME)
217
- : palette.btnBg;
218
- const fg = block ? palette.fg : accent ? CARBON : palette.btnFg;
219
- const chipRing = block ? palette.surface : bg;
220
- // The "+N" chip sits on a WHITE background (like the provider chips),
221
- // so its text must be dark — never the button's fg, which is white on
222
- // the default dark button and would vanish. CARBON matches the
223
- // letter-fallback chips.
224
- const moreColor = CARBON;
225
-
226
- button.style.background = state === "connected" && !block ? SPRING : bg;
227
- button.style.color = state === "connected" && !block ? CARBON : fg;
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 ${(options.accentColor ?? LIME)}38`
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, moreColor);
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.fg}">${ONE_MARK_SVG}</span>` +
277
- `<span style="font-size:12px;font-weight:600;letter-spacing:-0.02em;color:${palette.fg}">one</span>`;
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: "idle" | "connecting" | "connected") => {
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> — the simple path. One tag, any framework
334
- * (React, Vue, Svelte, plain HTML): the element wires the whole flow
335
- * itself from its attributes and manages Connect → Connecting →
336
- * Connected. Registered automatically in the browser on import;
337
- * defined inside a function so importing this module on a server
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 (JSON array of {name, imageUrl}), more-count,
298
+ * theme, platforms ("stripe, notion" or a JSON array), more-count,
342
299
  * description, accent-color, connected-label.
343
- * Events: "success" | "error" (detail: message) | "close" — plus
344
- * matching function properties (onSuccess/onError/onClose) that
345
- * React 19 / Vue / Svelte set naturally as props.
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: ((error: string) => void) | null = null;
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; // nothing to wire yet
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) : undefined;
348
+ const moreCount = moreCountRaw ? parseInt(moreCountRaw, 10) : NaN;
404
349
 
405
350
  this.handle = mountConnectButton(this, {
406
351
  connect: {
407
- authorize: { url: authorizeUrl },
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: (error) => {
416
- this.onError?.(error);
417
- this.dispatchEvent(new CustomEvent("error", { detail: error }));
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
- (this.getAttribute("variant") as ConnectButtonOptions["variant"]) ??
427
- undefined,
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 consumer's callback route puts on its final
2
- * redirect (any same-origin URL). The SDK reads them off the page URL
3
- * when the tab comes home — no completion page required. */
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 consumer's backend forwards nothing. */
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
- OneConnectProps,
15
+ OneConnectOptions,
5
16
  OneConnectHandle,
6
- OneConnectResult,
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
+ }