@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.
- package/CHANGELOG.md +281 -0
- package/README.md +2 -0
- package/dist/cjs/client/components/identity.js +24 -0
- package/dist/cjs/client/components/one-tap-state.js +96 -0
- package/dist/cjs/client/components/one-tap.js +106 -0
- package/dist/cjs/client/components/plan-pricing.js +28 -0
- package/dist/cjs/client/components/plans.js +8 -3
- package/dist/cjs/client/components/user-menu.js +2 -6
- package/dist/cjs/client/index.js +11 -3
- package/dist/cjs/client/provider.js +102 -7
- package/dist/cjs/server/client.js +12 -1
- package/dist/cjs/server/handler.js +126 -2
- package/dist/cjs/server/resources/calls.js +57 -0
- package/dist/cjs/server/resources/channels.js +115 -0
- package/dist/cjs/server/resources/enrichment.js +56 -0
- package/dist/cjs/server/resources/identity.js +40 -0
- package/dist/cjs/server/resources/workspaces.js +73 -2
- package/dist/esm/client/components/identity.js +21 -0
- package/dist/esm/client/components/one-tap-state.js +90 -0
- package/dist/esm/client/components/one-tap.js +103 -0
- package/dist/esm/client/components/plan-pricing.js +27 -0
- package/dist/esm/client/components/plans.js +9 -4
- package/dist/esm/client/components/user-menu.js +1 -5
- package/dist/esm/client/index.js +8 -1
- package/dist/esm/client/provider.js +102 -7
- package/dist/esm/server/client.js +12 -1
- package/dist/esm/server/handler.js +127 -3
- package/dist/esm/server/resources/calls.js +53 -0
- package/dist/esm/server/resources/channels.js +111 -0
- package/dist/esm/server/resources/enrichment.js +52 -0
- package/dist/esm/server/resources/identity.js +39 -0
- package/dist/esm/server/resources/workspaces.js +73 -2
- package/dist/types/client/components/identity.d.ts +17 -0
- package/dist/types/client/components/one-tap-state.d.ts +39 -0
- package/dist/types/client/components/one-tap.d.ts +50 -0
- package/dist/types/client/components/plan-pricing.d.ts +23 -0
- package/dist/types/client/index.d.ts +4 -2
- package/dist/types/client/provider.d.ts +101 -3
- package/dist/types/client/use-checkout.d.ts +9 -0
- package/dist/types/server/client.d.ts +9 -1
- package/dist/types/server/index.d.ts +3 -0
- package/dist/types/server/resources/calls.d.ts +45 -0
- package/dist/types/server/resources/channels.d.ts +91 -0
- package/dist/types/server/resources/enrichment.d.ts +44 -0
- package/dist/types/server/resources/identity.d.ts +35 -0
- package/dist/types/server/resources/workspaces.d.ts +63 -3
- package/dist/types/types.d.ts +344 -1
- 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:
|
|
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) {
|