@withone/connect 0.10.0 → 0.12.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.
@@ -4,7 +4,8 @@ import { randomBytes, createHash } from 'node:crypto';
4
4
  * The SDK reads them off the page URL when the tab comes home, so
5
5
  * there is no completion page to build. */
6
6
  const RETURN_STATUS_PARAM = "one_connect";
7
- const RETURN_MESSAGE_PARAM = "one_connect_message";
7
+ /** Why the flow failed, as a code (see ConnectFailureCode). */
8
+ const RETURN_ERROR_PARAM = "one_connect_error";
8
9
 
9
10
  /** One's production API. Point `oneApiUrl` elsewhere for development. */
10
11
  const DEFAULT_ONE_API_URL = "https://api.withone.ai";
@@ -70,6 +71,18 @@ function tenancyHeaders(accessToken) {
70
71
  }
71
72
  }
72
73
 
74
+ /** When a refresh token stops working, in epoch milliseconds, from its
75
+ * `exp` claim. Null when the token carries no readable expiry, in which
76
+ * case only One can say whether it still works. */
77
+ function refreshTokenExpiresAt(refreshToken) {
78
+ try {
79
+ const payload = JSON.parse(Buffer.from(refreshToken.split(".")[1], "base64url").toString());
80
+ return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1000 : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
73
86
  /** The scopes the token was granted, from its claims. Display only. */
74
87
  function tokenScopes(accessToken) {
75
88
  try {
@@ -89,8 +102,8 @@ function tokenScopes(accessToken) {
89
102
 
90
103
  /**
91
104
  * Where the app keeps each user's tokens: its database, a cache, an
92
- * encrypted cookie. The SDK never sees a token outside these three
93
- * calls. `userId` is the app's own id for its user.
105
+ * encrypted cookie. The SDK never sees a token outside these calls.
106
+ * `userId` is the app's own id for its user.
94
107
  */
95
108
 
96
109
  /** The transaction cookie the authorize leg sets and the callback reads. */
@@ -99,6 +112,14 @@ function tokenScopes(accessToken) {
99
112
 
100
113
  /** One catalog action for a platform. */
101
114
 
115
+ /**
116
+ * - `not_connected`: no tokens are stored for this user.
117
+ * - `refresh_failed`: One declared the grant dead (revoked, expired or
118
+ * reused). The tokens were cleared; ask the user to connect again.
119
+ * - `request_failed`: One answered with an error or could not be
120
+ * reached. During a refresh the tokens are kept, so retry later.
121
+ */
122
+
102
123
  class OneConnectError extends Error {
103
124
  constructor(code, message, status) {
104
125
  super(message);
@@ -126,12 +147,26 @@ class OneConnectError extends Error {
126
147
  * const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
127
148
  *
128
149
  * The Next.js and Node adapters turn the first two into route handlers.
150
+ *
151
+ * Refresh. One's access token lives an hour by default, its refresh token 30
152
+ * days; every refresh rotates both, and One treats a second use of a
153
+ * rotated refresh token as theft and revokes the whole grant. So the
154
+ * client refreshes one user at a time (in this process always, across
155
+ * processes through `tokenStore.withLock`), re-reads the store before
156
+ * spending a refresh token, clears tokens only when One declares the
157
+ * grant dead, and never lets a failing old pair delete a newer one.
129
158
  */
130
159
 
131
160
  /** Refresh this long before expiry, so a call never races the clock. */
132
161
  const REFRESH_MARGIN_MS = 60_000;
133
162
  const CATALOG_PAGE_SIZE = 100;
134
163
  const CATALOG_MAX_PAGES = 20;
164
+
165
+ /** What One's token endpoint answered. A network failure throws instead. */
166
+
167
+ /** The one refusal that means the grant is gone for good: revoked by the
168
+ * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
169
+ const isDeadGrant = answer => !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
135
170
  function createOneConnect(config) {
136
171
  const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
137
172
  const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
@@ -146,13 +181,13 @@ function createOneConnect(config) {
146
181
  /** One refresh in flight per user: two concurrent refreshes with the
147
182
  * same refresh token trip One's reuse detection. */
148
183
  const refreshing = new Map();
149
- const returnUrl = (status, message) => {
184
+ const returnUrl = (status, code) => {
150
185
  const url = new URL(returnTo, config.redirectUri);
151
186
  url.searchParams.set(RETURN_STATUS_PARAM, status);
152
- if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
187
+ if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
153
188
  return url.toString();
154
189
  };
155
- const exchange = async body => {
190
+ const postToken = async body => {
156
191
  const response = await fetch(tokenUrl, {
157
192
  method: "POST",
158
193
  headers: {
@@ -161,10 +196,40 @@ function createOneConnect(config) {
161
196
  },
162
197
  body
163
198
  });
164
- if (!response.ok) {
165
- throw new OneConnectError("request_failed", `One refused the token request (HTTP ${response.status}).`, response.status);
199
+ if (response.ok) return {
200
+ ok: true,
201
+ body: await response.json()
202
+ };
203
+ let error;
204
+ try {
205
+ const refusal = await response.json();
206
+ if (typeof refusal.error === "string") error = refusal.error;
207
+ } catch {
208
+ /* not an OAuth error body: a proxy page or a server error */
209
+ }
210
+ return {
211
+ ok: false,
212
+ status: response.status,
213
+ error
214
+ };
215
+ };
216
+ const exchange = async body => {
217
+ const answer = await postToken(body);
218
+ if (!answer.ok) {
219
+ throw new OneConnectError("request_failed", `One refused the token request (HTTP ${answer.status}).`, answer.status);
166
220
  }
167
- return await response.json();
221
+ return answer.body;
222
+ };
223
+
224
+ /** Runs `run` under the app's cross-process lock for this user, when
225
+ * the store has one. */
226
+ const locked = (userId, run) => tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
227
+
228
+ /** Whether either token of the pair stops working within `withinMs`. */
229
+ const expiresWithin = (tokens, withinMs) => {
230
+ const horizon = Date.now() + withinMs;
231
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
232
+ return tokens.expiresAt <= horizon || refreshExpiresAt !== null && refreshExpiresAt <= horizon;
168
233
  };
169
234
  const toTokens = response => ({
170
235
  accessToken: response.access_token,
@@ -196,17 +261,18 @@ function createOneConnect(config) {
196
261
  const oauthError = params.get("error");
197
262
  const cookieName = state ? txCookieName(state) : undefined;
198
263
  const verifier = cookieName ? input.getCookie(cookieName) : undefined;
199
- const fail = (outcome, message) => ({
200
- outcome,
264
+ const fail = (failure, message) => ({
265
+ outcome: failure === "declined" ? "declined" : "failed",
266
+ code: failure,
201
267
  message,
202
- redirectUrl: returnUrl("error", message),
268
+ redirectUrl: returnUrl("error", failure),
203
269
  clearCookieName: cookieName
204
270
  });
205
- if (oauthError === "access_denied") return fail("declined", "You cancelled the request.");
271
+ if (oauthError === "access_denied") return fail("declined", "The user cancelled on One's page.");
206
272
  if (oauthError) return fail("failed", `One reported an error: ${oauthError}.`);
207
- // The returned state names its own cookie. No cookie means a forged
208
- // or stale state; the code is never exchanged in that case.
209
- if (!code || !state || !verifier) return fail("failed", "The sign-in attempt expired or was tampered with.");
273
+ // The returned state names its own cookie. No cookie means a stale,
274
+ // foreign or forged state; the code is never exchanged in that case.
275
+ if (!code || !state || !verifier) return fail("expired", "The attempt expired, or its state cookie was missing.");
210
276
  try {
211
277
  const tokens = toTokens(await exchange(new URLSearchParams({
212
278
  grant_type: "authorization_code",
@@ -214,7 +280,9 @@ function createOneConnect(config) {
214
280
  redirect_uri: config.redirectUri,
215
281
  code_verifier: verifier
216
282
  })));
217
- await tokenStore.saveTokens(input.userId, tokens);
283
+ // Under the lock, so a refresh in flight on another server cannot
284
+ // interleave with this save.
285
+ await locked(input.userId, () => tokenStore.saveTokens(input.userId, tokens));
218
286
  } catch (error) {
219
287
  const status = error instanceof OneConnectError ? error.status : undefined;
220
288
  return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
@@ -225,39 +293,81 @@ function createOneConnect(config) {
225
293
  clearCookieName: cookieName
226
294
  };
227
295
  };
228
- const refreshTokens = userId => {
296
+ const notConnected = () => new OneConnectError("not_connected", "This user is not connected.");
297
+
298
+ /**
299
+ * The grant behind `failed` is dead. Clears it, unless a newer pair
300
+ * landed while it was failing (a reconnect's callback, another
301
+ * server's refresh): that pair is returned instead, because the user
302
+ * did nothing wrong and deleting it would disconnect them.
303
+ */
304
+ const retire = async (userId, failed, status) => {
305
+ const latest = await tokenStore.loadTokens(userId);
306
+ if (latest && latest.refreshToken !== failed.refreshToken) return latest;
307
+ await tokenStore.clearTokens(userId, failed);
308
+ throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
309
+ };
310
+
311
+ /**
312
+ * The one place a refresh token is spent. Under the app's lock it
313
+ * re-reads the store, and refreshes only when `stillNeeded` says the
314
+ * stored pair still needs it: another process may have refreshed while
315
+ * this one waited, and spending the same refresh token twice makes One
316
+ * revoke the grant.
317
+ */
318
+ const refreshUnderLock = (userId, stillNeeded) => locked(userId, async () => {
319
+ const current = await tokenStore.loadTokens(userId);
320
+ if (!current) throw notConnected();
321
+ if (!stillNeeded(current)) return current;
322
+
323
+ // An expired refresh token cannot work, and One answers one with a
324
+ // server error rather than invalid_grant, so settle it here.
325
+ const refreshExpiresAt = refreshTokenExpiresAt(current.refreshToken);
326
+ if (refreshExpiresAt !== null && refreshExpiresAt <= Date.now()) return retire(userId, current);
327
+ let answer;
328
+ try {
329
+ answer = await postToken(new URLSearchParams({
330
+ grant_type: "refresh_token",
331
+ refresh_token: current.refreshToken
332
+ }));
333
+ } catch {
334
+ throw new OneConnectError("request_failed", "One could not be reached to refresh the connection. The tokens were kept; try again.");
335
+ }
336
+ if (answer.ok) {
337
+ // Both tokens: One rotates the pair on every refresh.
338
+ const next = toTokens(answer.body);
339
+ await tokenStore.saveTokens(userId, next);
340
+ return next;
341
+ }
342
+ if (isDeadGrant(answer)) return retire(userId, current, answer.status);
343
+ // A server error, a rate limit, a misconfigured secret: nothing says
344
+ // the grant is gone, so keep the tokens and let the caller retry.
345
+ throw new OneConnectError("request_failed", `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`, answer.status);
346
+ });
347
+
348
+ /** One refresh per user in this process; concurrent callers share it. */
349
+ const singleFlight = (userId, job) => {
229
350
  const inFlight = refreshing.get(userId);
230
351
  if (inFlight) return inFlight;
231
- const job = (async () => {
232
- const current = await tokenStore.loadTokens(userId);
233
- if (!current) throw new OneConnectError("not_connected", "This user is not connected.");
234
- try {
235
- const next = toTokens(await exchange(new URLSearchParams({
236
- grant_type: "refresh_token",
237
- refresh_token: current.refreshToken
238
- })));
239
- // Both tokens: One rotates the pair on every refresh.
240
- await tokenStore.saveTokens(userId, next);
241
- return next;
242
- } catch (error) {
243
- // The family is dead: revoked, expired or reused. Keeping the
244
- // pair would only fail again; the user has to reconnect.
245
- await tokenStore.clearTokens(userId);
246
- const status = error instanceof OneConnectError ? error.status : undefined;
247
- throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
248
- } finally {
249
- refreshing.delete(userId);
250
- }
251
- })();
252
- refreshing.set(userId, job);
253
- return job;
352
+ const running = job().finally(() => refreshing.delete(userId));
353
+ refreshing.set(userId, running);
354
+ return running;
254
355
  };
255
- const getAccessToken = async userId => {
356
+ const refreshTokens = userId => singleFlight(userId, async () => {
357
+ const before = await tokenStore.loadTokens(userId);
358
+ if (!before) throw notConnected();
359
+ // Rotate the pair seen now; a pair someone else rotated since is
360
+ // already fresh.
361
+ return refreshUnderLock(userId, current => current.refreshToken === before.refreshToken);
362
+ });
363
+ const refreshIfExpiring = async (userId, options = {}) => {
364
+ const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
256
365
  const tokens = await tokenStore.loadTokens(userId);
257
- if (!tokens) throw new OneConnectError("not_connected", "This user is not connected.");
258
- if (Date.now() < tokens.expiresAt - REFRESH_MARGIN_MS) return tokens.accessToken;
259
- return (await refreshTokens(userId)).accessToken;
366
+ if (!tokens) throw notConnected();
367
+ if (!expiresWithin(tokens, withinMs)) return tokens;
368
+ return singleFlight(userId, () => refreshUnderLock(userId, current => expiresWithin(current, withinMs)));
260
369
  };
370
+ const getAccessToken = async userId => (await refreshIfExpiring(userId)).accessToken;
261
371
  const oneFetch = async (userId, path, init = {}) => {
262
372
  const accessToken = await getAccessToken(userId);
263
373
  const headers = new Headers(init.headers);
@@ -333,6 +443,7 @@ function createOneConnect(config) {
333
443
  getAccessToken,
334
444
  getTokens: userId => tokenStore.loadTokens(userId),
335
445
  refreshTokens,
446
+ refreshIfExpiring,
336
447
  disconnect: userId => tokenStore.clearTokens(userId),
337
448
  listConnections,
338
449
  listActions,
@@ -341,4 +452,4 @@ function createOneConnect(config) {
341
452
  };
342
453
  }
343
454
 
344
- export { OneConnectError, createOneConnect, tenancyHeaders, tokenScopes };
455
+ export { OneConnectError, createOneConnect, refreshTokenExpiresAt, tenancyHeaders, tokenScopes };
@@ -20,5 +20,9 @@ export declare function basicAuthorization(clientId: string, clientSecret: strin
20
20
  * these headers, and without them the call runs in the user's personal
21
21
  * space. The access token's claims name the tenant, so echo them. */
22
22
  export declare function tenancyHeaders(accessToken: string): Record<string, string>;
23
+ /** When a refresh token stops working, in epoch milliseconds, from its
24
+ * `exp` claim. Null when the token carries no readable expiry, in which
25
+ * case only One can say whether it still works. */
26
+ export declare function refreshTokenExpiresAt(refreshToken: string): number | null;
23
27
  /** The scopes the token was granted, from its claims. Display only. */
24
28
  export declare function tokenScopes(accessToken: string): string[];
@@ -11,13 +11,44 @@ export interface OneConnectTokens {
11
11
  }
12
12
  /**
13
13
  * Where the app keeps each user's tokens: its database, a cache, an
14
- * encrypted cookie. The SDK never sees a token outside these three
15
- * calls. `userId` is the app's own id for its user.
14
+ * encrypted cookie. The SDK never sees a token outside these calls.
15
+ * `userId` is the app's own id for its user.
16
16
  */
17
17
  export interface OneConnectTokenStore {
18
18
  saveTokens: (userId: string, tokens: OneConnectTokens) => Promise<void>;
19
19
  loadTokens: (userId: string) => Promise<OneConnectTokens | null>;
20
- clearTokens: (userId: string) => Promise<void>;
20
+ /**
21
+ * Deletes the user's tokens.
22
+ *
23
+ * `failed` is set when the SDK clears because One declared that pair
24
+ * dead. Delete only when the stored refresh token is still
25
+ * `failed.refreshToken`: a newer pair saved in the meantime (a
26
+ * reconnect, another server's refresh) must survive. `failed` is
27
+ * undefined for `disconnect`, which always deletes.
28
+ */
29
+ clearTokens: (userId: string, failed?: OneConnectTokens) => Promise<void>;
30
+ /**
31
+ * Runs `run` while holding a lock on this user that every server and
32
+ * worker of the app shares: a Postgres advisory lock, a Redis lock, a
33
+ * row lock. The SDK loads, refreshes and saves the user's tokens
34
+ * inside it.
35
+ *
36
+ * Required when the app runs more than one process (serverless,
37
+ * several instances, a background worker). One rotates the refresh
38
+ * token on every use and treats a second use of the old one as theft,
39
+ * revoking the whole grant, so two processes refreshing the same user
40
+ * at once disconnect that user. Without a lock the SDK can only stop
41
+ * that inside a single process.
42
+ *
43
+ * Hold it for at least 60 seconds before any timeout: it spans one
44
+ * call to One's token endpoint.
45
+ */
46
+ withLock?: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
47
+ }
48
+ export interface RefreshIfExpiringOptions {
49
+ /** Refresh when the access token or the refresh token expires within
50
+ * this many milliseconds. One minute when omitted. */
51
+ withinMs?: number;
21
52
  }
22
53
  export interface OneConnectServerConfig {
23
54
  /** The app's client id from the dashboard. */
@@ -70,10 +101,15 @@ export interface CompleteAuthorizationInput {
70
101
  /** Reads a cookie by name from the incoming request. */
71
102
  getCookie: (name: string) => string | undefined;
72
103
  }
104
+ import type { ConnectFailureCode } from "../types";
105
+ export type { ConnectFailureCode };
73
106
  export type AuthorizationOutcome = "connected" | "declined" | "failed";
74
107
  export interface CompleteAuthorizationResult {
75
108
  outcome: AuthorizationOutcome;
76
- /** Safe to show to the user when the outcome is not "connected". */
109
+ /** Why it failed, as the code the browser receives. Only the code goes
110
+ * on the return URL; the browser shows fixed text for it. */
111
+ code?: ConnectFailureCode;
112
+ /** What happened, for your logs. Never put it in front of the user. */
77
113
  message?: string;
78
114
  /** Send the browser here with a 302; it carries `?one_connect=…`. */
79
115
  redirectUrl: string;
@@ -128,6 +164,13 @@ export interface RunActionResult {
128
164
  blockedByGrant: boolean;
129
165
  data: unknown;
130
166
  }
167
+ /**
168
+ * - `not_connected`: no tokens are stored for this user.
169
+ * - `refresh_failed`: One declared the grant dead (revoked, expired or
170
+ * reused). The tokens were cleared; ask the user to connect again.
171
+ * - `request_failed`: One answered with an error or could not be
172
+ * reached. During a refresh the tokens are kept, so retry later.
173
+ */
131
174
  export type OneConnectErrorCode = "not_connected" | "refresh_failed" | "request_failed";
132
175
  export declare class OneConnectError extends Error {
133
176
  readonly code: OneConnectErrorCode;
@@ -1 +1 @@
1
- "use strict";var t=require("@withone/connect");exports.connectButton=function(o,n){let e=t.mountConnectButton(o,t.optionsFromProps(n));return{update(n){e.destroy(),e=t.mountConnectButton(o,t.optionsFromProps(n))},destroy(){e.destroy()}}};
1
+ "use strict";var t=require("@withone/connect");exports.connectButton=function(n,e){const o=t.mountConnectButton(n,e);return{update:t=>o.update(t),destroy:()=>o.destroy()}};
package/dist/svelte.d.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"],
8
+ * platforms: ["stripe", "notion"], connected: data.hasOneGrant,
9
9
  * onSuccess: () => { ... } }} />
10
10
  */
11
11
  import { type ConnectButtonProps } from "@withone/connect";
@@ -1 +1 @@
1
- import{mountConnectButton as t,optionsFromProps as e}from"@withone/connect";function o(o,r){let n=t(o,e(r));return{update(r){n.destroy(),n=t(o,e(r))},destroy(){n.destroy()}}}export{o as connectButton};
1
+ import{mountConnectButton as t}from"@withone/connect";function o(o,e){const n=t(o,e);return{update:t=>n.update(t),destroy:()=>n.destroy()}}export{o as connectButton};
package/dist/types.d.ts CHANGED
@@ -8,32 +8,55 @@
8
8
  */
9
9
  /** Theme of One's hosted connect page. */
10
10
  export type OneConnectTheme = "light" | "dark";
11
- export interface OneConnectOptions {
11
+ /** Theme of the button: fixed, or following the visitor's setting. */
12
+ export type ConnectButtonTheme = "light" | "dark" | "auto";
13
+ /**
14
+ * Why a flow ended without a grant. The callback route puts only this
15
+ * code on the return URL; the text shown for it is fixed in the SDK, so
16
+ * a crafted link can never put its own words in front of the user.
17
+ *
18
+ * - `declined`: the user cancelled on One's page.
19
+ * - `expired`: the attempt took too long or was started elsewhere.
20
+ * - `failed`: One could not complete the connection.
21
+ */
22
+ export type ConnectFailureCode = "declined" | "expired" | "failed";
23
+ export interface OneConnectFlowOptions {
12
24
  /** The app's own backend authorize route. Relative paths such as
13
25
  * "/api/one/authorize" resolve against the page's origin. */
14
26
  authorizeUrl: string;
15
27
  /** Theme for One's hosted page. Carried on the URL fragment, which
16
28
  * survives the redirect chain, so the backend forwards nothing. */
29
+ connectTheme?: OneConnectTheme;
30
+ /** @deprecated Renamed to `connectTheme`; removed in the next minor. */
17
31
  appTheme?: OneConnectTheme;
18
- /** The grant completed and the backend stored the tokens. */
32
+ /** The grant completed and the backend stored the tokens. Fires once
33
+ * per page load, on the first flow still mounted when the tab
34
+ * returns. Treat it as a hint to refetch: your server is the truth. */
19
35
  onSuccess?: () => void;
20
- /** The flow ended without a grant: the user declined, the attempt
21
- * expired, or the exchange failed. `message` is safe to show. */
22
- onError?: (message: string) => void;
36
+ /** The flow ended without a grant. `message` is fixed text for
37
+ * `code`, safe to show. */
38
+ onError?: (message: string, code: ConnectFailureCode) => void;
39
+ /** The user came back with the browser's Back button before finishing
40
+ * (the page was restored from the back-forward cache). */
41
+ onCancel?: () => void;
23
42
  }
24
- export interface OneConnectHandle {
43
+ export interface OneConnectFlow {
25
44
  /** Navigates the tab to One's hosted connect flow. */
26
45
  open: () => void;
46
+ /** Swaps the options (callbacks, theme) without losing the flow. */
47
+ update: (options: OneConnectFlowOptions) => void;
48
+ /** Stops listening: callbacks no longer fire for this flow. */
49
+ destroy: () => void;
27
50
  }
28
- /** How the app's callback route reports the outcome on its final
29
- * redirect, read off the page URL when the tab returns. */
51
+ /** How the flow ended, read off the page URL when the tab returns. */
30
52
  export interface OneConnectReturn {
31
53
  status: "success" | "error";
54
+ code?: ConnectFailureCode;
32
55
  message?: string;
33
56
  }
34
57
  /**
35
58
  * A connector chip on the button. Pass One's connector slug ("stripe",
36
- * "google-calendar") and the SDK shows the logo and the name; pass an
59
+ * "google-calendar") and the SDK shows its logo and name; pass an
37
60
  * object to override either.
38
61
  */
39
62
  export type ConnectButtonPlatformInput = string | {
@@ -48,31 +71,50 @@ export interface ConnectButtonPlatform {
48
71
  imageUrl: string;
49
72
  }
50
73
  export type ConnectButtonVariant = "default" | "accent" | "block";
74
+ export type ConnectButtonSize = "sm" | "md" | "lg";
51
75
  export type ConnectButtonState = "idle" | "connecting" | "connected";
52
- export interface ConnectButtonOptions {
53
- /** Everything the flow needs; the button wires open() and the
54
- * Connecting and Connected states around your callbacks. */
55
- connect: OneConnectOptions;
56
- /** "Connect your apps" unless overridden. */
57
- label?: string;
58
- /** default = neutral pill; accent = brand-colored pill; block =
59
- * full-width card with a description and a "Secured by One" foot. */
60
- variant?: ConnectButtonVariant;
61
- /** Matches the host page, not One's page (that is connect.appTheme). */
62
- theme?: OneConnectTheme;
63
- /** Connector chips. The first three render; the rest fold into a "+N"
64
- * chip, so that count only ever describes this list. */
76
+ /** One prop shape for every surface: React, Vue, Svelte, the custom
77
+ * element and `mountConnectButton`. */
78
+ export interface ConnectButtonProps {
79
+ /** The app's own backend authorize route; relative is fine. */
80
+ authorizeUrl: string;
81
+ /** Connector slugs, or objects that override the name or the logo.
82
+ * The first three draw as logos; the rest fold into a "+N" chip. */
65
83
  platforms?: ConnectButtonPlatformInput[];
66
- /** Sub-line on the block variant, shown while idle. */
67
- description?: string;
68
- /** Fill of the accent variant; One's lime when omitted. */
84
+ /** Whether this user has a live grant, from your server. When set, it
85
+ * decides the Connected state. When omitted, the button shows
86
+ * Connected only right after a successful return. */
87
+ connected?: boolean;
88
+ /** Not clickable, for example until terms are accepted. */
89
+ disabled?: boolean;
90
+ /** default = neutral, accent = your brand colour, block = a card with
91
+ * a description and a "Secured by One" foot. */
92
+ variant?: ConnectButtonVariant;
93
+ size?: ConnectButtonSize;
94
+ /** Stretches to the width of its container. */
95
+ fullWidth?: boolean;
96
+ /** Matches the host page. "auto" follows the visitor's setting. */
97
+ theme?: ConnectButtonTheme;
98
+ /** Theme of One's hosted page. */
99
+ connectTheme?: OneConnectTheme;
100
+ /** @deprecated Renamed to `connectTheme`; removed in the next minor. */
101
+ appTheme?: OneConnectTheme;
102
+ /** Fill of the accent variant; One's lime when omitted. The label is
103
+ * black or white, whichever reads better on it. */
69
104
  accentColor?: string;
70
- /** Label for the connected state. */
105
+ /** "Connect your apps" unless set. */
106
+ label?: string;
107
+ /** "Connected" unless set. */
71
108
  connectedLabel?: string;
109
+ /** Sub-line on the block variant. */
110
+ description?: string;
111
+ onSuccess?: () => void;
112
+ onError?: (message: string, code: ConnectFailureCode) => void;
113
+ onCancel?: () => void;
72
114
  }
73
115
  export interface ConnectButtonHandle {
74
- /** Override the visual state by hand. */
75
- setState: (state: ConnectButtonState) => void;
76
- /** Remove the button. */
116
+ /** Applies new props in place, keeping the button's state. */
117
+ update: (props: ConnectButtonProps) => void;
118
+ /** Removes the button and stops its callbacks. */
77
119
  destroy: () => void;
78
120
  }
package/dist/vue.cjs.js CHANGED
@@ -1 +1 @@
1
- "use strict";var e=require("vue"),t=require("@withone/connect");const n=e.defineComponent({name:"OneConnectButton",props:{authorizeUrl:{type:String,required:!0},appTheme:{type:String,default:void 0},label:{type:String,default:void 0},variant:{type:String,default:void 0},theme:{type:String,default:void 0},platforms:{type:Array,default:void 0},description:{type:String,default:void 0},accentColor:{type:String,default:void 0},connectedLabel:{type:String,default:void 0}},emits:{success:()=>!0,error:e=>"string"==typeof e},setup(n,{emit:o}){const r=e.ref(null);let i=null;const u=()=>{var e;null===(e=i)||void 0===e||e.destroy(),i=null,r.value&&(i=t.mountConnectButton(r.value,t.optionsFromProps({...n},{onSuccess:()=>o("success"),onError:e=>o("error",e)})))};return e.onMounted(u),e.watch(()=>t.propsIdentity({...n}),u),e.onBeforeUnmount(()=>{var e;null===(e=i)||void 0===e||e.destroy(),i=null}),()=>e.h("div",{ref:r})}});exports.ConnectButton=n;
1
+ "use strict";var e=require("vue"),t=require("@withone/connect");const n={type:Boolean,default:void 0},o=e.defineComponent({name:"OneConnectButton",props:{authorizeUrl:{type:String,required:!0},platforms:{type:Array,default:void 0},connected:n,disabled:n,variant:{type:String,default:void 0},size:{type:String,default:void 0},fullWidth:n,theme:{type:String,default:void 0},connectTheme:{type:String,default:void 0},appTheme:{type:String,default:void 0},accentColor:{type:String,default:void 0},label:{type:String,default:void 0},connectedLabel:{type:String,default:void 0},description:{type:String,default:void 0}},emits:{success:()=>!0,error:(e,t)=>"string"==typeof e&&"string"==typeof t,cancel:()=>!0},setup(n,{emit:o}){const r=e.ref(null);let i=null;const d=()=>({...n,onSuccess:()=>o("success"),onError:(e,t)=>o("error",e,t),onCancel:()=>o("cancel")});return e.onMounted(()=>{r.value&&(i=t.mountConnectButton(r.value,d()))}),e.watch(()=>({...n}),()=>{var e;return null===(e=i)||void 0===e?void 0:e.update(d())},{deep:!0}),e.onBeforeUnmount(()=>{var e;null===(e=i)||void 0===e||e.destroy(),i=null}),()=>e.h("div",{ref:r})}});exports.ConnectButton=o;