@withone/connect 0.14.0 → 0.16.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: one-connect
3
- description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 500 more). Use when wiring @withone/connect into an app - the button, the two backend routes, the connect key, and calling One with the grant.
3
+ description: Add One Connect to an application so its users can grant the app scoped, revocable access to their own One-connected tools (Gmail, Slack, Notion, Stripe and 900+ more). Use when wiring @withone/connect into an app - choosing key or token mode, the two backend routes, the Connect button or the app's own button, and calling One with the grant.
4
4
  ---
5
5
 
6
6
  # One Connect
@@ -13,19 +13,31 @@ you wire three things: a button, two routes, and the calls made with the grant.
13
13
  Browser Your backend One
14
14
  <ConnectButton> ------> GET /api/one/authorize ---302---> hosted page: sign in, pick tools, set access
15
15
  GET /api/one/callback <--302---- ?code&state
16
- saves one id for the user, redirects home
16
+ saves the grant for the user, redirects home
17
17
  <------ onSuccess fires
18
18
  Later: oneConnect.runAction(userId, ...) -> One, grant enforced
19
19
  ```
20
20
 
21
- Use **key mode**, described in sections 1 to 7: the app holds one connect
22
- key and saves one id per user, and nothing is refreshed. Token mode
23
- (section 9) is the other way to hold the grant; use it only when the human
24
- asks for it, or when the app already passes a `tokenStore`.
25
-
26
21
  The client secret and the connect key stay on the server.
27
22
 
28
- ## 1 - Ask the human for these
23
+ ## 1 - Choose the mode
24
+
25
+ The routes, the button and the calls are the same in both modes. Only what
26
+ the server keeps differs.
27
+
28
+ | | Key mode (default) | Token mode |
29
+ |---|---|---|
30
+ | Server keeps | one connect key for the app, one id per user | an access and a refresh token per user |
31
+ | Expires | nothing | yes; the app runs a daily refresh job |
32
+ | User revoked | next call throws `reconnect_required` | next refresh throws `refresh_failed` |
33
+ | Call outside the grant | `403`, `blockedByGrant: true` | `403`, `blockedByGrant` stays `false` |
34
+
35
+ Use **key mode** unless the human asks for token mode, the app needs the
36
+ bearer token itself, or the app already passes a `tokenStore`. Do not switch
37
+ an existing app from one mode to the other unless asked. Sections 2 to 9 are
38
+ key mode; section 10 lists what changes for token mode.
39
+
40
+ ## 2 - Ask the human for these
29
41
 
30
42
  They create the app in the One dashboard: Developers -> Connect -> New app.
31
43
 
@@ -33,14 +45,14 @@ They create the app in the One dashboard: Developers -> Connect -> New app.
33
45
  |---|---|
34
46
  | `ONE_CLIENT_ID` | From the app. |
35
47
  | `ONE_CLIENT_SECRET` | Starts with `one_secret_`. Shown once. |
36
- | `ONE_CONNECT_KEY` | On the app's page: Credentials -> Connect keys -> Create key, with the dashboard on Production. Shown once. |
48
+ | `ONE_CONNECT_KEY` | Key mode. On the app's page: Credentials -> Connect keys -> Create key, with the dashboard on Production. Shown once. |
37
49
  | Redirect URI | Registered on the app. Must match the callback route exactly, e.g. `http://localhost:3000/api/one/callback`. |
38
- | `ONE_PERMISSION_SET` | Optional. The tools and access levels to ask for. |
50
+ | `ONE_PERMISSION_SET` | Optional. The app's Permission set ID: the tools and access levels to ask for. Without it, the consent page lists the user's connections and they choose. |
39
51
 
40
52
  Never ask the human to paste the secret or the connect key into the chat.
41
53
  Tell them which environment variable to set and read it from there.
42
54
 
43
- ## 2 - Environment (server only)
55
+ ## 3 - Environment (server only)
44
56
 
45
57
  ```bash
46
58
  ONE_CLIENT_ID=...
@@ -50,7 +62,7 @@ ONE_REDIRECT_URI=https://yourapp.com/api/one/callback
50
62
  ONE_PERMISSION_SET=... # optional
51
63
  ```
52
64
 
53
- ## 3 - Install and create the client
65
+ ## 4 - Install and create the client
54
66
 
55
67
  ```bash
56
68
  npm install @withone/connect
@@ -67,9 +79,9 @@ export const oneConnect = createOneConnect({
67
79
  permissionSet: process.env.ONE_PERMISSION_SET,
68
80
  connectKey: process.env.ONE_CONNECT_KEY!,
69
81
  userStore: {
70
- saveUser: (userId, reference) => /* save the string on the app's user row */,
71
- loadUser: (userId) => /* read it; null when never connected */,
72
- clearUser: (userId) => /* set it to null */,
82
+ saveUser: async (userId, reference) => { /* save the string on the app's user row */ },
83
+ loadUser: async (userId) => /* read it; null when never connected */,
84
+ clearUser: async (userId) => { /* set it to null */ },
73
85
  },
74
86
  });
75
87
  ```
@@ -83,7 +95,10 @@ identifier, not a secret, so it needs no encryption and no lock. The
83
95
  package writes it in the callback and reads it on every call; the app
84
96
  never passes it anywhere.
85
97
 
86
- ## 4 - The two routes
98
+ Optional settings: `returnTo` (where the callback sends the browser, `/` by
99
+ default) and `oneApiUrl` (One's API origin, production by default).
100
+
101
+ ## 5 - The two routes
87
102
 
88
103
  Next.js App Router (also Remix, SvelteKit, Hono, Bun):
89
104
 
@@ -94,43 +109,79 @@ import { oneConnect } from "@/lib/one";
94
109
 
95
110
  export const { GET } = createOneConnectRoutes(oneConnect, {
96
111
  identifyUser: async (request) => /* the signed-in user's id, or null */,
112
+ signInUrl: "/login", // where signed-out users go; 401 when omitted
97
113
  loginHintFor: async (request) => /* their email, optional */,
98
- signInUrl: "/login",
114
+ onComplete: ({ userId, result }) => { /* log result.outcome and, on failure, result.message */ },
99
115
  });
100
116
  ```
101
117
 
102
- Express or plain Node: `createOneConnectHandlers(oneConnect, { identifyUser })`
103
- from `@withone/connect/node`, mounted at `/api/one/authorize` and
104
- `/api/one/callback`.
118
+ Express, Fastify, Koa or plain Node: `createOneConnectHandlers(oneConnect,
119
+ options)` from `@withone/connect/node` takes the same options and returns
120
+ `{ authorize, callback }`, mounted at `/api/one/authorize` and
121
+ `/api/one/callback`. Both routes must share that directory: the state
122
+ cookie is scoped to it.
123
+
124
+ ## 6 - The button
105
125
 
106
- ## 5 - The button
126
+ Pick by what the app already has. Each option uses the routes above.
127
+
128
+ **The ready-made button** (default):
107
129
 
108
130
  ```tsx
109
131
  import { ConnectButton } from "@withone/connect/react";
110
132
 
111
133
  <ConnectButton
112
134
  authorizeUrl="/api/one/authorize"
113
- platforms={["gmail", "stripe"]} // connector slugs
135
+ logos={["gmail", "stripe"]} // connector slugs, decoration only
114
136
  connected={hasGrant} // from the server: await oneConnect.isConnected(userId)
115
137
  onSuccess={() => { /* refetch app state */ }}
116
138
  onError={(message) => { /* show message */ }}
117
139
  />
118
140
  ```
119
141
 
120
- Optional props: `variant` ("default" | "accent" | "block"), `accentColor`,
121
- `size` ("sm" | "md" | "lg"), `fullWidth`, `theme` ("light" | "dark" | "auto"),
122
- `label`, `description`, `disabled`.
142
+ Optional props: `variant` ("default" | "accent" | "block"), `size` ("sm" |
143
+ "md" | "lg"), `fullWidth`, `theme` ("light" | "dark" | "auto"),
144
+ `connectTheme` ("light" | "dark", One's page), `label`, `connectedLabel`,
145
+ `description`, `disabled`, `onCancel`. The accent variant's colours come
146
+ from the host's `--one-connect-accent` and `--one-connect-accent-fg` CSS
147
+ variables.
123
148
 
124
149
  Vue: `@withone/connect/vue`, same props. Svelte: `use:connectButton` from
125
150
  `@withone/connect/svelte`. Anything else: `import "@withone/connect"` and use
126
- `<one-connect-button authorize-url="/api/one/authorize" platforms="gmail, stripe">`.
127
- A custom button in React: `useOneConnect({ authorizeUrl })` returns `{ open, status }`.
151
+ `<one-connect-button authorize-url="/api/one/authorize" logos="gmail, stripe">`.
152
+
153
+ **The app's own button.** When the app has its own design system button,
154
+ keep it and wire the flow to it instead of adding a second style:
128
155
 
129
- ## 6 - Calling One with the grant
156
+ - React: `useOneConnect` from `@withone/connect/react`.
130
157
 
131
- The same four steps the One CLI takes: find the action, read its knowledge,
132
- run it. Always read the knowledge before running an action for the first
133
- time; it names the required fields, the encoding and any header.
158
+ ```tsx
159
+ const { open, status, error } = useOneConnect({ authorizeUrl: "/api/one/authorize", onSuccess: refetch });
160
+ <Button onClick={open} disabled={status === "connecting"}>Connect your tools</Button>
161
+ ```
162
+
163
+ - Any other framework, or none: `createConnectFlow` from `@withone/connect`.
164
+ Create it once where the button lives (Vue `onMounted`, Svelte `onMount`),
165
+ call `flow.open()` on click, and `flow.destroy()` when the button goes.
166
+
167
+ ```ts
168
+ const flow = createConnectFlow({ authorizeUrl: "/api/one/authorize", onSuccess, onError, onCancel });
169
+ button.addEventListener("click", () => flow.open());
170
+ ```
171
+
172
+ - No SDK in the browser (server-rendered pages): a plain link,
173
+ `<a href="/api/one/authorize">`. The user returns with
174
+ `?one_connect=success`, or `?one_connect=error&one_connect_error=` with
175
+ `declined`, `expired` or `failed`. Show the app's own text per code, never
176
+ text from the URL, and remove the parameters after reading them.
177
+
178
+ Do not build a completion page; the callback redirect is the completion.
179
+
180
+ ## 7 - Calling One with the grant
181
+
182
+ The same four steps the One CLI takes: list, find the action, read its
183
+ knowledge, run it. Always read the knowledge before running an action for
184
+ the first time; it names the required fields, the encoding and any header.
134
185
 
135
186
  ```ts
136
187
  const connections = await oneConnect.listConnections(userId); // [{ key, platform, access }]
@@ -149,14 +200,19 @@ const reply = await oneConnect.runAction(userId, {
149
200
  // { status, ok, blockedByGrant, data }
150
201
  ```
151
202
 
203
+ Each connection's `access` is `{ policy: "full" }`, `{ policy: "methods",
204
+ methods }` or `{ policy: "actions", actions }`; plan from it before calling.
152
205
  `runAction` takes the method and path from the action, fills the path's
153
206
  placeholders, puts the connection key in the body of an action One serves
154
207
  itself (tag `custom`), and encodes the body as asked. `listActions(userId,
155
- platform)` lists a whole catalog when search is not enough.
208
+ platform)` lists a whole catalog when search is not enough;
209
+ `oneConnect.fetch(userId, path, init)` reaches any other `/v1` endpoint.
156
210
 
157
211
  Do not set any auth header. The package adds the connect key and the
158
212
  user's id to every call it makes.
159
213
 
214
+ ## 8 - Errors
215
+
160
216
  A `403` reply with `blockedByGrant: true` means the call is outside what
161
217
  the user granted. Do not retry it.
162
218
 
@@ -165,18 +221,19 @@ Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
165
221
  | `code` | Meaning | Do |
166
222
  |---|---|---|
167
223
  | `not_connected` | Nothing is stored for this user. | Show the Connect button. |
168
- | `reconnect_required` | One will not act for this user: they revoked access, or the app is deactivated. The stored value is kept. | Ask the user to connect again. |
224
+ | `reconnect_required` | Key mode: One will not act for this user; they revoked access, or the app is deactivated. The stored value is kept. | Ask the user to connect again. |
225
+ | `refresh_failed` | Token mode: the grant is gone. The tokens are cleared. | Ask the user to connect again. |
169
226
  | `request_failed` | One answered with an error or could not be reached. Nothing stored changed. | Retry later. |
170
227
 
171
228
  ```ts
172
229
  import { OneConnectError } from "@withone/connect/server";
173
230
 
174
231
  try {
175
- await oneConnect.runAction(userId, action);
232
+ await oneConnect.runAction(userId, input);
176
233
  } catch (error) {
177
234
  if (
178
235
  error instanceof OneConnectError &&
179
- ["not_connected", "reconnect_required"].includes(error.code)
236
+ ["not_connected", "reconnect_required", "refresh_failed"].includes(error.code)
180
237
  ) {
181
238
  // show the Connect button again
182
239
  } else {
@@ -186,10 +243,11 @@ try {
186
243
  ```
187
244
 
188
245
  `isConnected(userId)` says the user has connected before. One confirms the
189
- consent on each call, so a user who revoked is found by the next call
190
- throwing `reconnect_required`.
246
+ consent on each call, so a user who revoked is found by the next call.
247
+
248
+ ## 9 - Rules and done
191
249
 
192
- ## 7 - Rules
250
+ Rules:
193
251
 
194
252
  - Never put the client secret or the connect key in browser code, logs,
195
253
  error reports, source files or prompts. Environment variables only.
@@ -197,14 +255,14 @@ throwing `reconnect_required`.
197
255
  the dashboard on Production.
198
256
  - Store the per-user string as given.
199
257
  - The registered redirect URI and `ONE_REDIRECT_URI` must be identical.
200
- - Do not build a completion page; the callback redirect is the completion.
201
258
  - Do not write OAuth steps or One request headers by hand; use the package.
202
- - Do not switch an existing app from one mode to the other unless asked.
259
+ - Never show `result.message` or any URL text to users; log it.
203
260
 
204
- ## 8 - Done when
261
+ Done when:
205
262
 
206
263
  1. The button leads to One's page; after signing in and authorizing, the user
207
- lands back in the app and `onSuccess` fires.
264
+ lands back in the app and `onSuccess` fires (or the app reads
265
+ `?one_connect=success`).
208
266
  2. One string is saved for the user.
209
267
  3. `listConnections` returns only the granted connections.
210
268
  4. `runAction` works for an action inside the grant and returns `403` with
@@ -212,11 +270,10 @@ throwing `reconnect_required`.
212
270
  5. After the user revokes the app in their One dashboard, the next call
213
271
  fails with `reconnect_required` and the app asks them to connect again.
214
272
 
215
- ## 9 - Token mode (only when asked)
273
+ ## 10 - Token mode (only when chosen in section 1)
216
274
 
217
- The other way to hold the grant: the app stores an access token and a
218
- refresh token per user, as a standard OAuth client. The routes, the button
219
- and the calls are the same. No `ONE_CONNECT_KEY` is needed.
275
+ The app stores an access token and a refresh token per user, as a standard
276
+ OAuth client. No `ONE_CONNECT_KEY` is needed; everything else above holds.
220
277
 
221
278
  ```ts
222
279
  export const oneConnect = createOneConnect({
@@ -225,11 +282,13 @@ export const oneConnect = createOneConnect({
225
282
  redirectUri: process.env.ONE_REDIRECT_URI!,
226
283
  permissionSet: process.env.ONE_PERMISSION_SET,
227
284
  tokenStore: {
228
- saveTokens: (userId, tokens) => /* save in the app's database, encrypted */,
229
- loadTokens: (userId) => /* read; null when never connected */,
230
- clearTokens: (userId) => /* delete */,
285
+ saveTokens: async (userId, tokens) => { /* save in the app's database, encrypted */ },
286
+ loadTokens: async (userId) => /* read; null when never connected */,
287
+ clearTokens: async (userId) => { /* delete */ },
231
288
  },
232
289
  });
290
+
291
+ const accessToken = await oneConnect.getAccessToken(userId); // when the app needs the bearer token itself
233
292
  ```
234
293
 
235
294
  - Store tokens encrypted, keyed by the app's user.
@@ -262,6 +321,3 @@ the pair. The access token lives as long as the app's Token lifetime says
262
321
  (30 days unless changed under Advanced when creating the app); keep the
263
322
  window shorter than that, or every run refreshes. In token mode, "done"
264
323
  also means the daily job exists and runs for every connected user.
265
-
266
- The mode is whichever credential `createOneConnect` is given: `connectKey`
267
- and `userStore` for key mode, `tokenStore` for token mode.
package/src/button.ts CHANGED
@@ -20,8 +20,10 @@ import type {
20
20
  * custom properties set through the CSSOM, which such a policy allows.
21
21
  *
22
22
  * Theming hooks for the host page: `--one-connect-font`,
23
- * `--one-connect-radius`, and `::part(button)` / `::part(label)` on the
24
- * host (`one-connect-button`, or `.one-connect` for the wrappers).
23
+ * `--one-connect-radius`, `--one-connect-accent` and
24
+ * `--one-connect-accent-fg` (the accent variant's fill and text), and
25
+ * `::part(button)` / `::part(label)` on the host (`one-connect-button`,
26
+ * or `.one-connect` for the wrappers).
25
27
  */
26
28
 
27
29
  const MAX_VISIBLE_CHIPS = 3;
@@ -129,57 +131,6 @@ function adoptStyles(root: ShadowRoot): void {
129
131
  root.appendChild(style);
130
132
  }
131
133
 
132
- /* ── Accent contrast ──────────────────────────────────────────────── */
133
-
134
- function parseRgb(color: string): [number, number, number] | null {
135
- const value = color.trim().toLowerCase();
136
- const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(value);
137
- if (hex) {
138
- const digits = hex[1].length === 3 ? hex[1].replace(/./g, "$&$&") : hex[1];
139
- return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)) as [
140
- number,
141
- number,
142
- number,
143
- ];
144
- }
145
- const rgb = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/.exec(value);
146
- return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : null;
147
- }
148
-
149
- /** Any CSS colour to sRGB, through a canvas when it is not already hex
150
- * or rgb() ("navy", "hsl(…)"). Null when the browser cannot say. */
151
- function toRgb(color: string): [number, number, number] | null {
152
- const direct = parseRgb(color);
153
- if (direct) return direct;
154
- try {
155
- const context = document.createElement("canvas").getContext("2d");
156
- if (!context) return null;
157
- context.fillStyle = "#000";
158
- context.fillStyle = color;
159
- return parseRgb(String(context.fillStyle));
160
- } catch {
161
- return null;
162
- }
163
- }
164
-
165
- const luminance = ([r, g, b]: [number, number, number]): number => {
166
- const channel = (c: number) => {
167
- const s = c / 255;
168
- return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
169
- };
170
- return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
171
- };
172
-
173
- /** Black or white, whichever has the higher WCAG contrast on `color`. */
174
- export function readableTextOn(color: string): string {
175
- const rgb = toRgb(color);
176
- if (!rgb) return CARBON;
177
- const l = luminance(rgb);
178
- const onWhite = 1.05 / (l + 0.05);
179
- const onCarbon = (l + 0.05) / (luminance([10, 12, 11]) + 0.05);
180
- return onWhite > onCarbon ? WHITE : CARBON;
181
- }
182
-
183
134
  /* ── Rendering ────────────────────────────────────────────────────── */
184
135
 
185
136
  const el = <K extends keyof HTMLElementTagNameMap>(
@@ -200,7 +151,7 @@ const icon = (svg: string, className = "icon"): HTMLSpanElement => {
200
151
  };
201
152
 
202
153
  function buildStack(props: ConnectButtonProps): HTMLElement | null {
203
- const platforms = normalizePlatforms(props.platforms);
154
+ const platforms = normalizePlatforms(props.logos ?? props.platforms);
204
155
  if (platforms.length === 0) return null;
205
156
  const stack = el("span", "stack");
206
157
  stack.setAttribute("aria-hidden", "true");
@@ -230,7 +181,7 @@ const flowOptions = (
230
181
  handlers: Pick<OneConnectFlowOptions, "onSuccess" | "onError" | "onCancel">,
231
182
  ): OneConnectFlowOptions => ({
232
183
  authorizeUrl: props.authorizeUrl,
233
- connectTheme: props.connectTheme ?? props.appTheme,
184
+ connectTheme: props.connectTheme,
234
185
  ...handlers,
235
186
  });
236
187
 
@@ -311,22 +262,6 @@ export function renderConnectButton(
311
262
  ? (props.connectedLabel ?? "Connected")
312
263
  : (props.label ?? "Connect your apps");
313
264
 
314
- // Colour props travel as custom properties on the button inside the
315
- // shadow root, set through the CSSOM: allowed under a strict CSP, and
316
- // never written to the host, whose attributes belong to whoever
317
- // rendered it (a server-rendered page must hydrate unchanged).
318
- if (variant === "accent") {
319
- const accent = props.accentColor?.trim() || LIME;
320
- button.style.setProperty("--one-connect-accent", accent);
321
- button.style.setProperty(
322
- "--one-connect-accent-fg",
323
- readableTextOn(accent),
324
- );
325
- } else {
326
- button.style.removeProperty("--one-connect-accent");
327
- button.style.removeProperty("--one-connect-accent-fg");
328
- }
329
-
330
265
  button.dataset.variant = variant;
331
266
  button.dataset.size = props.size ?? "md";
332
267
  button.dataset.theme = props.theme ?? "light";
@@ -438,6 +373,7 @@ export function mountConnectButton(
438
373
 
439
374
  const ATTRIBUTES = [
440
375
  "authorize-url",
376
+ "logos",
441
377
  "platforms",
442
378
  "connected",
443
379
  "disabled",
@@ -446,8 +382,6 @@ const ATTRIBUTES = [
446
382
  "full-width",
447
383
  "theme",
448
384
  "connect-theme",
449
- "app-theme",
450
- "accent-color",
451
385
  "label",
452
386
  "connected-label",
453
387
  "description",
@@ -510,17 +444,16 @@ export function registerConnectButton(): void {
510
444
  private props(authorizeUrl: string): ConnectButtonProps {
511
445
  return {
512
446
  authorizeUrl,
513
- platforms: parsePlatformsAttribute(this.getAttribute("platforms")),
447
+ logos: parsePlatformsAttribute(
448
+ this.getAttribute("logos") ?? this.getAttribute("platforms"),
449
+ ),
514
450
  connected: flag(this, "connected"),
515
451
  disabled: flag(this, "disabled"),
516
452
  variant: attributeAs(this, "variant", ["default", "accent", "block"]),
517
453
  size: attributeAs(this, "size", ["sm", "md", "lg"]),
518
454
  fullWidth: flag(this, "full-width"),
519
455
  theme: attributeAs(this, "theme", ["light", "dark", "auto"]),
520
- connectTheme:
521
- attributeAs(this, "connect-theme", ["light", "dark"]) ??
522
- attributeAs(this, "app-theme", ["light", "dark"]),
523
- accentColor: this.getAttribute("accent-color") ?? undefined,
456
+ connectTheme: attributeAs(this, "connect-theme", ["light", "dark"]),
524
457
  label: this.getAttribute("label") ?? undefined,
525
458
  connectedLabel: this.getAttribute("connected-label") ?? undefined,
526
459
  description: this.getAttribute("description") ?? undefined,
package/src/flow.ts CHANGED
@@ -92,7 +92,7 @@ function bindPageshow(): void {
92
92
  }
93
93
 
94
94
  const authorizeUrlFor = (options: OneConnectFlowOptions): string => {
95
- const theme = options.connectTheme ?? options.appTheme;
95
+ const theme = options.connectTheme;
96
96
  try {
97
97
  const url = new URL(options.authorizeUrl, window.location.origin);
98
98
  if (theme) url.hash = `${THEME_PARAM}=${theme}`;
@@ -156,12 +156,6 @@ export function createConnectFlow(
156
156
  };
157
157
  }
158
158
 
159
- /** @deprecated Renamed to `createConnectFlow` (it is not a React hook;
160
- * React apps can use `useOneConnect` from `@withone/connect/react`).
161
- * Removed in the next minor. */
162
- export const useOneConnect = (options: OneConnectFlowOptions): OneConnectFlow =>
163
- createConnectFlow(options);
164
-
165
159
  /** Test seam: forget this page load's outcome. */
166
160
  export function resetPageReturnForTests(): void {
167
161
  pageReturn = undefined;
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createConnectFlow, readConnectReturn, useOneConnect } from "./flow";
1
+ export { createConnectFlow, readConnectReturn } from "./flow";
2
2
  export {
3
3
  mountConnectButton,
4
4
  registerConnectButton,
@@ -6,6 +6,8 @@ export {
6
6
  } from "./button";
7
7
  export type {
8
8
  ConnectButtonHandle,
9
+ ConnectButtonLogo,
10
+ ConnectButtonLogoInput,
9
11
  ConnectButtonPlatform,
10
12
  ConnectButtonPlatformInput,
11
13
  ConnectButtonProps,
@@ -20,8 +22,3 @@ export type {
20
22
  OneConnectTheme,
21
23
  } from "./types";
22
24
 
23
- import type { OneConnectFlow, OneConnectFlowOptions } from "./types";
24
- /** @deprecated Renamed to `OneConnectFlowOptions`; removed in the next minor. */
25
- export type OneConnectOptions = OneConnectFlowOptions;
26
- /** @deprecated Renamed to `OneConnectFlow`; removed in the next minor. */
27
- export type OneConnectHandle = OneConnectFlow;
package/src/next.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * That serves /api/one/authorize and /api/one/callback. Register
15
15
  * `https://yourapp.com/api/one/callback` as the app's redirect URI.
16
16
  */
17
- import type { OneConnectClient } from "./server";
17
+ import type { CompleteAuthorizationResult, OneConnectClient } from "./server";
18
18
 
19
19
  export interface OneConnectRoutesOptions {
20
20
  /** The app's own id for the signed-in user, or null when nobody is
@@ -25,6 +25,15 @@ export interface OneConnectRoutesOptions {
25
25
  /** Where to send the browser when nobody is signed in. Answers 401
26
26
  * when omitted. */
27
27
  signInUrl?: string;
28
+ /** Called after every callback, before the browser is redirected:
29
+ * `result.outcome` is "connected", "declined" or "failed", and
30
+ * `result.message` says why a flow failed. For your logs and metrics;
31
+ * never show the message to the user. An error it throws is ignored,
32
+ * so the user still lands back in the app. */
33
+ onComplete?: (event: {
34
+ userId: string;
35
+ result: CompleteAuthorizationResult;
36
+ }) => void | Promise<void>;
28
37
  }
29
38
 
30
39
  export interface OneConnectRoutes {
@@ -114,6 +123,13 @@ export function createOneConnectRoutes(
114
123
  url: request.url,
115
124
  getCookie: (name) => readCookie(request, name),
116
125
  });
126
+ if (options.onComplete) {
127
+ try {
128
+ await options.onComplete({ userId: user, result });
129
+ } catch {
130
+ /* the app's own hook threw; the redirect still happens */
131
+ }
132
+ }
117
133
  return redirectWithCookies(
118
134
  result.redirectUrl,
119
135
  result.clearCookieName
package/src/node.ts CHANGED
@@ -29,8 +29,12 @@ export interface OneConnectNodeOptions {
29
29
  loginHintFor?: (
30
30
  request: IncomingMessage,
31
31
  ) => Promise<string | null> | string | null;
32
- /** Where to send the browser when nobody is signed in. */
32
+ /** Where to send the browser when nobody is signed in. Answers 401
33
+ * when omitted. */
33
34
  signInUrl?: string;
35
+ /** Called after every callback with how it ended; see
36
+ * `OneConnectRoutesOptions.onComplete`. */
37
+ onComplete?: OneConnectRoutesOptions["onComplete"];
34
38
  }
35
39
 
36
40
  export type NodeHandler = (
@@ -80,27 +84,31 @@ export function createOneConnectHandlers(
80
84
  options: OneConnectNodeOptions,
81
85
  ): OneConnectHandlers {
82
86
  // The web adapter receives a Request; the Node callbacks want the
83
- // original IncomingMessage, so it is carried alongside by closure.
84
- let current: IncomingMessage | null = null;
85
- const routeOptions: OneConnectRoutesOptions = {
86
- identifyUser: () => options.identifyUser(current as IncomingMessage),
87
- loginHintFor: options.loginHintFor
88
- ? () => options.loginHintFor!(current as IncomingMessage)
87
+ // original IncomingMessage. Each Request maps to its own message, so
88
+ // concurrent requests never read each other's session or login hint.
89
+ const origin = new WeakMap<Request, IncomingMessage>();
90
+ const nodeRequestFor = (request: Request): IncomingMessage => {
91
+ const message = origin.get(request);
92
+ if (!message) throw new Error("@withone/connect/node: unknown request");
93
+ return message;
94
+ };
95
+ const loginHintFor = options.loginHintFor;
96
+ const routes = createOneConnectRoutes(oneConnect, {
97
+ identifyUser: (request) => options.identifyUser(nodeRequestFor(request)),
98
+ loginHintFor: loginHintFor
99
+ ? (request) => loginHintFor(nodeRequestFor(request))
89
100
  : undefined,
90
101
  signInUrl: options.signInUrl,
91
- };
92
- const routes = createOneConnectRoutes(oneConnect, routeOptions);
102
+ onComplete: options.onComplete,
103
+ } satisfies OneConnectRoutesOptions);
93
104
 
94
105
  const handle = async (
95
106
  request: IncomingMessage,
96
107
  response: ServerResponse,
97
108
  ): Promise<void> => {
98
- current = request;
99
- try {
100
- await send(response, await routes.GET(toWebRequest(request)));
101
- } finally {
102
- current = null;
103
- }
109
+ const web = toWebRequest(request);
110
+ origin.set(web, request);
111
+ await send(response, await routes.GET(web));
104
112
  };
105
113
 
106
114
  return { authorize: handle, callback: handle };
package/src/platforms.ts CHANGED
@@ -94,7 +94,7 @@ export function normalizePlatforms(
94
94
  }
95
95
 
96
96
  /**
97
- * The `platforms` attribute on <one-connect-button>: a comma list of
97
+ * The `logos` attribute on <one-connect-button>: a comma list of
98
98
  * slugs ("stripe, notion") or, for overrides, a JSON array of
99
99
  * {slug, name, imageUrl} objects.
100
100
  */
package/src/react.ts CHANGED
@@ -30,15 +30,14 @@ export interface ConnectButtonProps extends ConnectButtonCoreProps {
30
30
  const visualKey = (props: ConnectButtonCoreProps): string =>
31
31
  JSON.stringify([
32
32
  props.authorizeUrl,
33
- props.platforms ?? [],
33
+ props.logos ?? props.platforms ?? [],
34
34
  props.connected,
35
35
  props.disabled,
36
36
  props.variant,
37
37
  props.size,
38
38
  props.fullWidth,
39
39
  props.theme,
40
- props.connectTheme ?? props.appTheme,
41
- props.accentColor,
40
+ props.connectTheme,
42
41
  props.label,
43
42
  props.connectedLabel,
44
43
  props.description,
@@ -121,7 +121,7 @@ export interface OneConnectBaseConfig {
121
121
  export interface OneConnectKeyConfig extends OneConnectBaseConfig {
122
122
  mode?: "key";
123
123
  /** The app's connect key, minted on the app's page in the dashboard.
124
- * Server only. One key per environment. */
124
+ * Server only. Connect keys work in Production. */
125
125
  connectKey: string;
126
126
  userStore: OneConnectUserStore;
127
127
  tokenStore?: never;
package/src/svelte.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Svelte compiler or dependency is involved:
6
6
  *
7
7
  * <div use:connectButton={{ authorizeUrl: "/api/one/authorize",
8
- * platforms: ["stripe", "notion"], connected: data.hasOneGrant,
8
+ * logos: ["stripe", "notion"], connected: data.hasOneGrant,
9
9
  * onSuccess: () => { ... } }} />
10
10
  */
11
11
  import {