@withone/connect 0.16.1 → 0.17.1

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/src/react.ts CHANGED
@@ -38,6 +38,8 @@ const visualKey = (props: ConnectButtonCoreProps): string =>
38
38
  props.fullWidth,
39
39
  props.theme,
40
40
  props.connectTheme,
41
+ props.connector,
42
+ props.returnTo,
41
43
  props.label,
42
44
  props.connectedLabel,
43
45
  props.description,
@@ -38,6 +38,8 @@ import {
38
38
  createPkceVerifier,
39
39
  createState,
40
40
  pkceChallenge,
41
+ appPath,
42
+ parseTxCookieValue,
41
43
  txCookie,
42
44
  txCookieName,
43
45
  } from "./oauth";
@@ -57,6 +59,7 @@ import {
57
59
  type ReachableConnection,
58
60
  type RefreshIfExpiringOptions,
59
61
  type RunActionInput,
62
+ type OneRefusal,
60
63
  type RunActionResult,
61
64
  type SearchActionsOptions,
62
65
  type StartAuthorizationInput,
@@ -67,6 +70,24 @@ export * from "./types";
67
70
  export { encodeUserReference, parseUserReference } from "./key";
68
71
  export { refreshTokenExpiresAt, tenancyHeaders, tokenScopes } from "./oauth";
69
72
 
73
+ /**
74
+ * Why One itself refused a call, from its reply. One's own errors carry a
75
+ * correlationId; a provider's error does not, so it reads `null`. Only a
76
+ * 403 is the grant: One also answers a plan limit (402), a rate limit
77
+ * (429) and its own failures (5xx) with a correlationId, and none of those
78
+ * mean the call is outside what the user granted.
79
+ */
80
+ function oneRefusal(response: Response, data: unknown): OneRefusal | null {
81
+ if (response.ok) return null;
82
+ const fromOne =
83
+ typeof data === "object" && data !== null && "correlationId" in (data as Record<string, unknown>);
84
+ if (!fromOne) return null;
85
+ if (response.status === 403) return "grant";
86
+ if (response.status === 402) return "plan_limit";
87
+ if (response.status === 429) return "rate_limit";
88
+ return "one_error";
89
+ }
90
+
70
91
  const CATALOG_PAGE_SIZE = 100;
71
92
  const CATALOG_MAX_PAGES = 20;
72
93
  const SEARCH_DEFAULT_LIMIT = 5;
@@ -246,8 +267,12 @@ export function createOneConnect(
246
267
  const returnTo = config.returnTo ?? "/";
247
268
  const scopes = config.scopes ?? DEFAULT_SCOPES;
248
269
 
249
- const returnUrl = (status: "success" | "error", code?: ConnectFailureCode): string => {
250
- const url = new URL(returnTo, config.redirectUri);
270
+ const returnUrl = (
271
+ status: "success" | "error",
272
+ code?: ConnectFailureCode,
273
+ flowReturnTo?: string,
274
+ ): string => {
275
+ const url = new URL(flowReturnTo ?? returnTo, config.redirectUri);
251
276
  url.searchParams.set(RETURN_STATUS_PARAM, status);
252
277
  if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
253
278
  return url.toString();
@@ -319,7 +344,7 @@ export function createOneConnect(
319
344
  if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
320
345
  return {
321
346
  redirectUrl: url.toString(),
322
- cookie: txCookie(state, verifier, config.redirectUri),
347
+ cookie: txCookie(state, verifier, config.redirectUri, appPath(input.returnTo)),
323
348
  };
324
349
  };
325
350
 
@@ -331,7 +356,10 @@ export function createOneConnect(
331
356
  const state = params.get("state");
332
357
  const oauthError = params.get("error");
333
358
  const cookieName = state ? txCookieName(state) : undefined;
334
- const verifier = cookieName ? input.getCookie(cookieName) : undefined;
359
+ const stored = cookieName ? input.getCookie(cookieName) : undefined;
360
+ const { verifier, returnTo: flowReturnTo } = stored
361
+ ? parseTxCookieValue(stored)
362
+ : { verifier: undefined, returnTo: undefined };
335
363
 
336
364
  const fail = (
337
365
  failure: ConnectFailureCode,
@@ -340,7 +368,7 @@ export function createOneConnect(
340
368
  outcome: failure === "declined" ? "declined" : "failed",
341
369
  code: failure,
342
370
  message,
343
- redirectUrl: returnUrl("error", failure),
371
+ redirectUrl: returnUrl("error", failure, flowReturnTo),
344
372
  clearCookieName: cookieName,
345
373
  });
346
374
 
@@ -378,7 +406,7 @@ export function createOneConnect(
378
406
 
379
407
  return {
380
408
  outcome: "connected",
381
- redirectUrl: returnUrl("success"),
409
+ redirectUrl: returnUrl("success", undefined, flowReturnTo),
382
410
  clearCookieName: cookieName,
383
411
  };
384
412
  };
@@ -615,7 +643,7 @@ export function createOneConnect(
615
643
  // tells them apart: if that still answers, the consent stands
616
644
  // and it was this call One refused.
617
645
  if (await consentStands(userId, refused))
618
- return { status: response.status, ok: false, blockedByGrant: true, data: text };
646
+ return { status: response.status, ok: false, blockedByGrant: true, refusal: "grant", data: text };
619
647
  throw refused;
620
648
  }
621
649
  // One refusing the credential is not an answer to the action.
@@ -627,14 +655,8 @@ export function createOneConnect(
627
655
  } catch {
628
656
  /* the provider answered with something other than JSON */
629
657
  }
630
- // One's own refusals carry a correlationId; a provider's error does
631
- // not. A One-shaped 403 is the grant working, not the provider.
632
- const blockedByGrant =
633
- !response.ok &&
634
- typeof data === "object" &&
635
- data !== null &&
636
- "correlationId" in (data as Record<string, unknown>);
637
- return { status: response.status, ok: response.ok, blockedByGrant, data };
658
+ const refusal = oneRefusal(response, data);
659
+ return { status: response.status, ok: response.ok, blockedByGrant: refusal === "grant", refusal, data };
638
660
  };
639
661
 
640
662
  const shared = {
@@ -36,18 +36,49 @@ export function txCookieName(state: string): string {
36
36
  return `${TX_COOKIE_PREFIX}${state}`;
37
37
  }
38
38
 
39
+ /** A return path this one flow may use: a path on the app itself. A
40
+ * full URL, a protocol-relative one or anything else is dropped, so
41
+ * the callback never sends a user to another site. */
42
+ export function appPath(value: string | undefined): string | undefined {
43
+ if (!value || !value.startsWith("/")) return undefined;
44
+ if (value.startsWith("//") || value.startsWith("/\\")) return undefined;
45
+ return value;
46
+ }
47
+
48
+ /** The cookie value: the verifier, then the flow's own return path when
49
+ * it has one. A dot separates them; the verifier is base64url, which
50
+ * never contains one, so a value written before 0.17 still reads. */
51
+ export function txCookieValue(verifier: string, returnTo?: string): string {
52
+ return returnTo
53
+ ? `${verifier}.${Buffer.from(returnTo).toString("base64url")}`
54
+ : verifier;
55
+ }
56
+
57
+ export function parseTxCookieValue(value: string): {
58
+ verifier: string;
59
+ returnTo?: string;
60
+ } {
61
+ const dot = value.indexOf(".");
62
+ if (dot < 0) return { verifier: value };
63
+ return {
64
+ verifier: value.slice(0, dot),
65
+ returnTo: appPath(Buffer.from(value.slice(dot + 1), "base64url").toString()),
66
+ };
67
+ }
68
+
39
69
  /** The cookie scoped to the routes that need it: the directory of the
40
70
  * callback path, which is also where the authorize route lives. */
41
71
  export function txCookie(
42
72
  state: string,
43
73
  verifier: string,
44
74
  redirectUri: string,
75
+ returnTo?: string,
45
76
  ): OneConnectCookie {
46
77
  const url = new URL(redirectUri);
47
78
  const path = url.pathname.replace(/\/[^/]*$/, "") || "/";
48
79
  return {
49
80
  name: txCookieName(state),
50
- value: verifier,
81
+ value: txCookieValue(verifier, returnTo),
51
82
  options: {
52
83
  httpOnly: true,
53
84
  secure: url.protocol === "https:",
@@ -161,6 +161,10 @@ export interface OneConnectCookie {
161
161
  export interface StartAuthorizationInput {
162
162
  /** Pre-fills the sign-in email on One's page. Never locks it. */
163
163
  loginHint?: string;
164
+ /** Where this one flow returns to: a path on your app ("/chat/42").
165
+ * Kept in the flow's cookie, never sent to One. Anything that is not
166
+ * a plain path is ignored and the configured `returnTo` applies. */
167
+ returnTo?: string;
164
168
  }
165
169
 
166
170
  export interface StartAuthorizationResult {
@@ -275,13 +279,31 @@ export interface RunActionInput {
275
279
  encoding?: ActionBodyEncoding;
276
280
  }
277
281
 
282
+ /**
283
+ * Why One itself turned a call down, when it did. A provider's own error
284
+ * (Gmail's 404, Stripe's 400) is not One's refusal and reads `null`.
285
+ *
286
+ * - `grant`: the call is outside what the user granted (403). Do not retry.
287
+ * - `plan_limit`: a plan limit was hit (402, "Please try again later or
288
+ * upgrade your plan"). Not about the grant: wait and retry later.
289
+ * - `rate_limit`: too many calls (429). Wait and retry later.
290
+ * - `one_error`: any other error from One (for example a 5xx). Retry with
291
+ * a backoff.
292
+ */
293
+ export type OneRefusal = "grant" | "plan_limit" | "rate_limit" | "one_error";
294
+
278
295
  export interface RunActionResult {
279
296
  status: number;
280
297
  ok: boolean;
281
- /** True when One refused the call because it is outside the grant.
282
- * The provider was never called. Do not retry. Key mode sets it; in
283
- * token mode it stays false today, so treat any 403 there as refused. */
298
+ /** True only when One refused the call because it is outside the grant
299
+ * (a 403). The provider was never called. Do not retry. Key mode sets
300
+ * it; in token mode it stays false today, so treat any 403 there as
301
+ * refused. One's other refusals (plan limit, rate limit, its own
302
+ * errors) are not about the grant: see `refusal`. */
284
303
  blockedByGrant: boolean;
304
+ /** Why One turned the call down, or `null` when it didn't (the call
305
+ * succeeded, or the provider answered with its own error). */
306
+ refusal: OneRefusal | null;
285
307
  data: unknown;
286
308
  }
287
309
 
package/src/types.ts CHANGED
@@ -31,6 +31,14 @@ export interface OneConnectFlowOptions {
31
31
  /** Theme for One's hosted page. Carried on the URL fragment, which
32
32
  * survives the redirect chain, so the backend forwards nothing. */
33
33
  connectTheme?: OneConnectTheme;
34
+ /** A connector slug ("gmail"). One's page opens that connector's
35
+ * connect screen first, then shows the full list as usual. Carried
36
+ * on the URL fragment like the theme. */
37
+ connector?: string;
38
+ /** Where the user lands afterwards, for this flow only: a path on
39
+ * your app such as "/chat/42". Your authorize route reads it and
40
+ * the callback uses it instead of the server's `returnTo`. */
41
+ returnTo?: string;
34
42
  /** The grant completed and the backend stored the tokens. Fires once
35
43
  * per page load, on the first flow still mounted when the tab
36
44
  * returns. Treat it as a hint to refetch: your server is the truth. */
@@ -75,9 +83,9 @@ export interface ConnectButtonLogo {
75
83
  imageUrl: string;
76
84
  }
77
85
 
78
- /** @deprecated Renamed to `ConnectButtonLogoInput`; removed in 0.17.0. */
86
+ /** @deprecated Renamed to `ConnectButtonLogoInput`; removed in 0.18.0. */
79
87
  export type ConnectButtonPlatformInput = ConnectButtonLogoInput;
80
- /** @deprecated Renamed to `ConnectButtonLogo`; removed in 0.17.0. */
88
+ /** @deprecated Renamed to `ConnectButtonLogo`; removed in 0.18.0. */
81
89
  export type ConnectButtonPlatform = ConnectButtonLogo;
82
90
 
83
91
  export type ConnectButtonVariant = "default" | "accent" | "block";
@@ -94,7 +102,7 @@ export interface ConnectButtonProps {
94
102
  * into a "+N" chip. Decoration only; the permission set decides what
95
103
  * One asks for. */
96
104
  logos?: ConnectButtonLogoInput[];
97
- /** @deprecated Renamed to `logos`; removed in 0.17.0. */
105
+ /** @deprecated Renamed to `logos`; removed in 0.18.0. */
98
106
  platforms?: ConnectButtonLogoInput[];
99
107
  /** Whether this user has a live grant, from your server. When set, it
100
108
  * decides the Connected state. When omitted, the button shows
@@ -113,6 +121,10 @@ export interface ConnectButtonProps {
113
121
  theme?: ConnectButtonTheme;
114
122
  /** Theme of One's hosted page. */
115
123
  connectTheme?: OneConnectTheme;
124
+ /** A connector slug ("gmail") to open first on One's page. */
125
+ connector?: string;
126
+ /** Where this flow returns to: a path on your app. */
127
+ returnTo?: string;
116
128
  /** "Connect your apps" unless set. */
117
129
  label?: string;
118
130
  /** "Connected" unless set. */
package/src/vue.ts CHANGED
@@ -62,6 +62,8 @@ export const ConnectButton = defineComponent({
62
62
  type: String as PropType<OneConnectTheme>,
63
63
  default: undefined,
64
64
  },
65
+ connector: { type: String, default: undefined },
66
+ returnTo: { type: String, default: undefined },
65
67
  label: { type: String, default: undefined },
66
68
  connectedLabel: { type: String, default: undefined },
67
69
  description: { type: String, default: undefined },