@withone/connect 0.8.2 → 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 -137
  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,63 +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
- /** The One ring mark — same glyph the card footer pairs with the
60
- * wordmark. Inline so the SDK stays free of One URLs. */
61
61
  const ONE_MARK_SVG =
62
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>';
63
63
  const ARROW_SVG =
@@ -77,16 +77,17 @@ function ensureStyles(): void {
77
77
  `.owcb:focus-visible{outline:2px solid ${SPRING};outline-offset:3px}`,
78
78
  ".owcb[disabled]{cursor:default;opacity:.75;transform:none}",
79
79
  ".owcb-stack{display:inline-flex;align-items:center;flex-shrink:0}",
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}`,
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}`,
81
81
  ".owcb-chip:first-child{margin-left:0}",
82
82
  ".owcb-chip img{width:14px;height:14px;display:block;object-fit:contain}",
83
- ".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}}`,
84
85
  ".owcb:hover .owcb-chip{margin-left:-2px}",
85
86
  ".owcb:hover .owcb-chip:first-child{margin-left:0}",
86
87
  ".owcb-label{white-space:nowrap}",
87
88
  `.owcb-arrow{opacity:.5;transition:transform .2s ${EASE},opacity .2s ${EASE}}`,
88
89
  ".owcb:hover .owcb-arrow{transform:translateX(2px);opacity:.8}",
89
- `.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}",
90
91
  "@keyframes owcb-spin{to{transform:rotate(360deg)}}",
91
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}",
92
93
  ".owcb-block-top{display:flex;align-items:center;justify-content:space-between;gap:12px}",
@@ -101,12 +102,8 @@ function ensureStyles(): void {
101
102
  function buildStack(
102
103
  options: ConnectButtonOptions,
103
104
  chipRing: string,
104
- moreColor: string,
105
105
  ): HTMLElement | null {
106
- // At most this many provider chips render; everything past it folds
107
- // into a single "+N" chip so the button stays clean and uncrowded.
108
- const MAX_VISIBLE_CHIPS = 3;
109
- const platforms = options.platforms ?? [];
106
+ const platforms = normalizePlatforms(options.platforms);
110
107
  if (platforms.length === 0 && !options.moreCount) return null;
111
108
  const stack = document.createElement("span");
112
109
  stack.className = "owcb-stack";
@@ -114,24 +111,17 @@ function buildStack(
114
111
  for (const platform of platforms.slice(0, MAX_VISIBLE_CHIPS)) {
115
112
  const chip = document.createElement("span");
116
113
  chip.className = "owcb-chip";
117
- chip.style.background = "#ffffff";
114
+ chip.title = platform.name;
118
115
  chip.style.boxShadow = `0 0 0 1.5px ${chipRing}`;
119
- if (platform.imageUrl) {
120
- const img = document.createElement("img");
121
- img.alt = "";
122
- img.src = platform.imageUrl;
123
- img.addEventListener("error", () => {
124
- img.style.display = "none";
125
- chip.textContent = platform.name.charAt(0).toUpperCase();
126
- chip.style.font = "600 10px ui-monospace,monospace";
127
- chip.style.color = CARBON;
128
- });
129
- chip.appendChild(img);
130
- } else {
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");
131
122
  chip.textContent = platform.name.charAt(0).toUpperCase();
132
- chip.style.font = "600 10px ui-monospace,monospace";
133
- chip.style.color = CARBON;
134
- }
123
+ });
124
+ chip.appendChild(img);
135
125
  stack.appendChild(chip);
136
126
  }
137
127
  const extra =
@@ -140,9 +130,7 @@ function buildStack(
140
130
  if (extra > 0) {
141
131
  const more = document.createElement("span");
142
132
  more.className = "owcb-chip owcb-more";
143
- more.style.background = "#ffffff";
144
133
  more.style.boxShadow = `0 0 0 1.5px ${chipRing}`;
145
- more.style.color = moreColor;
146
134
  more.textContent = `+${extra}`;
147
135
  stack.appendChild(more);
148
136
  }
@@ -158,23 +146,20 @@ export const mountConnectButton = (
158
146
  const palette = options.theme === "dark" ? DARK : LIGHT;
159
147
  const variant = options.variant ?? "default";
160
148
  const label = options.label ?? "Connect your apps";
149
+ const connectedLabel = options.connectedLabel ?? "Connected";
161
150
 
162
- let state: "idle" | "connecting" | "connected" = "idle";
151
+ let state: ConnectButtonState = "idle";
163
152
  let destroyed = false;
164
153
 
165
- const handle: OneConnectHandle = useOneConnect({
154
+ const flow: OneConnectHandle = useOneConnect({
166
155
  ...options.connect,
167
156
  onSuccess: () => {
168
157
  setState("connected");
169
158
  options.connect.onSuccess?.();
170
159
  },
171
- onError: (error) => {
160
+ onError: (message) => {
172
161
  setState("idle");
173
- options.connect.onError?.(error);
174
- },
175
- onClose: () => {
176
- setState("idle");
177
- options.connect.onClose?.();
162
+ options.connect.onError?.(message);
178
163
  },
179
164
  });
180
165
 
@@ -182,52 +167,54 @@ export const mountConnectButton = (
182
167
  button.type = "button";
183
168
  button.className = variant === "block" ? "owcb owcb-block" : "owcb";
184
169
  button.addEventListener("click", () => {
185
- if (state !== "idle" && state !== "connected") return;
170
+ if (state === "connecting") return;
186
171
  setState("connecting");
187
- handle.open();
172
+ flow.open();
188
173
  });
189
174
 
175
+ const titleFor = (): string =>
176
+ state === "connecting"
177
+ ? "Connecting…"
178
+ : state === "connected"
179
+ ? connectedLabel
180
+ : label;
181
+
190
182
  const paint = () => {
191
183
  const accent = variant === "accent";
192
184
  const block = variant === "block";
193
- const bg = block
185
+ const background = block
194
186
  ? palette.surface
195
187
  : accent
196
188
  ? (options.accentColor ?? LIME)
197
- : palette.btnBg;
198
- const fg = block ? palette.fg : accent ? CARBON : palette.btnFg;
199
- const chipRing = block ? palette.surface : bg;
200
- // The "+N" chip sits on a WHITE background (like the provider chips),
201
- // so its text must be dark — never the button's fg, which is white on
202
- // the default dark button and would vanish. CARBON matches the
203
- // letter-fallback chips.
204
- const moreColor = CARBON;
205
-
206
- button.style.background = state === "connected" && !block ? SPRING : bg;
207
- 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;
208
200
  button.style.boxShadow = block
209
201
  ? `inset 0 0 0 1px ${palette.line}`
210
202
  : state === "connected"
211
203
  ? "none"
212
204
  : accent
213
- ? `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`
214
206
  : palette.shadow;
215
207
  button.disabled = state === "connecting";
216
208
  button.innerHTML = "";
217
209
 
218
- const stack = buildStack(options, chipRing, moreColor);
210
+ const stack = buildStack(options, chipRing);
219
211
 
220
212
  if (block) {
221
213
  const top = document.createElement("span");
222
214
  top.className = "owcb-block-top";
223
215
  const title = document.createElement("span");
224
216
  title.className = "owcb-block-title";
225
- title.textContent =
226
- state === "connecting"
227
- ? "Connecting…"
228
- : state === "connected"
229
- ? (options.connectedLabel ?? "Connected")
230
- : label;
217
+ title.textContent = titleFor();
231
218
  top.appendChild(title);
232
219
  if (state === "connecting") {
233
220
  const spinner = document.createElement("span");
@@ -244,17 +231,14 @@ export const mountConnectButton = (
244
231
  sub.textContent = options.description;
245
232
  button.appendChild(sub);
246
233
  }
247
- // Mirrors the card/overlay footer strip: "Secured by" + ring mark
248
- // + "one" wordmark, so the button and the surface it opens read as
249
- // the same product.
250
234
  const foot = document.createElement("span");
251
235
  foot.className = "owcb-block-foot";
252
236
  foot.style.color = palette.muted;
253
237
  foot.style.borderTop = `1px solid ${palette.line}`;
254
238
  foot.innerHTML =
255
239
  `<span style="font-size:11px;color:${palette.muted}">Secured by</span>` +
256
- `<span style="display:inline-flex;color:${palette.fg}">${ONE_MARK_SVG}</span>` +
257
- `<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>`;
258
242
  button.appendChild(foot);
259
243
  return;
260
244
  }
@@ -274,12 +258,7 @@ export const mountConnectButton = (
274
258
 
275
259
  const text = document.createElement("span");
276
260
  text.className = "owcb-label";
277
- text.textContent =
278
- state === "connecting"
279
- ? "Connecting…"
280
- : state === "connected"
281
- ? (options.connectedLabel ?? "Connected")
282
- : label;
261
+ text.textContent = titleFor();
283
262
  button.appendChild(text);
284
263
 
285
264
  if (state === "idle") {
@@ -290,7 +269,7 @@ export const mountConnectButton = (
290
269
  }
291
270
  };
292
271
 
293
- const setState = (next: "idle" | "connecting" | "connected") => {
272
+ const setState = (next: ConnectButtonState) => {
294
273
  if (destroyed) return;
295
274
  state = next;
296
275
  paint();
@@ -303,26 +282,24 @@ export const mountConnectButton = (
303
282
  setState,
304
283
  destroy: () => {
305
284
  destroyed = true;
306
- handle.close({ keepResult: true });
307
285
  button.remove();
308
286
  },
309
287
  };
310
288
  };
311
289
 
312
290
  /**
313
- * <one-connect-button> — the simple path. One tag, any framework
314
- * (React, Vue, Svelte, plain HTML): the element wires the whole flow
315
- * itself from its attributes and manages Connect → Connecting →
316
- * Connected. Registered automatically in the browser on import;
317
- * defined inside a function so importing this module on a server
318
- * (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.
319
296
  *
320
297
  * Attributes: authorize-url (required), app-theme, label, variant,
321
- * theme, platforms (JSON array of {name, imageUrl}), more-count,
298
+ * theme, platforms ("stripe, notion" or a JSON array), more-count,
322
299
  * description, accent-color, connected-label.
323
- * Events: "success" | "error" (detail: message) | "close" — plus
324
- * matching function properties (onSuccess/onError/onClose) that
325
- * 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.
326
303
  */
327
304
  export function registerConnectButton(): void {
328
305
  if (typeof window === "undefined" || typeof customElements === "undefined")
@@ -344,8 +321,7 @@ export function registerConnectButton(): void {
344
321
  ];
345
322
 
346
323
  onSuccess: (() => void) | null = null;
347
- onError: ((error: string) => void) | null = null;
348
- onClose: (() => void) | null = null;
324
+ onError: ((message: string) => void) | null = null;
349
325
 
350
326
  private handle: ConnectButtonHandle | null = null;
351
327
 
@@ -366,48 +342,28 @@ export function registerConnectButton(): void {
366
342
  this.handle?.destroy();
367
343
  this.handle = null;
368
344
  const authorizeUrl = this.getAttribute("authorize-url");
369
- if (!authorizeUrl) return; // nothing to wire yet
370
-
371
- let platforms: ConnectButtonOptions["platforms"];
372
- const rawPlatforms = this.getAttribute("platforms");
373
- if (rawPlatforms) {
374
- try {
375
- const parsed = JSON.parse(rawPlatforms) as unknown;
376
- if (Array.isArray(parsed)) platforms = parsed;
377
- } catch {
378
- /* malformed platforms JSON — render without chips */
379
- }
380
- }
345
+ if (!authorizeUrl) return;
381
346
 
382
347
  const moreCountRaw = this.getAttribute("more-count");
383
- const moreCount = moreCountRaw ? parseInt(moreCountRaw, 10) : undefined;
348
+ const moreCount = moreCountRaw ? parseInt(moreCountRaw, 10) : NaN;
384
349
 
385
350
  this.handle = mountConnectButton(this, {
386
351
  connect: {
387
- authorize: { url: authorizeUrl },
388
- appTheme:
389
- (this.getAttribute("app-theme") as "light" | "dark" | null) ??
390
- undefined,
352
+ authorizeUrl,
353
+ appTheme: attributeAs(this, "app-theme", ["light", "dark"]),
391
354
  onSuccess: () => {
392
355
  this.onSuccess?.();
393
356
  this.dispatchEvent(new CustomEvent("success"));
394
357
  },
395
- onError: (error) => {
396
- this.onError?.(error);
397
- this.dispatchEvent(new CustomEvent("error", { detail: error }));
398
- },
399
- onClose: () => {
400
- this.onClose?.();
401
- this.dispatchEvent(new CustomEvent("close"));
358
+ onError: (message) => {
359
+ this.onError?.(message);
360
+ this.dispatchEvent(new CustomEvent("error", { detail: message }));
402
361
  },
403
362
  },
404
363
  label: this.getAttribute("label") ?? undefined,
405
- variant:
406
- (this.getAttribute("variant") as ConnectButtonOptions["variant"]) ??
407
- undefined,
408
- theme:
409
- (this.getAttribute("theme") as "light" | "dark" | null) ?? undefined,
410
- platforms,
364
+ variant: attributeAs(this, "variant", ["default", "accent", "block"]),
365
+ theme: attributeAs(this, "theme", ["light", "dark"]),
366
+ platforms: parsePlatformsAttribute(this.getAttribute("platforms")),
411
367
  moreCount: Number.isFinite(moreCount) ? moreCount : undefined,
412
368
  description: this.getAttribute("description") ?? undefined,
413
369
  accentColor: this.getAttribute("accent-color") ?? undefined,
@@ -419,4 +375,17 @@ export function registerConnectButton(): void {
419
375
  customElements.define("one-connect-button", OneConnectButtonElement);
420
376
  }
421
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
+
422
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
+ }