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