@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.
- package/README.md +229 -59
- package/dist/button.d.ts +0 -2
- package/dist/flow.d.ts +0 -4
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +2 -7
- package/dist/index.esm.js +1 -1
- package/dist/next.cjs.js +10 -0
- package/dist/next.d.ts +10 -1
- package/dist/next.esm.js +10 -0
- package/dist/node.cjs.js +17 -13
- package/dist/node.d.ts +6 -1
- package/dist/node.esm.js +17 -13
- package/dist/platforms.d.ts +1 -1
- package/dist/react.cjs.js +1 -1
- package/dist/react.esm.js +1 -1
- package/dist/server/types.d.ts +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/types.d.ts +19 -16
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +15 -24
- package/dist/vue.esm.js +1 -1
- package/package.json +1 -1
- package/skills/one-connect/SKILL.md +108 -52
- package/src/button.ts +11 -78
- package/src/flow.ts +1 -7
- package/src/index.ts +3 -6
- package/src/next.ts +17 -1
- package/src/node.ts +23 -15
- package/src/platforms.ts +1 -1
- package/src/react.ts +2 -3
- package/src/server/types.ts +1 -1
- package/src/svelte.ts +1 -1
- package/src/types.ts +20 -16
- package/src/vue.ts +7 -5
|
@@ -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
|
|
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
|
|
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 -
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
103
|
-
from `@withone/connect/node
|
|
104
|
-
`/api/one/
|
|
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
|
-
|
|
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
|
-
|
|
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"), `
|
|
121
|
-
|
|
122
|
-
`label`, `
|
|
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"
|
|
127
|
-
|
|
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
|
-
|
|
156
|
+
- React: `useOneConnect` from `@withone/connect/react`.
|
|
130
157
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
259
|
+
- Never show `result.message` or any URL text to users; log it.
|
|
203
260
|
|
|
204
|
-
|
|
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
|
-
##
|
|
273
|
+
## 10 - Token mode (only when chosen in section 1)
|
|
216
274
|
|
|
217
|
-
The
|
|
218
|
-
|
|
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`,
|
|
24
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 `
|
|
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
|
|
41
|
-
props.accentColor,
|
|
40
|
+
props.connectTheme,
|
|
42
41
|
props.label,
|
|
43
42
|
props.connectedLabel,
|
|
44
43
|
props.description,
|
package/src/server/types.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
8
|
+
* logos: ["stripe", "notion"], connected: data.hasOneGrant,
|
|
9
9
|
* onSuccess: () => { ... } }} />
|
|
10
10
|
*/
|
|
11
11
|
import {
|