@withone/connect 0.11.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";
@@ -180,10 +181,10 @@ function createOneConnect(config) {
180
181
  /** One refresh in flight per user: two concurrent refreshes with the
181
182
  * same refresh token trip One's reuse detection. */
182
183
  const refreshing = new Map();
183
- const returnUrl = (status, message) => {
184
+ const returnUrl = (status, code) => {
184
185
  const url = new URL(returnTo, config.redirectUri);
185
186
  url.searchParams.set(RETURN_STATUS_PARAM, status);
186
- if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
187
+ if (code) url.searchParams.set(RETURN_ERROR_PARAM, code);
187
188
  return url.toString();
188
189
  };
189
190
  const postToken = async body => {
@@ -260,17 +261,18 @@ function createOneConnect(config) {
260
261
  const oauthError = params.get("error");
261
262
  const cookieName = state ? txCookieName(state) : undefined;
262
263
  const verifier = cookieName ? input.getCookie(cookieName) : undefined;
263
- const fail = (outcome, message) => ({
264
- outcome,
264
+ const fail = (failure, message) => ({
265
+ outcome: failure === "declined" ? "declined" : "failed",
266
+ code: failure,
265
267
  message,
266
- redirectUrl: returnUrl("error", message),
268
+ redirectUrl: returnUrl("error", failure),
267
269
  clearCookieName: cookieName
268
270
  });
269
- 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.");
270
272
  if (oauthError) return fail("failed", `One reported an error: ${oauthError}.`);
271
- // The returned state names its own cookie. No cookie means a forged
272
- // or stale state; the code is never exchanged in that case.
273
- 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.");
274
276
  try {
275
277
  const tokens = toTokens(await exchange(new URLSearchParams({
276
278
  grant_type: "authorization_code",
@@ -101,10 +101,15 @@ export interface CompleteAuthorizationInput {
101
101
  /** Reads a cookie by name from the incoming request. */
102
102
  getCookie: (name: string) => string | undefined;
103
103
  }
104
+ import type { ConnectFailureCode } from "../types";
105
+ export type { ConnectFailureCode };
104
106
  export type AuthorizationOutcome = "connected" | "declined" | "failed";
105
107
  export interface CompleteAuthorizationResult {
106
108
  outcome: AuthorizationOutcome;
107
- /** 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. */
108
113
  message?: string;
109
114
  /** Send the browser here with a 302; it carries `?one_connect=…`. */
110
115
  redirectUrl: string;
@@ -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;
package/dist/vue.d.ts CHANGED
@@ -1,35 +1,52 @@
1
1
  import type { PropType } from "vue";
2
- import { type ConnectButtonPlatformInput, type ConnectButtonVariant, type OneConnectTheme } from "@withone/connect";
2
+ import { type ConnectButtonPlatformInput, type ConnectButtonSize, type ConnectButtonTheme, type ConnectButtonVariant, type ConnectFailureCode, type OneConnectTheme } from "@withone/connect";
3
3
  export declare const ConnectButton: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
4
4
  authorizeUrl: {
5
5
  type: StringConstructor;
6
6
  required: true;
7
7
  };
8
- appTheme: {
9
- type: PropType<OneConnectTheme>;
8
+ platforms: {
9
+ type: PropType<ConnectButtonPlatformInput[]>;
10
10
  default: undefined;
11
11
  };
12
- label: {
13
- type: StringConstructor;
12
+ connected: {
13
+ type: BooleanConstructor;
14
+ default: undefined;
15
+ };
16
+ disabled: {
17
+ type: BooleanConstructor;
14
18
  default: undefined;
15
19
  };
16
20
  variant: {
17
21
  type: PropType<ConnectButtonVariant>;
18
22
  default: undefined;
19
23
  };
24
+ size: {
25
+ type: PropType<ConnectButtonSize>;
26
+ default: undefined;
27
+ };
28
+ fullWidth: {
29
+ type: BooleanConstructor;
30
+ default: undefined;
31
+ };
20
32
  theme: {
33
+ type: PropType<ConnectButtonTheme>;
34
+ default: undefined;
35
+ };
36
+ connectTheme: {
21
37
  type: PropType<OneConnectTheme>;
22
38
  default: undefined;
23
39
  };
24
- platforms: {
25
- type: PropType<ConnectButtonPlatformInput[]>;
40
+ /** @deprecated use connectTheme */
41
+ appTheme: {
42
+ type: PropType<OneConnectTheme>;
26
43
  default: undefined;
27
44
  };
28
- description: {
45
+ accentColor: {
29
46
  type: StringConstructor;
30
47
  default: undefined;
31
48
  };
32
- accentColor: {
49
+ label: {
33
50
  type: StringConstructor;
34
51
  default: undefined;
35
52
  };
@@ -37,41 +54,63 @@ export declare const ConnectButton: import("vue").DefineComponent<import("vue").
37
54
  type: StringConstructor;
38
55
  default: undefined;
39
56
  };
57
+ description: {
58
+ type: StringConstructor;
59
+ default: undefined;
60
+ };
40
61
  }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
41
62
  [key: string]: any;
42
63
  }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
43
64
  success: () => true;
44
- error: (message: string) => boolean;
65
+ error: (message: string, code: ConnectFailureCode) => boolean;
66
+ cancel: () => true;
45
67
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
46
68
  authorizeUrl: {
47
69
  type: StringConstructor;
48
70
  required: true;
49
71
  };
50
- appTheme: {
51
- type: PropType<OneConnectTheme>;
72
+ platforms: {
73
+ type: PropType<ConnectButtonPlatformInput[]>;
52
74
  default: undefined;
53
75
  };
54
- label: {
55
- type: StringConstructor;
76
+ connected: {
77
+ type: BooleanConstructor;
78
+ default: undefined;
79
+ };
80
+ disabled: {
81
+ type: BooleanConstructor;
56
82
  default: undefined;
57
83
  };
58
84
  variant: {
59
85
  type: PropType<ConnectButtonVariant>;
60
86
  default: undefined;
61
87
  };
88
+ size: {
89
+ type: PropType<ConnectButtonSize>;
90
+ default: undefined;
91
+ };
92
+ fullWidth: {
93
+ type: BooleanConstructor;
94
+ default: undefined;
95
+ };
62
96
  theme: {
97
+ type: PropType<ConnectButtonTheme>;
98
+ default: undefined;
99
+ };
100
+ connectTheme: {
63
101
  type: PropType<OneConnectTheme>;
64
102
  default: undefined;
65
103
  };
66
- platforms: {
67
- type: PropType<ConnectButtonPlatformInput[]>;
104
+ /** @deprecated use connectTheme */
105
+ appTheme: {
106
+ type: PropType<OneConnectTheme>;
68
107
  default: undefined;
69
108
  };
70
- description: {
109
+ accentColor: {
71
110
  type: StringConstructor;
72
111
  default: undefined;
73
112
  };
74
- accentColor: {
113
+ label: {
75
114
  type: StringConstructor;
76
115
  default: undefined;
77
116
  };
@@ -79,16 +118,26 @@ export declare const ConnectButton: import("vue").DefineComponent<import("vue").
79
118
  type: StringConstructor;
80
119
  default: undefined;
81
120
  };
121
+ description: {
122
+ type: StringConstructor;
123
+ default: undefined;
124
+ };
82
125
  }>> & Readonly<{
83
126
  onSuccess?: (() => any) | undefined;
84
- onError?: ((message: string) => any) | undefined;
127
+ onError?: ((message: string, code: ConnectFailureCode) => any) | undefined;
128
+ onCancel?: (() => any) | undefined;
85
129
  }>, {
130
+ connected: boolean;
86
131
  label: string;
87
- variant: ConnectButtonVariant;
88
- theme: OneConnectTheme;
132
+ disabled: boolean;
133
+ connectTheme: OneConnectTheme;
134
+ appTheme: OneConnectTheme;
89
135
  platforms: ConnectButtonPlatformInput[];
136
+ variant: ConnectButtonVariant;
137
+ size: ConnectButtonSize;
138
+ theme: ConnectButtonTheme;
90
139
  description: string;
91
- appTheme: OneConnectTheme;
140
+ fullWidth: boolean;
92
141
  accentColor: string;
93
142
  connectedLabel: string;
94
143
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
package/dist/vue.esm.js CHANGED
@@ -1 +1 @@
1
- import{defineComponent as e,ref as t,onMounted as r,watch as o,onBeforeUnmount as n,h as i}from"vue";import{propsIdentity as l,mountConnectButton as u,optionsFromProps as d}from"@withone/connect";const a=e({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(e,{emit:a}){const p=t(null);let s=null;const c=()=>{var t;null===(t=s)||void 0===t||t.destroy(),s=null,p.value&&(s=u(p.value,d({...e},{onSuccess:()=>a("success"),onError:e=>a("error",e)})))};return r(c),o(()=>l({...e}),c),n(()=>{var e;null===(e=s)||void 0===e||e.destroy(),s=null}),()=>i("div",{ref:p})}});export{a 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{mountConnectButton as l}from"@withone/connect";const d={type:Boolean,default:void 0},a=e({name:"OneConnectButton",props:{authorizeUrl:{type:String,required:!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},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(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}),()=>i("div",{ref:a})}});export{a as ConnectButton};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/connect",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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",
@@ -27,8 +27,11 @@
27
27
  "@tsconfig/recommended": "^1.0.3",
28
28
  "@types/node": "^22.15.0",
29
29
  "@types/react": "^19.2.18",
30
+ "@types/react-dom": "^19.3.0",
31
+ "happy-dom": "^20.14.5",
30
32
  "prettier": "^3.2.5",
31
33
  "react": "^19.2.8",
34
+ "react-dom": "^19.3.0",
32
35
  "rollup": "^4.12.0",
33
36
  "rollup-plugin-typescript2": "^0.36.0",
34
37
  "tslib": "^2.6.2",
@@ -126,32 +126,50 @@ The routes mint `state` and PKCE, keep them in a per-flow httpOnly cookie
126
126
  (`one_tx_<state>`, SameSite=Lax, 30 minutes), verify the state on return,
127
127
  exchange the code with the secret over HTTP Basic, store both tokens, and
128
128
  redirect to `/` with `?one_connect=success` or
129
- `?one_connect=error&one_connect_message=...`. Pass `returnTo` to
129
+ `?one_connect=error&one_connect_error=declined|expired|failed` (a code, never
130
+ free text: the SDK shows fixed text for it). Pass `returnTo` to
130
131
  `createOneConnect` to land somewhere else.
131
132
 
132
133
  ## 5 - The button
133
134
 
134
135
  ```tsx
135
- import { ConnectButton } from "@withone/connect/react";
136
+ import { ConnectButton } from "@withone/connect/react"; // "use client" bundle: fine in a Server Component
136
137
 
137
138
  <ConnectButton
138
139
  authorizeUrl="/api/one/authorize"
139
- platforms={["stripe", "google-calendar"]} // connector slugs; logos and names come from One
140
- onSuccess={() => { /* refresh app state; the tokens are already stored */ }}
141
- onError={(message) => { /* show it; the user may simply have declined */ }}
140
+ platforms={["stripe", "google-calendar"]} // connector slugs; logos from One's CDN, names from the slug
141
+ connected={hasGrant} // from the server (e.g. await oneConnect.isConnected(userId))
142
+ onSuccess={() => { /* refetch app state; the tokens are already stored */ }}
143
+ onError={(message, code) => { /* show message; code: declined | expired | failed */ }}
142
144
  />
143
145
  ```
144
146
 
145
- Vue: `import { ConnectButton } from "@withone/connect/vue"` with `authorize-url`,
146
- `:platforms`, `@success`, `@error`. Svelte: `import { connectButton } from
147
- "@withone/connect/svelte"` as `use:connectButton={{ authorizeUrl, platforms,
148
- onSuccess }}`. Anything else: `import "@withone/connect"` registers
149
- `<one-connect-button authorize-url="/api/one/authorize" platforms="stripe, notion">`,
150
- which dispatches `success` and `error` events. A custom element:
151
- `useOneConnect({ authorizeUrl, onSuccess, onError }).open` on a click.
152
-
153
- The flow is a same-tab redirect. `onSuccess` and `onError` fire once when the
154
- tab comes back, and the SDK removes the `one_connect` params from the URL.
147
+ Other props: `disabled`, `variant` ("default" | "accent" | "block"), `size`
148
+ ("sm" | "md" | "lg"), `fullWidth`, `theme` ("light" | "dark" | "auto"),
149
+ `connectTheme` (One's page), `accentColor`, `label`, `connectedLabel`,
150
+ `description` (block), `onCancel` (user pressed Back on One's page).
151
+
152
+ Always pass `connected` from the server. Without it the button forgets after
153
+ a reload and asks the user to connect again.
154
+
155
+ Vue: `import { ConnectButton } from "@withone/connect/vue"` with the same props
156
+ in kebab case (`authorize-url`, `:connected`), and `@success`, `@error`,
157
+ `@cancel`. Svelte: `import { connectButton } from "@withone/connect/svelte"`
158
+ as `use:connectButton={{ authorizeUrl, platforms, connected, onSuccess }}`.
159
+ Anything else: `import "@withone/connect"` registers
160
+ `<one-connect-button authorize-url="/api/one/authorize" platforms="stripe, notion" connected>`,
161
+ which dispatches `success`, `error` (detail `{ message, code }`) and `cancel`.
162
+ A custom button in React: `const { open, status, error } = useOneConnect({ authorizeUrl })`
163
+ from `@withone/connect/react`. Elsewhere: `createConnectFlow({ authorizeUrl, onSuccess, onError }).open`.
164
+
165
+ The button renders in a shadow root with a constructed stylesheet, so it
166
+ works under a strict CSP. Allow `https://assets.withone.ai` in `img-src` for
167
+ the logos. Style it with `--one-connect-font`, `--one-connect-radius` and
168
+ `::part(button)`; do not wrap it in extra styling divs.
169
+
170
+ The flow is a same-tab redirect. The outcome is read once per page load:
171
+ every button shows it, and the callbacks fire once, on the first button
172
+ still mounted.
155
173
 
156
174
  ## 6 - Calling One with the grant
157
175