@cosmoops/connect-sdk 0.20.0 → 0.23.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/README.md +2 -0
  3. package/dist/cjs/client/components/identity.js +24 -0
  4. package/dist/cjs/client/components/one-tap-state.js +96 -0
  5. package/dist/cjs/client/components/one-tap.js +106 -0
  6. package/dist/cjs/client/components/plan-pricing.js +28 -0
  7. package/dist/cjs/client/components/plans.js +8 -3
  8. package/dist/cjs/client/components/user-menu.js +2 -6
  9. package/dist/cjs/client/index.js +11 -3
  10. package/dist/cjs/client/provider.js +102 -7
  11. package/dist/cjs/server/client.js +12 -1
  12. package/dist/cjs/server/handler.js +126 -2
  13. package/dist/cjs/server/resources/calls.js +57 -0
  14. package/dist/cjs/server/resources/channels.js +115 -0
  15. package/dist/cjs/server/resources/enrichment.js +56 -0
  16. package/dist/cjs/server/resources/identity.js +40 -0
  17. package/dist/cjs/server/resources/workspaces.js +73 -2
  18. package/dist/esm/client/components/identity.js +21 -0
  19. package/dist/esm/client/components/one-tap-state.js +90 -0
  20. package/dist/esm/client/components/one-tap.js +103 -0
  21. package/dist/esm/client/components/plan-pricing.js +27 -0
  22. package/dist/esm/client/components/plans.js +9 -4
  23. package/dist/esm/client/components/user-menu.js +1 -5
  24. package/dist/esm/client/index.js +8 -1
  25. package/dist/esm/client/provider.js +102 -7
  26. package/dist/esm/server/client.js +12 -1
  27. package/dist/esm/server/handler.js +127 -3
  28. package/dist/esm/server/resources/calls.js +53 -0
  29. package/dist/esm/server/resources/channels.js +111 -0
  30. package/dist/esm/server/resources/enrichment.js +52 -0
  31. package/dist/esm/server/resources/identity.js +39 -0
  32. package/dist/esm/server/resources/workspaces.js +73 -2
  33. package/dist/types/client/components/identity.d.ts +17 -0
  34. package/dist/types/client/components/one-tap-state.d.ts +39 -0
  35. package/dist/types/client/components/one-tap.d.ts +50 -0
  36. package/dist/types/client/components/plan-pricing.d.ts +23 -0
  37. package/dist/types/client/index.d.ts +4 -2
  38. package/dist/types/client/provider.d.ts +101 -3
  39. package/dist/types/client/use-checkout.d.ts +9 -0
  40. package/dist/types/server/client.d.ts +9 -1
  41. package/dist/types/server/index.d.ts +3 -0
  42. package/dist/types/server/resources/calls.d.ts +45 -0
  43. package/dist/types/server/resources/channels.d.ts +91 -0
  44. package/dist/types/server/resources/enrichment.d.ts +44 -0
  45. package/dist/types/server/resources/identity.d.ts +35 -0
  46. package/dist/types/server/resources/workspaces.d.ts +63 -3
  47. package/dist/types/types.d.ts +344 -1
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,8 +3,289 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.23.0
7
+
8
+ ### Added
9
+
10
+ - **`CompanyProfile.legalJurisdiction`** - the seat a governing-law clause
11
+ names, e.g. `"Chennai, Tamil Nadu"`.
12
+
13
+ **This is not the registered address**, and that is why it is its own field
14
+ rather than something to derive. An entity registered in one state routinely
15
+ names another as the venue for disputes, and the applications that kept
16
+ their own copies of this had reached for the registered address instead -
17
+ publishing two different venues on two terms pages operated by the same
18
+ company. `null` when the operator has not published one, in which case a
19
+ terms page should omit the venue rather than substitute anything.
20
+
21
+ - **`CompanyProfile.brandName` is now editable by the platform operator.** The
22
+ field is unchanged on the wire; what changed is that Connect served it from
23
+ a hardcoded constant until now, which made it the one company fact nobody
24
+ could correct without a deploy.
25
+
26
+ ### Changed
27
+
28
+ - **`GET /company-profile` can now answer `503 unavailable`.** Connect used to
29
+ resolve every field against hardcoded defaults, so this endpoint always
30
+ answered - including with a legal name and grievance officer that nobody had
31
+ checked against the operator's own record. Those defaults are gone, and an
32
+ unconfigured platform refuses the read instead.
33
+
34
+ Nothing you wrote needs changing: `<ConnectGrievanceNotice />` already
35
+ renders `null` on failure, and `useConnectCompanyProfile()` surfaces it as
36
+ an error like any other. **Do not add a fallback of your own** - a copy kept
37
+ for outages is still a copy, and going stale is the failure this endpoint
38
+ exists to prevent.
39
+
40
+ - **`connect.enrichment.company()`** - fill an account in from a work email
41
+ address, instead of asking somebody to type it.
42
+
43
+ ```ts
44
+ const result = await connect.enrichment.company({ email: "priya@acme.test" });
45
+ if (result.status === "enriched") {
46
+ form.setValues(result.profile); // then let the person confirm it
47
+ }
48
+ ```
49
+
50
+ Connect reads the company's **own website** - the domain your input yields,
51
+ plus About/Contact pages linked from its homepage - and returns what it found
52
+ with `citations` naming the URL each claim came from. It does not search the
53
+ web, and it never answers from a model's recall: a field the pages did not
54
+ state comes back `null`. An invented industry is indistinguishable from a
55
+ real one, and an invented address reaches an invoice.
56
+
57
+ `status: "skipped"` is a normal 200, not an error - most records legitimately
58
+ have nothing to enrich. `consumer_domain` (a `gmail.com` address names a
59
+ person, not an employer) and `blocked_domain` will never succeed on a retry;
60
+ `unreachable` and `no_signal` may. `confidence` is derived from how many
61
+ fields the pages actually supported, not from the model's opinion of itself,
62
+ so `low` means "offer this" and `high` means "safe to pre-fill".
63
+
64
+ Needs an API key scoped to the new **`enrichment:read`**. That is separate
65
+ from `ai:extract` because the two differ in what they touch rather than only
66
+ in what they cost: extraction reads a file you already hold, this makes
67
+ Connect fetch a third-party website on a string that reached you from a
68
+ stranger.
69
+
70
+ - **`connect.workspaces.access()`, `.invite()`, `.setAccess()` and
71
+ `.revokeInvitation()`** - manage the customer's roster from inside your own
72
+ application, instead of sending them to Connect's team page.
73
+
74
+ `members()` has always been a read, so a partner's own team screen could
75
+ show who had access and then had to hand off to Connect for every change.
76
+ These four close that loop, and all of them take `asUser`: the workspace and
77
+ the actor come from the caller's session token, never from a parameter.
78
+
79
+ ```ts
80
+ const { capacity, viewer } = await connect.workspaces.access({ asUser: token });
81
+ if (viewer.canInvite && capacity.canAdd) {
82
+ await connect.workspaces.invite({ inviteeEmail: "priya@acme.test", roleKey: "rep" }, { asUser: token });
83
+ }
84
+ await connect.workspaces.setAccess({ userId, roleKey: null }, { asUser: token }); // revoke
85
+ ```
86
+
87
+ **You can only ever move your own application's grant.** The wire carries a
88
+ `roleKey` from your catalog rather than a roles array, and Connect computes
89
+ the member's next roles as *what they hold now, minus any grant naming your
90
+ application, plus yours* - so nothing these methods can express reaches a
91
+ workspace-wide Connect role, another application's access, or removes
92
+ someone from the workspace.
93
+
94
+ Two gates apply on top of the API key's new `members:manage` scope, and both
95
+ are about the person rather than your application: the signed-in caller must
96
+ hold Connect's own `member:invite` / `member:update` on the workspace, and
97
+ they cannot grant access they do not hold themselves. `viewer` on the
98
+ `access()` response is the first of those, resolved, so you can render your
99
+ controls from it instead of letting a button 403.
100
+
101
+ **Seats are the workspace's, not your plan's.** `capacity.included` is a
102
+ baseline plus every active subscription's member allowance folded across
103
+ applications, and `used` counts pending invitations as well as members,
104
+ because an invitation is a seat already spoken for. `reason` separates "no
105
+ plan grants member seats" (`no_allowance` - sell them one) from "every seat
106
+ is taken" (`exhausted` - an upgrade, or a removal); those read alike and
107
+ need different words on screen. The read is advisory - the authoritative
108
+ check runs inside `invite()`, which is where two people claiming the last
109
+ seat at once is settled.
110
+
111
+ `revokeInvitation()` is not optional decoration: an invitation sent to a
112
+ mistyped address holds a seat for its full seven days, and an application
113
+ that can spend one but not release it leaves a customer paying for a seat
114
+ nobody can use.
115
+
116
+ **Server-side only, deliberately.** None of the four is exposed through
117
+ `createConnectHandler`, which stays four fixed reads plus sign-out and the
118
+ workspace switch. A bridge route is a capability handed to any script
119
+ running in your page, and "grant this account admin" is the highest-value
120
+ one there is; call these from your own server, where the API key already
121
+ lives.
122
+
123
+ ## 0.22.0
124
+
125
+ ### Fixed
126
+
127
+ - **`<ConnectGrievanceNotice />` rendered nothing for a signed-out visitor -
128
+ which is most of the people who read a privacy page.**
129
+
130
+ The bridge route `GET /company-profile` is deliberately unauthenticated: the
131
+ answer is a fact about the platform operator, identical for every caller and
132
+ already on the public web, and requiring a session would hide the redressal
133
+ channel at the one moment it most needs to be legible. The browser half
134
+ never got that exemption. `useConnectCompanyProfile` goes through
135
+ `useConnectResource`, which waits for `session.user` before it reads - right
136
+ for every other route, and the reason this component was blank on exactly
137
+ the public page the docs recommend putting it on.
138
+
139
+ `useConnectResource` now takes `{ requiresSession: false }`, and the company
140
+ profile is its only caller. It widens no access - the bridge decides that,
141
+ not the hook.
142
+
143
+ - **`<ConnectPlans />`: a card reading "Subscribe" opened Connect's *change
144
+ plan* screen, which answered a 404.**
145
+
146
+ The label and the URL were two separate decisions. `resolveAction` needs the
147
+ **current plan** to classify a change and falls back to `"subscribe"`
148
+ without one; the checkout URL was built from the mere presence of a
149
+ subscription *row*. Those disagree in a state Connect reports routinely:
150
+ `SubscriptionResponse` carries `plan: null` beside a live `subscription`
151
+ whenever that plan has been deleted from the application's catalogue - which
152
+ re-seeding an application does. The button then said "Subscribe" and opened
153
+ `/billing/subscriptions/{id}/change-plan/{planId}`, where Connect could not
154
+ read the current price either and rendered its not-found page.
155
+
156
+ Both now come from the same `PlanAction`. When the grid cannot classify a
157
+ change it hands off to the subscribe route, which re-reads the workspace's
158
+ live subscriptions server-side and redirects to the change-plan screen
159
+ itself when there is genuinely one to change - so Connect decides, and the
160
+ grid does not have to be sure.
161
+
162
+ ### Added
163
+
164
+ - **`checkoutSubscriptionId(action, current)`** - the other half of
165
+ `resolveAction`, exported alongside it. A partner writing its own pricing
166
+ grid has to turn that action into a checkout URL, and reading the
167
+ subscription id straight off the entitlements response is exactly the bug
168
+ above. Pass its result as `buildCheckoutUrl`'s `subscriptionId`.
169
+
170
+ ## 0.21.0
171
+
172
+ ### Added
173
+
174
+ - **`<ConnectOneTap />`** - a signed-out visitor who is already signed into
175
+ Connect is offered their account, and one tap signs them in **without leaving
176
+ the page**.
177
+
178
+ ```tsx
179
+ <ConnectProvider>
180
+ <App />
181
+ <ConnectOneTap />
182
+ </ConnectProvider>
183
+ ```
184
+
185
+ That is the whole integration. It needs no `clientId` in the browser and no
186
+ configuration: the bridge already knows the client, and now tells the page
187
+ where to ask on the `/session` read it was making anyway, so discovering One
188
+ Tap costs no extra request.
189
+
190
+ **What it replaces.** `silentSignIn` answers the same question - *is this
191
+ visitor already signed into Connect?* - by bouncing the browser to Connect
192
+ and back with `prompt=none`. That costs a visible full-page navigation on the
193
+ first load of **every** signed-out visit, shows the visitor nothing while it
194
+ happens, and gives them no way to decline. One Tap asks with a single
195
+ `fetch`, names the account before anything is signed, and completes with no
196
+ navigation at all. `silentSignIn` still works and is unchanged; prefer this.
197
+
198
+ **It only appears when it can help.** A visitor Connect does not recognise
199
+ sees nothing, ever - no prompt, no flicker, no redirect - so this is safe to
200
+ mount unconditionally, which `silentSignIn` never was.
201
+
202
+ **Requires your application to be same-site with Connect** - a
203
+ `cosmoops.com` subdomain. `SameSite` is judged on the registrable domain, so
204
+ the visitor's Connect cookie rides along from `crm.cosmoops.com` and from
205
+ nowhere else. Deployed off-site, the prompt simply never appears and
206
+ `ConnectLoginButton` behaves exactly as before. No third-party cookie is
207
+ involved at any point, so this works in every browser rather than only in
208
+ Chromium.
209
+
210
+ **Two prompts, because consent is not assumed.** A visitor who has authorised
211
+ your application before gets their name, email, workspace and photo. One who
212
+ has not gets an impersonal prompt whose tap leads to the real consent
213
+ screen - Connect releases no identifying detail at all until that has
214
+ happened once.
215
+
216
+ **Dismissal is remembered.** Closing the prompt buys 2 hours of quiet, then a
217
+ day, then a week; signing out buys a day. All in your own origin's
218
+ `localStorage`, and none of it leaves the browser.
219
+
220
+ - **`readOneTapHint()` and `oneTapSignIn()` on `useConnect()`** - the two calls
221
+ `<ConnectOneTap />` is built from, for applications that want to draw the
222
+ prompt themselves. Both report expected outcomes as values rather than
223
+ throwing: `consent_required` and `interaction_required` mean "hand off to the
224
+ interactive screen", which is a hand-off, not an error to show anybody.
225
+
226
+ - **`ConnectSession.oneTap`** - where the browser may ask Connect about the
227
+ visitor, or `null` when this deployment cannot run One Tap (no `clientId`, or
228
+ a session store with no `write`). Present on the signed-in and signed-out
229
+ branches alike, so no consumer has to test for key presence.
230
+
231
+ ### Security
232
+
233
+ - The One Tap exchange is **PKCE-bound, and the verifier never reaches the
234
+ page**. Connect hands the authorization code to the browser, so unlike the
235
+ redirect flow the code alone must not be redeemable: `/one-tap/start` mints
236
+ the pair and keeps the verifier in an `HttpOnly`, `SameSite=Lax` cookie on
237
+ your own origin, and `/one-tap/complete` is the only thing that can spend it.
238
+ A code lifted out of the browser, or replayed cross-site, is worthless.
239
+
6
240
  ## 0.20.0
7
241
 
242
+ ### Added
243
+
244
+ - **`connect.channels` and `connect.calls`** - the phone numbers Connect has
245
+ assigned to your application, and the calls that arrived on them. Seven
246
+ endpoints that have been live on the API and reachable only by hand until
247
+ now.
248
+
249
+ ```ts
250
+ const { id } = await connect.channels.claim({ workspaceId });
251
+ await connect.channels.setRouting(id, { agent: "sales", afterHours: "voicemail" });
252
+
253
+ const { items } = await connect.calls.list({ channelId: id });
254
+ const { call, turns } = await connect.calls.get(items[0].id);
255
+ ```
256
+
257
+ **You never pick the number.** Inventory is a regulated asset bought under
258
+ Connect's carrier account, so `claim` takes a workspace and a kind and
259
+ Connect picks from a pool you cannot see - which is also why nothing here
260
+ exposes a carrier, a provider reference or a monthly rate. A workspace holds
261
+ exactly one number across every application it uses, and it is fixed for the
262
+ life of the engagement; only a Connect operator can unassign one. A `409`
263
+ means either "this workspace already has one" (fix the call) or "the pool is
264
+ empty right now" (retry - Connect is notified).
265
+
266
+ `setRouting`'s `destination` is an arbitrary JSON object Connect stores
267
+ verbatim and **never reads into**: which agent answers, business hours,
268
+ overflow and fallback are all your application's model. It replaces rather
269
+ than merges, and `clearRouting` is the only way to say "stop answering" -
270
+ overwriting a destination can only point the number somewhere else. Clearing
271
+ is idempotent and, unlike `setRouting`, is allowed on a suspended channel:
272
+ making a live number stop answering must never be the operation that is
273
+ unavailable.
274
+
275
+ `calls.list` filters on `channelId` and nothing else - each further filter
276
+ costs a Firestore composite index - and pages by cursor: **page until
277
+ `hasMore` is `false` rather than stopping on an empty `items`.**
278
+ `calls.get` adds the transcript, ordered by `sequence`, which is the sort to
279
+ trust since two turns can share a millisecond. `recordingFileId` and
280
+ `transcriptFileId` are Drive file ids readable with a `drive:read` key, and
281
+ are `null` until the carrier posts the audio - branch on `recordingStatus`
282
+ rather than reading `null` as a failure.
283
+
284
+ Scopes: `channels:read`, `channels:claim`, `channels:manage`, `calls:read`.
285
+ A channel or call belonging to another application answers `not_found`, not
286
+ `forbidden` - confirming it exists would make these reads an oracle for
287
+ enumerating another partner's ids.
288
+
8
289
  ### Changed
9
290
 
10
291
  - **The app launcher shows the whole catalogue, in two groups.**
package/README.md CHANGED
@@ -343,6 +343,8 @@ try {
343
343
  | `billing` | `listPlans` · `getAddonUsage` · `consumeAddon` |
344
344
  | `drive` | `upload` · `getFileUrl` · `getPreviewUrl` |
345
345
  | `contacts` | `lookupByPhone` · `get` |
346
+ | `channels` | `list` · `get` · `claim` · `setRouting` · `clearRouting` |
347
+ | `calls` | `list` · `get` |
346
348
  | `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `rescheduleBooking` · `cancelBooking` |
347
349
  | `ai` | `chat` · `extract` |
348
350
  | `otp` | `send` · `verify` |
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ /**
3
+ * How a person is rendered when there is no photo to render.
4
+ *
5
+ * Shared rather than copied because two surfaces showing *different* initials
6
+ * for the same account reads as a bug in the account, not in the component -
7
+ * and the One Tap prompt and the user menu are frequently on screen within
8
+ * seconds of each other.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.initials = initials;
12
+ /**
13
+ * Up to two letters standing for a person.
14
+ *
15
+ * Splits on the punctuation an email address uses as well as whitespace, so
16
+ * `priya.nair@acme.test` yields `PN` rather than `PR` - an account with no
17
+ * display name is exactly the case the fallback exists for, and taking the
18
+ * first two characters of a local part would make it useless there.
19
+ */
20
+ function initials(name, email = null) {
21
+ const source = name?.trim() || email?.trim() || "";
22
+ const parts = source.split(/[\s@._-]+/).filter(Boolean);
23
+ return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
24
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * How long One Tap stays quiet, and why.
4
+ *
5
+ * A prompt that reappears on every page load after being closed is not a
6
+ * convenience, it is an interstitial. Two things silence it, both stored in the
7
+ * application's own `localStorage` - nothing here leaves the browser, and
8
+ * nothing here is a security control:
9
+ *
10
+ * - **Dismissal, escalating.** Closing it once is "not now"; closing it three
11
+ * times is "stop asking". The backoff says that back.
12
+ * - **Signing out.** Someone who has just signed out of this application does
13
+ * not want to be offered the same account back on the next render. That is a
14
+ * different fact from a dismissal, so it does not consume a dismissal, but it
15
+ * buys a day of quiet.
16
+ *
17
+ * Every access is wrapped: `localStorage` throws outright in some embedded and
18
+ * privacy-restricted contexts, and a prompt is never worth breaking a page for.
19
+ * A read that fails reads as "not suppressed", which is the same state a fresh
20
+ * browser is in.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.oneTapSuppressed = oneTapSuppressed;
24
+ exports.dismissOneTap = dismissOneTap;
25
+ exports.suppressOneTapAfterSignOut = suppressOneTapAfterSignOut;
26
+ exports.clearOneTapSuppression = clearOneTapSuppression;
27
+ const STORAGE_KEY = "connect_one_tap";
28
+ /** Escalating quiet after 1, 2, and 3-or-more dismissals. */
29
+ const DISMISS_BACKOFF_MS = [2 * 60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000];
30
+ const SIGN_OUT_QUIET_MS = 24 * 60 * 60 * 1000;
31
+ function read() {
32
+ const empty = { dismissals: 0, until: 0 };
33
+ if (typeof window === "undefined")
34
+ return empty;
35
+ try {
36
+ const raw = window.localStorage.getItem(STORAGE_KEY);
37
+ if (!raw)
38
+ return empty;
39
+ const parsed = JSON.parse(raw);
40
+ return {
41
+ dismissals: typeof parsed.dismissals === "number" ? parsed.dismissals : 0,
42
+ until: typeof parsed.until === "number" ? parsed.until : 0,
43
+ };
44
+ }
45
+ catch {
46
+ return empty;
47
+ }
48
+ }
49
+ function write(state) {
50
+ if (typeof window === "undefined")
51
+ return;
52
+ try {
53
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
54
+ }
55
+ catch {
56
+ // Storage is full, blocked, or unavailable. The prompt simply keeps its
57
+ // default behaviour rather than the page failing.
58
+ }
59
+ }
60
+ /**
61
+ * Whether the prompt may be shown at all.
62
+ *
63
+ * Read once when the component mounts, never during render: `localStorage` is
64
+ * external mutable state, and reading it in a render body is impure - with the
65
+ * React Compiler on, exactly the kind of thing that can be hoisted into the
66
+ * wrong answer. Same reasoning as `ConnectProvider`'s `silentAttemptSpent`.
67
+ */
68
+ function oneTapSuppressed() {
69
+ return Date.now() < read().until;
70
+ }
71
+ /** Records a close, and escalates how long the next quiet period lasts. */
72
+ function dismissOneTap() {
73
+ const { dismissals } = read();
74
+ const next = dismissals + 1;
75
+ const backoff = DISMISS_BACKOFF_MS[Math.min(next, DISMISS_BACKOFF_MS.length) - 1];
76
+ write({ dismissals: next, until: Date.now() + backoff });
77
+ }
78
+ /**
79
+ * Silences the prompt after an explicit sign-out, without spending a dismissal.
80
+ * Signing out is not a complaint about the prompt, so it must not push someone
81
+ * closer to the seven-day backoff.
82
+ */
83
+ function suppressOneTapAfterSignOut() {
84
+ write({ dismissals: read().dismissals, until: Date.now() + SIGN_OUT_QUIET_MS });
85
+ }
86
+ /** Clears the record once a sign-in actually happens - the prompt did its job. */
87
+ function clearOneTapSuppression() {
88
+ if (typeof window === "undefined")
89
+ return;
90
+ try {
91
+ window.localStorage.removeItem(STORAGE_KEY);
92
+ }
93
+ catch {
94
+ // See `write`.
95
+ }
96
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ConnectOneTap = ConnectOneTap;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const lucide_react_1 = require("lucide-react");
8
+ const react_2 = require("@heroui/react");
9
+ const provider_js_1 = require("../provider.js");
10
+ const mark_js_1 = require("../mark.js");
11
+ const identity_js_1 = require("./identity.js");
12
+ const motion_js_1 = require("./motion.js");
13
+ const one_tap_state_js_1 = require("./one-tap-state.js");
14
+ function ConnectOneTap({ onInteractiveSignIn, className = "" }) {
15
+ const { session, isLoading, signIn, readOneTapHint, oneTapSignIn } = (0, provider_js_1.useConnect)();
16
+ const [hint, setHint] = (0, react_1.useState)(null);
17
+ const [phase, setPhase] = (0, react_1.useState)("hidden");
18
+ /**
19
+ * Sampled once at mount rather than read during render, for the same reason
20
+ * `ConnectProvider` samples its silent-attempt marker that way: `localStorage`
21
+ * is external mutable state and reading it in a render body is impure.
22
+ * `false` on the server, which is correct - nothing renders until the hint
23
+ * has resolved on the client anyway.
24
+ */
25
+ const [suppressed] = (0, react_1.useState)(() => typeof window !== "undefined" && (0, one_tap_state_js_1.oneTapSuppressed)());
26
+ const signedOut = !isLoading && !session?.user;
27
+ const askable = signedOut && !suppressed && Boolean(session?.oneTap);
28
+ (0, react_1.useEffect)(() => {
29
+ if (!askable)
30
+ return;
31
+ // Nothing here is a synchronous `setState` in an effect: every write
32
+ // happens after awaiting the network. The flag is what stops a late answer
33
+ // landing on a component that has already unmounted.
34
+ let cancelled = false;
35
+ void readOneTapHint().then((answer) => {
36
+ if (cancelled)
37
+ return;
38
+ // A visitor Connect does not recognise is the common case and gets
39
+ // nothing - no prompt, no flicker, no trace that a question was asked.
40
+ if (!answer || answer.status !== "signed_in")
41
+ return;
42
+ setHint(answer);
43
+ setPhase("offered");
44
+ });
45
+ return () => {
46
+ cancelled = true;
47
+ };
48
+ }, [askable, readOneTapHint]);
49
+ const handoff = (0, react_1.useCallback)(() => {
50
+ setPhase("hidden");
51
+ if (onInteractiveSignIn)
52
+ onInteractiveSignIn();
53
+ else
54
+ signIn();
55
+ }, [onInteractiveSignIn, signIn]);
56
+ const accept = (0, react_1.useCallback)(async () => {
57
+ // A visitor who has never authorised this application cannot be signed in
58
+ // silently and Connect would refuse anyway, so the tap goes straight to the
59
+ // screen that can ask them properly.
60
+ if (!hint?.consented) {
61
+ handoff();
62
+ return;
63
+ }
64
+ setPhase("working");
65
+ const result = await oneTapSignIn();
66
+ if (result.ok) {
67
+ // The provider has already refreshed the session; this component
68
+ // disappears because `signedOut` is now false.
69
+ setPhase("hidden");
70
+ return;
71
+ }
72
+ // `consent_required` and `interaction_required` both mean a screen is
73
+ // genuinely needed. `login_required` means the Connect session went away
74
+ // between the hint and the tap. All three are handed to the interactive
75
+ // flow, which can explain itself; a rate limit or a transport failure just
76
+ // closes the prompt rather than blaming the visitor for tapping.
77
+ if (result.reason === "consent_required" ||
78
+ result.reason === "interaction_required" ||
79
+ result.reason === "login_required") {
80
+ handoff();
81
+ return;
82
+ }
83
+ setPhase("hidden");
84
+ }, [handoff, hint, oneTapSignIn]);
85
+ const dismiss = (0, react_1.useCallback)(() => {
86
+ (0, one_tap_state_js_1.dismissOneTap)();
87
+ setPhase("hidden");
88
+ }, []);
89
+ if (phase === "hidden" || !hint)
90
+ return null;
91
+ const displayName = hint.user?.displayName ?? hint.user?.email ?? null;
92
+ const applicationName = hint.application?.name;
93
+ const busy = phase === "working";
94
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: 12 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, role: "dialog", "aria-label": applicationName ? `Continue to ${applicationName} with CosmoOps` : "Continue with CosmoOps",
95
+ // Fixed, above ordinary content but below a modal: this is an offer,
96
+ // and it must never sit on top of something the visitor opened
97
+ // themselves.
98
+ className: `fixed inset-x-3 bottom-3 z-40 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:w-80 ${className}`, onKeyDown: (event) => {
99
+ if (event.key === "Escape")
100
+ dismiss();
101
+ }, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: "p-4 shadow-lg", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start gap-3", children: [hint.consented ? ((0, jsx_runtime_1.jsxs)(react_2.Avatar, { size: "md", color: "accent", variant: "soft", className: "shrink-0 rounded-full", children: [hint.user?.photoUrl && ((0, jsx_runtime_1.jsx)(react_2.Avatar.Image, { alt: displayName ?? "Your account", src: hint.user.photoUrl })), (0, jsx_runtime_1.jsx)(react_2.Avatar.Fallback, { children: (0, identity_js_1.initials)(hint.user?.displayName ?? null, hint.user?.email ?? null) })] })) : ((0, jsx_runtime_1.jsx)(mark_js_1.ConnectMark, { className: "mt-0.5 size-8 shrink-0" })), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm font-medium text-foreground", children: hint.consented ? `Continue as ${displayName ?? "your account"}` : "You're signed in to CosmoOps" }), (0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: hint.consented
102
+ ? [hint.user?.email, hint.workspace?.name].filter(Boolean).join(" · ")
103
+ : applicationName
104
+ ? `Continue to ${applicationName}`
105
+ : "Continue with your account" })] }), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: "tertiary", size: "sm", isIconOnly: true, "aria-label": "Not now", onPress: dismiss, isDisabled: busy, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", className: "mt-3 w-full", onPress: () => void accept(), isPending: busy, children: [(0, jsx_runtime_1.jsx)(mark_js_1.ConnectMark, { className: "size-4" }), busy ? "Signing you in…" : "Continue"] })] }) }) }));
106
+ }
@@ -28,6 +28,7 @@ exports.resolvePlanGroup = resolvePlanGroup;
28
28
  exports.groupPlansByTier = groupPlansByTier;
29
29
  exports.annualSaving = annualSaving;
30
30
  exports.resolveAction = resolveAction;
31
+ exports.checkoutSubscriptionId = checkoutSubscriptionId;
31
32
  /**
32
33
  * Whether a plan costs nothing - the SDK's copy of Connect's `isFreeSelection`
33
34
  * (`packages/data/src/payments/free-plan.ts`), which is what the server
@@ -207,3 +208,30 @@ exports.ACTION_LABEL = {
207
208
  "switch-to-monthly": "Switch to monthly",
208
209
  subscribe: "Subscribe",
209
210
  };
211
+ /**
212
+ * The subscription id a checkout hand-off should carry for `action` - or
213
+ * `null`, meaning "send them to Connect's *subscribe* screen".
214
+ *
215
+ * Paired with `resolveAction` deliberately: the button's label and the URL it
216
+ * opens are one decision, and deriving the URL from the mere *presence* of a
217
+ * subscription row instead is what sent a card reading "Subscribe" to
218
+ * Connect's change-plan screen, which answered `notFound()`.
219
+ *
220
+ * The gap is real rather than hypothetical. `resolveAction` needs the
221
+ * **current plan** to classify a change and answers `"subscribe"` without
222
+ * one - and `SubscriptionResponse` carries `plan: null` beside a perfectly
223
+ * live `subscription` whenever that plan has been deleted from the catalogue
224
+ * out from under it, which a re-seeded application does. Connect's change-plan
225
+ * route is stuck in the same place for the same reason: it cannot prorate
226
+ * against a price it cannot read.
227
+ *
228
+ * Sending those to `/apps/.../subscribe/...` is a real hand-off, not a guess:
229
+ * that route re-reads the workspace's live subscriptions server-side and
230
+ * redirects to the change-plan screen itself when there genuinely is one to
231
+ * change. Connect decides, so the grid does not have to be sure.
232
+ */
233
+ function checkoutSubscriptionId(action, current) {
234
+ if (action === "subscribe")
235
+ return null;
236
+ return current?.subscription?.id ?? null;
237
+ }
@@ -83,8 +83,13 @@ function ConnectPlans({ planIds, hideCoupon = false, className }) {
83
83
  * created in `"created"` and only flips to active when the payment webhook
84
84
  * lands, which can be minutes later. Holding a customer on a spinner until
85
85
  * then would misreport a completed checkout as an abandoned one.
86
+ *
87
+ * `action` is threaded through rather than re-derived: it is the same
88
+ * decision the card's label came from, and `checkoutSubscriptionId` is what
89
+ * keeps the two from disagreeing - see its doc comment for the 404 that
90
+ * disagreement produced.
86
91
  */
87
- function startCheckout(plan) {
92
+ function startCheckout(plan, action) {
88
93
  if (!applicationId)
89
94
  return;
90
95
  checkout.start({
@@ -92,7 +97,7 @@ function ConnectPlans({ planIds, hideCoupon = false, className }) {
92
97
  appUrl,
93
98
  applicationId,
94
99
  planId: plan.id,
95
- subscriptionId: subscription?.subscription?.id ?? null,
100
+ subscriptionId: (0, plan_pricing_js_1.checkoutSubscriptionId)(action, subscription),
96
101
  coupon,
97
102
  }),
98
103
  isSettled: (next) => next.subscription.plan?.id === plan.id,
@@ -142,7 +147,7 @@ function ConnectPlans({ planIds, hideCoupon = false, className }) {
142
147
  // plan would race the first and one of them would be
143
148
  // rejected on arrival.
144
149
  isDisabled: isCurrent || checkout.status === "waiting", onPress: () => setPending({ plan, action }), children: [plan_pricing_js_1.ACTION_LABEL[action], !isCurrent && (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) })] }) }, tier.planGroup));
145
- }) }), subscription?.subscription && ((0, jsx_runtime_1.jsx)(ConnectUnsubscribeRow, { subscription: subscription.subscription, appUrl: appUrl, checkout: checkout })), (0, jsx_runtime_1.jsx)(subscribe_dialog_js_1.ConnectSubscribeDialog, { plan: pending?.plan ?? null, action: pending?.action ?? "subscribe", current: subscription, coupon: hideCoupon ? undefined : coupon, onClose: () => setPending(null), onConfirm: () => pending && startCheckout(pending.plan) })] }) }));
150
+ }) }), subscription?.subscription && ((0, jsx_runtime_1.jsx)(ConnectUnsubscribeRow, { subscription: subscription.subscription, appUrl: appUrl, checkout: checkout })), (0, jsx_runtime_1.jsx)(subscribe_dialog_js_1.ConnectSubscribeDialog, { plan: pending?.plan ?? null, action: pending?.action ?? "subscribe", current: subscription, coupon: hideCoupon ? undefined : coupon, onClose: () => setPending(null), onConfirm: () => pending && startCheckout(pending.plan, pending.action) })] }) }));
146
151
  }
147
152
  /**
148
153
  * What the page says while the customer is off in Connect, and after.
@@ -14,6 +14,7 @@ const react_1 = require("@heroui/react");
14
14
  const lucide_react_1 = require("lucide-react");
15
15
  const provider_js_1 = require("../provider.js");
16
16
  const mark_js_1 = require("../mark.js");
17
+ const identity_js_1 = require("./identity.js");
17
18
  const account_sessions_js_1 = require("./account-sessions.js");
18
19
  const motion_js_1 = require("./motion.js");
19
20
  const shell_context_js_1 = require("./shell-context.js");
@@ -23,11 +24,6 @@ const THEME_OPTIONS = [
23
24
  { value: "dark", label: "Dark", icon: lucide_react_1.Moon },
24
25
  { value: "system", label: "System", icon: lucide_react_1.Laptop },
25
26
  ];
26
- function initials(name, email) {
27
- const source = name?.trim() || email?.trim() || "";
28
- const parts = source.split(/[\s@._-]+/).filter(Boolean);
29
- return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
30
- }
31
27
  function openExternal(url) {
32
28
  window.open(url, "_blank", "noopener,noreferrer");
33
29
  }
@@ -73,7 +69,7 @@ function UserAvatar({ user, size, }) {
73
69
  // squircle sitting beside circular status dots reads as a rendering fault
74
70
  // rather than a choice. The root is `overflow-hidden`, so rounding it here
75
71
  // rounds the image and the fallback with it.
76
- (0, jsx_runtime_1.jsxs)(react_1.Avatar, { size: size, color: "accent", variant: "soft", className: "rounded-full", children: [user.photoUrl && (0, jsx_runtime_1.jsx)(react_1.Avatar.Image, { alt: "", src: user.photoUrl }), (0, jsx_runtime_1.jsx)(react_1.Avatar.Fallback, { children: initials(user.displayName, user.email) })] }));
72
+ (0, jsx_runtime_1.jsxs)(react_1.Avatar, { size: size, color: "accent", variant: "soft", className: "rounded-full", children: [user.photoUrl && (0, jsx_runtime_1.jsx)(react_1.Avatar.Image, { alt: "", src: user.photoUrl }), (0, jsx_runtime_1.jsx)(react_1.Avatar.Fallback, { children: (0, identity_js_1.initials)(user.displayName, user.email) })] }));
77
73
  }
78
74
  /** `dispatch_admin` / `billing-viewer` -> `Dispatch admin`. */
79
75
  function humanizeRole(role) {