@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.
@@ -34,14 +34,40 @@ function txCookieName(state) {
34
34
  return `${TX_COOKIE_PREFIX}${state}`;
35
35
  }
36
36
 
37
+ /** A return path this one flow may use: a path on the app itself. A
38
+ * full URL, a protocol-relative one or anything else is dropped, so
39
+ * the callback never sends a user to another site. */
40
+ function appPath(value) {
41
+ if (!value || !value.startsWith("/")) return undefined;
42
+ if (value.startsWith("//") || value.startsWith("/\\")) return undefined;
43
+ return value;
44
+ }
45
+
46
+ /** The cookie value: the verifier, then the flow's own return path when
47
+ * it has one. A dot separates them; the verifier is base64url, which
48
+ * never contains one, so a value written before 0.17 still reads. */
49
+ function txCookieValue(verifier, returnTo) {
50
+ return returnTo ? `${verifier}.${Buffer.from(returnTo).toString("base64url")}` : verifier;
51
+ }
52
+ function parseTxCookieValue(value) {
53
+ const dot = value.indexOf(".");
54
+ if (dot < 0) return {
55
+ verifier: value
56
+ };
57
+ return {
58
+ verifier: value.slice(0, dot),
59
+ returnTo: appPath(Buffer.from(value.slice(dot + 1), "base64url").toString())
60
+ };
61
+ }
62
+
37
63
  /** The cookie scoped to the routes that need it: the directory of the
38
64
  * callback path, which is also where the authorize route lives. */
39
- function txCookie(state, verifier, redirectUri) {
65
+ function txCookie(state, verifier, redirectUri, returnTo) {
40
66
  const url = new URL(redirectUri);
41
67
  const path = url.pathname.replace(/\/[^/]*$/, "") || "/";
42
68
  return {
43
69
  name: txCookieName(state),
44
- value: verifier,
70
+ value: txCookieValue(verifier, returnTo),
45
71
  options: {
46
72
  httpOnly: true,
47
73
  secure: url.protocol === "https:",
@@ -159,6 +185,18 @@ function tokenScopes(accessToken) {
159
185
  * method and path the SDK runs it with.
160
186
  */
161
187
 
188
+ /**
189
+ * Why One itself turned a call down, when it did. A provider's own error
190
+ * (Gmail's 404, Stripe's 400) is not One's refusal and reads `null`.
191
+ *
192
+ * - `grant`: the call is outside what the user granted (403). Do not retry.
193
+ * - `plan_limit`: a plan limit was hit (402, "Please try again later or
194
+ * upgrade your plan"). Not about the grant: wait and retry later.
195
+ * - `rate_limit`: too many calls (429). Wait and retry later.
196
+ * - `one_error`: any other error from One (for example a 5xx). Retry with
197
+ * a backoff.
198
+ */
199
+
162
200
  /**
163
201
  * - `not_connected`: nothing is stored for this user.
164
202
  * - `reconnect_required` (key mode): One will not act for this user.
@@ -484,6 +522,23 @@ function createTokenCredential(tokenStore, postToken) {
484
522
  * The Next.js and Node adapters turn the first two into route handlers.
485
523
  * See `./key` and `./token` for what each mode stores and sends.
486
524
  */
525
+
526
+ /**
527
+ * Why One itself refused a call, from its reply. One's own errors carry a
528
+ * correlationId; a provider's error does not, so it reads `null`. Only a
529
+ * 403 is the grant: One also answers a plan limit (402), a rate limit
530
+ * (429) and its own failures (5xx) with a correlationId, and none of those
531
+ * mean the call is outside what the user granted.
532
+ */
533
+ function oneRefusal(response, data) {
534
+ if (response.ok) return null;
535
+ const fromOne = typeof data === "object" && data !== null && "correlationId" in data;
536
+ if (!fromOne) return null;
537
+ if (response.status === 403) return "grant";
538
+ if (response.status === 402) return "plan_limit";
539
+ if (response.status === 429) return "rate_limit";
540
+ return "one_error";
541
+ }
487
542
  const CATALOG_PAGE_SIZE = 100;
488
543
  const CATALOG_MAX_PAGES = 20;
489
544
  const SEARCH_DEFAULT_LIMIT = 5;
@@ -563,8 +618,8 @@ function createOneConnect(config) {
563
618
  const apiUrl = `${oneApiUrl}/v1`;
564
619
  const returnTo = config.returnTo ?? "/";
565
620
  const scopes = config.scopes ?? DEFAULT_SCOPES;
566
- const returnUrl = (status, code) => {
567
- const url = new URL(returnTo, config.redirectUri);
621
+ const returnUrl = (status, code, flowReturnTo) => {
622
+ const url = new URL(flowReturnTo ?? returnTo, config.redirectUri);
568
623
  url.searchParams.set(RETURN_STATUS_PARAM, status);
569
624
  if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
570
625
  return url.toString();
@@ -623,7 +678,7 @@ function createOneConnect(config) {
623
678
  if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
624
679
  return {
625
680
  redirectUrl: url.toString(),
626
- cookie: txCookie(state, verifier, config.redirectUri)
681
+ cookie: txCookie(state, verifier, config.redirectUri, appPath(input.returnTo))
627
682
  };
628
683
  };
629
684
  const completeAuthorization = async input => {
@@ -632,12 +687,19 @@ function createOneConnect(config) {
632
687
  const state = params.get("state");
633
688
  const oauthError = params.get("error");
634
689
  const cookieName = state ? txCookieName(state) : undefined;
635
- const verifier = cookieName ? input.getCookie(cookieName) : undefined;
690
+ const stored = cookieName ? input.getCookie(cookieName) : undefined;
691
+ const {
692
+ verifier,
693
+ returnTo: flowReturnTo
694
+ } = stored ? parseTxCookieValue(stored) : {
695
+ verifier: undefined,
696
+ returnTo: undefined
697
+ };
636
698
  const fail = (failure, message) => ({
637
699
  outcome: failure === "declined" ? "declined" : "failed",
638
700
  code: failure,
639
701
  message,
640
- redirectUrl: returnUrl("error", failure),
702
+ redirectUrl: returnUrl("error", failure, flowReturnTo),
641
703
  clearCookieName: cookieName
642
704
  });
643
705
  if (oauthError === "access_denied") return fail("declined", "The user cancelled on One's page.");
@@ -660,7 +722,7 @@ function createOneConnect(config) {
660
722
  }
661
723
  return {
662
724
  outcome: "connected",
663
- redirectUrl: returnUrl("success"),
725
+ redirectUrl: returnUrl("success", undefined, flowReturnTo),
664
726
  clearCookieName: cookieName
665
727
  };
666
728
  };
@@ -827,6 +889,7 @@ function createOneConnect(config) {
827
889
  status: response.status,
828
890
  ok: false,
829
891
  blockedByGrant: true,
892
+ refusal: "grant",
830
893
  data: text
831
894
  };
832
895
  throw refused;
@@ -840,13 +903,12 @@ function createOneConnect(config) {
840
903
  } catch {
841
904
  /* the provider answered with something other than JSON */
842
905
  }
843
- // One's own refusals carry a correlationId; a provider's error does
844
- // not. A One-shaped 403 is the grant working, not the provider.
845
- const blockedByGrant = !response.ok && typeof data === "object" && data !== null && "correlationId" in data;
906
+ const refusal = oneRefusal(response, data);
846
907
  return {
847
908
  status: response.status,
848
909
  ok: response.ok,
849
- blockedByGrant,
910
+ blockedByGrant: refusal === "grant",
911
+ refusal,
850
912
  data
851
913
  };
852
914
  };
@@ -32,14 +32,40 @@ function txCookieName(state) {
32
32
  return `${TX_COOKIE_PREFIX}${state}`;
33
33
  }
34
34
 
35
+ /** A return path this one flow may use: a path on the app itself. A
36
+ * full URL, a protocol-relative one or anything else is dropped, so
37
+ * the callback never sends a user to another site. */
38
+ function appPath(value) {
39
+ if (!value || !value.startsWith("/")) return undefined;
40
+ if (value.startsWith("//") || value.startsWith("/\\")) return undefined;
41
+ return value;
42
+ }
43
+
44
+ /** The cookie value: the verifier, then the flow's own return path when
45
+ * it has one. A dot separates them; the verifier is base64url, which
46
+ * never contains one, so a value written before 0.17 still reads. */
47
+ function txCookieValue(verifier, returnTo) {
48
+ return returnTo ? `${verifier}.${Buffer.from(returnTo).toString("base64url")}` : verifier;
49
+ }
50
+ function parseTxCookieValue(value) {
51
+ const dot = value.indexOf(".");
52
+ if (dot < 0) return {
53
+ verifier: value
54
+ };
55
+ return {
56
+ verifier: value.slice(0, dot),
57
+ returnTo: appPath(Buffer.from(value.slice(dot + 1), "base64url").toString())
58
+ };
59
+ }
60
+
35
61
  /** The cookie scoped to the routes that need it: the directory of the
36
62
  * callback path, which is also where the authorize route lives. */
37
- function txCookie(state, verifier, redirectUri) {
63
+ function txCookie(state, verifier, redirectUri, returnTo) {
38
64
  const url = new URL(redirectUri);
39
65
  const path = url.pathname.replace(/\/[^/]*$/, "") || "/";
40
66
  return {
41
67
  name: txCookieName(state),
42
- value: verifier,
68
+ value: txCookieValue(verifier, returnTo),
43
69
  options: {
44
70
  httpOnly: true,
45
71
  secure: url.protocol === "https:",
@@ -157,6 +183,18 @@ function tokenScopes(accessToken) {
157
183
  * method and path the SDK runs it with.
158
184
  */
159
185
 
186
+ /**
187
+ * Why One itself turned a call down, when it did. A provider's own error
188
+ * (Gmail's 404, Stripe's 400) is not One's refusal and reads `null`.
189
+ *
190
+ * - `grant`: the call is outside what the user granted (403). Do not retry.
191
+ * - `plan_limit`: a plan limit was hit (402, "Please try again later or
192
+ * upgrade your plan"). Not about the grant: wait and retry later.
193
+ * - `rate_limit`: too many calls (429). Wait and retry later.
194
+ * - `one_error`: any other error from One (for example a 5xx). Retry with
195
+ * a backoff.
196
+ */
197
+
160
198
  /**
161
199
  * - `not_connected`: nothing is stored for this user.
162
200
  * - `reconnect_required` (key mode): One will not act for this user.
@@ -482,6 +520,23 @@ function createTokenCredential(tokenStore, postToken) {
482
520
  * The Next.js and Node adapters turn the first two into route handlers.
483
521
  * See `./key` and `./token` for what each mode stores and sends.
484
522
  */
523
+
524
+ /**
525
+ * Why One itself refused a call, from its reply. One's own errors carry a
526
+ * correlationId; a provider's error does not, so it reads `null`. Only a
527
+ * 403 is the grant: One also answers a plan limit (402), a rate limit
528
+ * (429) and its own failures (5xx) with a correlationId, and none of those
529
+ * mean the call is outside what the user granted.
530
+ */
531
+ function oneRefusal(response, data) {
532
+ if (response.ok) return null;
533
+ const fromOne = typeof data === "object" && data !== null && "correlationId" in data;
534
+ if (!fromOne) return null;
535
+ if (response.status === 403) return "grant";
536
+ if (response.status === 402) return "plan_limit";
537
+ if (response.status === 429) return "rate_limit";
538
+ return "one_error";
539
+ }
485
540
  const CATALOG_PAGE_SIZE = 100;
486
541
  const CATALOG_MAX_PAGES = 20;
487
542
  const SEARCH_DEFAULT_LIMIT = 5;
@@ -561,8 +616,8 @@ function createOneConnect(config) {
561
616
  const apiUrl = `${oneApiUrl}/v1`;
562
617
  const returnTo = config.returnTo ?? "/";
563
618
  const scopes = config.scopes ?? DEFAULT_SCOPES;
564
- const returnUrl = (status, code) => {
565
- const url = new URL(returnTo, config.redirectUri);
619
+ const returnUrl = (status, code, flowReturnTo) => {
620
+ const url = new URL(flowReturnTo ?? returnTo, config.redirectUri);
566
621
  url.searchParams.set(RETURN_STATUS_PARAM, status);
567
622
  if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
568
623
  return url.toString();
@@ -621,7 +676,7 @@ function createOneConnect(config) {
621
676
  if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
622
677
  return {
623
678
  redirectUrl: url.toString(),
624
- cookie: txCookie(state, verifier, config.redirectUri)
679
+ cookie: txCookie(state, verifier, config.redirectUri, appPath(input.returnTo))
625
680
  };
626
681
  };
627
682
  const completeAuthorization = async input => {
@@ -630,12 +685,19 @@ function createOneConnect(config) {
630
685
  const state = params.get("state");
631
686
  const oauthError = params.get("error");
632
687
  const cookieName = state ? txCookieName(state) : undefined;
633
- const verifier = cookieName ? input.getCookie(cookieName) : undefined;
688
+ const stored = cookieName ? input.getCookie(cookieName) : undefined;
689
+ const {
690
+ verifier,
691
+ returnTo: flowReturnTo
692
+ } = stored ? parseTxCookieValue(stored) : {
693
+ verifier: undefined,
694
+ returnTo: undefined
695
+ };
634
696
  const fail = (failure, message) => ({
635
697
  outcome: failure === "declined" ? "declined" : "failed",
636
698
  code: failure,
637
699
  message,
638
- redirectUrl: returnUrl("error", failure),
700
+ redirectUrl: returnUrl("error", failure, flowReturnTo),
639
701
  clearCookieName: cookieName
640
702
  });
641
703
  if (oauthError === "access_denied") return fail("declined", "The user cancelled on One's page.");
@@ -658,7 +720,7 @@ function createOneConnect(config) {
658
720
  }
659
721
  return {
660
722
  outcome: "connected",
661
- redirectUrl: returnUrl("success"),
723
+ redirectUrl: returnUrl("success", undefined, flowReturnTo),
662
724
  clearCookieName: cookieName
663
725
  };
664
726
  };
@@ -825,6 +887,7 @@ function createOneConnect(config) {
825
887
  status: response.status,
826
888
  ok: false,
827
889
  blockedByGrant: true,
890
+ refusal: "grant",
828
891
  data: text
829
892
  };
830
893
  throw refused;
@@ -838,13 +901,12 @@ function createOneConnect(config) {
838
901
  } catch {
839
902
  /* the provider answered with something other than JSON */
840
903
  }
841
- // One's own refusals carry a correlationId; a provider's error does
842
- // not. A One-shaped 403 is the grant working, not the provider.
843
- const blockedByGrant = !response.ok && typeof data === "object" && data !== null && "correlationId" in data;
904
+ const refusal = oneRefusal(response, data);
844
905
  return {
845
906
  status: response.status,
846
907
  ok: response.ok,
847
- blockedByGrant,
908
+ blockedByGrant: refusal === "grant",
909
+ refusal,
848
910
  data
849
911
  };
850
912
  };
@@ -11,9 +11,21 @@ export declare function createState(): string;
11
11
  export declare function createPkceVerifier(): string;
12
12
  export declare function pkceChallenge(verifier: string): string;
13
13
  export declare function txCookieName(state: string): string;
14
+ /** A return path this one flow may use: a path on the app itself. A
15
+ * full URL, a protocol-relative one or anything else is dropped, so
16
+ * the callback never sends a user to another site. */
17
+ export declare function appPath(value: string | undefined): string | undefined;
18
+ /** The cookie value: the verifier, then the flow's own return path when
19
+ * it has one. A dot separates them; the verifier is base64url, which
20
+ * never contains one, so a value written before 0.17 still reads. */
21
+ export declare function txCookieValue(verifier: string, returnTo?: string): string;
22
+ export declare function parseTxCookieValue(value: string): {
23
+ verifier: string;
24
+ returnTo?: string;
25
+ };
14
26
  /** The cookie scoped to the routes that need it: the directory of the
15
27
  * callback path, which is also where the authorize route lives. */
16
- export declare function txCookie(state: string, verifier: string, redirectUri: string): OneConnectCookie;
28
+ export declare function txCookie(state: string, verifier: string, redirectUri: string, returnTo?: string): OneConnectCookie;
17
29
  export declare function basicAuthorization(clientId: string, clientSecret: string): string;
18
30
  /** The tenant headers a grant needs on every call. A grant made into an
19
31
  * organization or project lives there; One resolves the tenant from
@@ -149,6 +149,10 @@ export interface OneConnectCookie {
149
149
  export interface StartAuthorizationInput {
150
150
  /** Pre-fills the sign-in email on One's page. Never locks it. */
151
151
  loginHint?: string;
152
+ /** Where this one flow returns to: a path on your app ("/chat/42").
153
+ * Kept in the flow's cookie, never sent to One. Anything that is not
154
+ * a plain path is ignored and the configured `returnTo` applies. */
155
+ returnTo?: string;
152
156
  }
153
157
  export interface StartAuthorizationResult {
154
158
  /** Send the browser here with a 302. */
@@ -256,13 +260,30 @@ export interface RunActionInput {
256
260
  * notation); "multipart" for file-style uploads. */
257
261
  encoding?: ActionBodyEncoding;
258
262
  }
263
+ /**
264
+ * Why One itself turned a call down, when it did. A provider's own error
265
+ * (Gmail's 404, Stripe's 400) is not One's refusal and reads `null`.
266
+ *
267
+ * - `grant`: the call is outside what the user granted (403). Do not retry.
268
+ * - `plan_limit`: a plan limit was hit (402, "Please try again later or
269
+ * upgrade your plan"). Not about the grant: wait and retry later.
270
+ * - `rate_limit`: too many calls (429). Wait and retry later.
271
+ * - `one_error`: any other error from One (for example a 5xx). Retry with
272
+ * a backoff.
273
+ */
274
+ export type OneRefusal = "grant" | "plan_limit" | "rate_limit" | "one_error";
259
275
  export interface RunActionResult {
260
276
  status: number;
261
277
  ok: boolean;
262
- /** True when One refused the call because it is outside the grant.
263
- * The provider was never called. Do not retry. Key mode sets it; in
264
- * token mode it stays false today, so treat any 403 there as refused. */
278
+ /** True only when One refused the call because it is outside the grant
279
+ * (a 403). The provider was never called. Do not retry. Key mode sets
280
+ * it; in token mode it stays false today, so treat any 403 there as
281
+ * refused. One's other refusals (plan limit, rate limit, its own
282
+ * errors) are not about the grant: see `refusal`. */
265
283
  blockedByGrant: boolean;
284
+ /** Why One turned the call down, or `null` when it didn't (the call
285
+ * succeeded, or the provider answered with its own error). */
286
+ refusal: OneRefusal | null;
266
287
  data: unknown;
267
288
  }
268
289
  /**
package/dist/types.d.ts CHANGED
@@ -27,6 +27,14 @@ export interface OneConnectFlowOptions {
27
27
  /** Theme for One's hosted page. Carried on the URL fragment, which
28
28
  * survives the redirect chain, so the backend forwards nothing. */
29
29
  connectTheme?: OneConnectTheme;
30
+ /** A connector slug ("gmail"). One's page opens that connector's
31
+ * connect screen first, then shows the full list as usual. Carried
32
+ * on the URL fragment like the theme. */
33
+ connector?: string;
34
+ /** Where the user lands afterwards, for this flow only: a path on
35
+ * your app such as "/chat/42". Your authorize route reads it and
36
+ * the callback uses it instead of the server's `returnTo`. */
37
+ returnTo?: string;
30
38
  /** The grant completed and the backend stored the tokens. Fires once
31
39
  * per page load, on the first flow still mounted when the tab
32
40
  * returns. Treat it as a hint to refetch: your server is the truth. */
@@ -69,9 +77,9 @@ export interface ConnectButtonLogo {
69
77
  name: string;
70
78
  imageUrl: string;
71
79
  }
72
- /** @deprecated Renamed to `ConnectButtonLogoInput`; removed in 0.17.0. */
80
+ /** @deprecated Renamed to `ConnectButtonLogoInput`; removed in 0.18.0. */
73
81
  export type ConnectButtonPlatformInput = ConnectButtonLogoInput;
74
- /** @deprecated Renamed to `ConnectButtonLogo`; removed in 0.17.0. */
82
+ /** @deprecated Renamed to `ConnectButtonLogo`; removed in 0.18.0. */
75
83
  export type ConnectButtonPlatform = ConnectButtonLogo;
76
84
  export type ConnectButtonVariant = "default" | "accent" | "block";
77
85
  export type ConnectButtonSize = "sm" | "md" | "lg";
@@ -86,7 +94,7 @@ export interface ConnectButtonProps {
86
94
  * into a "+N" chip. Decoration only; the permission set decides what
87
95
  * One asks for. */
88
96
  logos?: ConnectButtonLogoInput[];
89
- /** @deprecated Renamed to `logos`; removed in 0.17.0. */
97
+ /** @deprecated Renamed to `logos`; removed in 0.18.0. */
90
98
  platforms?: ConnectButtonLogoInput[];
91
99
  /** Whether this user has a live grant, from your server. When set, it
92
100
  * decides the Connected state. When omitted, the button shows
@@ -105,6 +113,10 @@ export interface ConnectButtonProps {
105
113
  theme?: ConnectButtonTheme;
106
114
  /** Theme of One's hosted page. */
107
115
  connectTheme?: OneConnectTheme;
116
+ /** A connector slug ("gmail") to open first on One's page. */
117
+ connector?: string;
118
+ /** Where this flow returns to: a path on your app. */
119
+ returnTo?: string;
108
120
  /** "Connect your apps" unless set. */
109
121
  label?: string;
110
122
  /** "Connected" unless set. */
package/dist/vue.cjs.js CHANGED
@@ -1 +1 @@
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},logos:{type:Array,default:void 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},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 l=()=>({...n,onSuccess:()=>o("success"),onError:(e,t)=>o("error",e,t),onCancel:()=>o("cancel")});return e.onMounted(()=>{r.value&&(i=t.renderConnectButton(r.value,l()))}),e.watch(()=>({...n}),()=>{var e;return null===(e=i)||void 0===e?void 0:e.update(l())},{deep:!0}),e.onBeforeUnmount(()=>{var e;null===(e=i)||void 0===e||e.destroy(),i=null}),()=>{var t;return e.h("span",{ref:r,class:"one-connect","data-variant":null!==(t=n.variant)&&void 0!==t?t:"default","data-full-width":n.fullWidth?"":void 0})}}});exports.ConnectButton=o;
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},logos:{type:Array,default:void 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},connector:{type:String,default:void 0},returnTo:{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.renderConnectButton(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}),()=>{var t;return e.h("span",{ref:r,class:"one-connect","data-variant":null!==(t=n.variant)&&void 0!==t?t:"default","data-full-width":n.fullWidth?"":void 0})}}});exports.ConnectButton=o;
package/dist/vue.d.ts CHANGED
@@ -42,6 +42,14 @@ export declare const ConnectButton: import("vue").DefineComponent<import("vue").
42
42
  type: PropType<OneConnectTheme>;
43
43
  default: undefined;
44
44
  };
45
+ connector: {
46
+ type: StringConstructor;
47
+ default: undefined;
48
+ };
49
+ returnTo: {
50
+ type: StringConstructor;
51
+ default: undefined;
52
+ };
45
53
  label: {
46
54
  type: StringConstructor;
47
55
  default: undefined;
@@ -102,6 +110,14 @@ export declare const ConnectButton: import("vue").DefineComponent<import("vue").
102
110
  type: PropType<OneConnectTheme>;
103
111
  default: undefined;
104
112
  };
113
+ connector: {
114
+ type: StringConstructor;
115
+ default: undefined;
116
+ };
117
+ returnTo: {
118
+ type: StringConstructor;
119
+ default: undefined;
120
+ };
105
121
  label: {
106
122
  type: StringConstructor;
107
123
  default: undefined;
@@ -123,6 +139,8 @@ export declare const ConnectButton: import("vue").DefineComponent<import("vue").
123
139
  label: string;
124
140
  disabled: boolean;
125
141
  connectTheme: OneConnectTheme;
142
+ connector: string;
143
+ returnTo: string;
126
144
  logos: ConnectButtonLogoInput[];
127
145
  platforms: ConnectButtonLogoInput[];
128
146
  variant: ConnectButtonVariant;
package/dist/vue.esm.js CHANGED
@@ -1 +1 @@
1
- import{defineComponent as e,ref as t,onMounted as n,watch as o,onBeforeUnmount as r,h as l}from"vue";import{renderConnectButton as i}from"@withone/connect";const a={type:Boolean,default:void 0},d=e({name:"OneConnectButton",props:{authorizeUrl:{type:String,required:!0},logos:{type:Array,default:void 0},platforms:{type:Array,default:void 0},connected:a,disabled:a,variant:{type:String,default:void 0},size:{type:String,default:void 0},fullWidth:a,theme:{type:String,default:void 0},connectTheme:{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(e,{emit:a}){const d=t(null);let u=null;const c=()=>({...e,onSuccess:()=>a("success"),onError:(e,t)=>a("error",e,t),onCancel:()=>a("cancel")});return n(()=>{d.value&&(u=i(d.value,c()))}),o(()=>({...e}),()=>{var e;return null===(e=u)||void 0===e?void 0:e.update(c())},{deep:!0}),r(()=>{var e;null===(e=u)||void 0===e||e.destroy(),u=null}),()=>{var t;return l("span",{ref:d,class:"one-connect","data-variant":null!==(t=e.variant)&&void 0!==t?t:"default","data-full-width":e.fullWidth?"":void 0})}}});export{d as ConnectButton};
1
+ import{defineComponent as e,ref as t,onMounted as n,watch as o,onBeforeUnmount as r,h as i}from"vue";import{renderConnectButton as l}from"@withone/connect";const d={type:Boolean,default:void 0},a=e({name:"OneConnectButton",props:{authorizeUrl:{type:String,required:!0},logos:{type:Array,default:void 0},platforms:{type:Array,default:void 0},connected:d,disabled:d,variant:{type:String,default:void 0},size:{type:String,default:void 0},fullWidth:d,theme:{type:String,default:void 0},connectTheme:{type:String,default:void 0},connector:{type:String,default:void 0},returnTo:{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(e,{emit:d}){const a=t(null);let u=null;const c=()=>({...e,onSuccess:()=>d("success"),onError:(e,t)=>d("error",e,t),onCancel:()=>d("cancel")});return n(()=>{a.value&&(u=l(a.value,c()))}),o(()=>({...e}),()=>{var e;return null===(e=u)||void 0===e?void 0:e.update(c())},{deep:!0}),r(()=>{var e;null===(e=u)||void 0===e||e.destroy(),u=null}),()=>{var t;return i("span",{ref:a,class:"one-connect","data-variant":null!==(t=e.variant)&&void 0!==t?t:"default","data-full-width":e.fullWidth?"":void 0})}}});export{a as ConnectButton};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/connect",
3
- "version": "0.16.1",
3
+ "version": "0.17.1",
4
4
  "description": "One Connect for your app: the button your users press, the two backend routes as one import, and a server client that calls One with the grant. Users keep their connections in One; your app holds only what they granted.",
5
5
  "files": [
6
6
  "dist",
@@ -149,8 +149,10 @@ import { ConnectButton } from "@withone/connect/react";
149
149
 
150
150
  Optional props: `variant` ("default" | "accent" | "block"), `size` ("sm" |
151
151
  "md" | "lg"), `fullWidth`, `theme` ("light" | "dark" | "auto"),
152
- `connectTheme` ("light" | "dark", One's page), `label`, `connectedLabel`,
153
- `description`, `disabled`, `onCancel`. The accent variant's colours come
152
+ `connectTheme` ("light" | "dark", One's page), `connector` (a slug such as
153
+ "gmail": One's page opens that connector's connect screen first, then the
154
+ usual list), `returnTo` (a path on the app this flow returns to), `label`,
155
+ `connectedLabel`, `description`, `disabled`, `onCancel`. The accent variant's colours come
154
156
  from the host's `--one-connect-accent` and `--one-connect-accent-fg` CSS
155
157
  variables.
156
158
 
@@ -177,8 +179,15 @@ keep it and wire the flow to it instead of adding a second style:
177
179
  button.addEventListener("click", () => flow.open());
178
180
  ```
179
181
 
182
+ - One connector at a time (a chat that says "Connect Gmail", a settings
183
+ row): `useOneConnect({ authorizeUrl, connector: "gmail", returnTo: "/chat/42" })`.
184
+ One's page still signs the user in and asks for the space, then opens
185
+ Gmail's connect screen instead of the list; the list follows with
186
+ everything connected. `returnTo` must be a path on the app.
187
+
180
188
  - No SDK in the browser (server-rendered pages): a plain link,
181
- `<a href="/api/one/authorize">`. The user returns with
189
+ `<a href="/api/one/authorize">` (add `#one_connector=gmail` and
190
+ `?one_return_to=%2Fchat%2F42` for the above). The user returns with
182
191
  `?one_connect=success`, or `?one_connect=error&one_connect_error=` with
183
192
  `declined`, `expired` or `failed`. Show the app's own text per code, never
184
193
  text from the URL, and remove the parameters after reading them.
@@ -205,7 +214,7 @@ const reply = await oneConnect.runAction(userId, {
205
214
  encoding: "json", // or "form" / "multipart", as the guide says
206
215
  headers: {}, // only when the guide names one
207
216
  });
208
- // { status, ok, blockedByGrant, data }
217
+ // { status, ok, blockedByGrant, refusal, data }
209
218
  ```
210
219
 
211
220
  Each connection's `access` is `{ policy: "full" }`, `{ policy: "methods",
@@ -221,8 +230,18 @@ user's id to every call it makes.
221
230
 
222
231
  ## 8 - Errors
223
232
 
224
- A `403` reply with `blockedByGrant: true` means the call is outside what
225
- the user granted. Do not retry it.
233
+ `refusal` says why One itself turned a call down:
234
+
235
+ - `"grant"` (`403`, `blockedByGrant: true`): outside what the user granted.
236
+ Do not retry it.
237
+ - `"plan_limit"` (`402`): a plan limit, not the grant. Wait and retry later.
238
+ Do not ask the user to reconnect.
239
+ - `"rate_limit"` (`429`): too many calls. Wait and retry.
240
+ - `"one_error"` (other, e.g. `5xx`): retry with a backoff.
241
+ - `null`: not One's refusal; the provider's own answer, or a success.
242
+
243
+ Only a `403` means "outside the grant". Never treat a `402`, `429` or `5xx`
244
+ as a missing permission.
226
245
 
227
246
  Errors are `OneConnectError` with a `code`. Handle them where the app calls One:
228
247
 
package/src/button.ts CHANGED
@@ -182,6 +182,8 @@ const flowOptions = (
182
182
  ): OneConnectFlowOptions => ({
183
183
  authorizeUrl: props.authorizeUrl,
184
184
  connectTheme: props.connectTheme,
185
+ connector: props.connector,
186
+ returnTo: props.returnTo,
185
187
  ...handlers,
186
188
  });
187
189
 
@@ -382,6 +384,8 @@ const ATTRIBUTES = [
382
384
  "full-width",
383
385
  "theme",
384
386
  "connect-theme",
387
+ "connector",
388
+ "return-to",
385
389
  "label",
386
390
  "connected-label",
387
391
  "description",
@@ -454,6 +458,8 @@ export function registerConnectButton(): void {
454
458
  fullWidth: flag(this, "full-width"),
455
459
  theme: attributeAs(this, "theme", ["light", "dark", "auto"]),
456
460
  connectTheme: attributeAs(this, "connect-theme", ["light", "dark"]),
461
+ connector: this.getAttribute("connector") ?? undefined,
462
+ returnTo: this.getAttribute("return-to") ?? undefined,
457
463
  label: this.getAttribute("label") ?? undefined,
458
464
  connectedLabel: this.getAttribute("connected-label") ?? undefined,
459
465
  description: this.getAttribute("description") ?? undefined,
package/src/constants.ts CHANGED
@@ -13,6 +13,16 @@ export const LEGACY_MESSAGE_PARAM = "one_connect_message";
13
13
  * chain, so the app's backend forwards nothing. */
14
14
  export const THEME_PARAM = "one_theme";
15
15
 
16
+ /** Fragment key naming the connector One's page opens first. Sign-in
17
+ * and the space step run as always; then that connector's connect
18
+ * screen opens instead of the list, and the list follows with
19
+ * everything the user has connected. */
20
+ export const CONNECTOR_PARAM = "one_connector";
21
+
22
+ /** Query param on the app's own authorize route naming where this one
23
+ * flow returns to. The route reads it; it never reaches One. */
24
+ export const RETURN_TO_PARAM = "one_return_to";
25
+
16
26
  /** One's connector logos, by slug. */
17
27
  export const CONNECTOR_ASSETS_URL = "https://assets.withone.ai/connectors";
18
28
 
package/src/flow.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { THEME_PARAM } from "./constants";
1
+ import { CONNECTOR_PARAM, RETURN_TO_PARAM, THEME_PARAM } from "./constants";
2
2
  import { hasReturnParams, parseReturn, stripReturnParams } from "./return";
3
3
  import type {
4
4
  OneConnectFlow,
@@ -91,11 +91,19 @@ function bindPageshow(): void {
91
91
  });
92
92
  }
93
93
 
94
+ /** The theme and the connector ride the fragment: no server sees it,
95
+ * and it survives every redirect to One's page. The return path is a
96
+ * query param for the app's own route, which reads it and keeps it. */
94
97
  const authorizeUrlFor = (options: OneConnectFlowOptions): string => {
95
- const theme = options.connectTheme;
96
98
  try {
97
99
  const url = new URL(options.authorizeUrl, window.location.origin);
98
- if (theme) url.hash = `${THEME_PARAM}=${theme}`;
100
+ if (options.returnTo) url.searchParams.set(RETURN_TO_PARAM, options.returnTo);
101
+ const fragment = new URLSearchParams();
102
+ if (options.connectTheme) fragment.set(THEME_PARAM, options.connectTheme);
103
+ const connector = options.connector?.trim().toLowerCase();
104
+ if (connector) fragment.set(CONNECTOR_PARAM, connector);
105
+ const hash = fragment.toString();
106
+ if (hash) url.hash = hash;
99
107
  return url.toString();
100
108
  } catch {
101
109
  return options.authorizeUrl;
package/src/next.ts CHANGED
@@ -14,6 +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 { RETURN_TO_PARAM } from "./constants";
17
18
  import type { CompleteAuthorizationResult, OneConnectClient } from "./server";
18
19
 
19
20
  export interface OneConnectRoutesOptions {
@@ -109,7 +110,11 @@ export function createOneConnectRoutes(
109
110
  const user = await requireUser(request);
110
111
  if (user instanceof Response) return user;
111
112
  const loginHint = (await options.loginHintFor?.(request)) ?? undefined;
112
- const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
113
+ // The browser flow names where this one flow returns to; the client
114
+ // keeps only a path on the app, so a crafted link goes nowhere else.
115
+ const returnTo =
116
+ new URL(request.url).searchParams.get(RETURN_TO_PARAM) ?? undefined;
117
+ const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint, returnTo });
113
118
  return redirectWithCookies(redirectUrl, [
114
119
  serializeCookie(cookie.name, cookie.value, cookie.options),
115
120
  ]);