@cosmoops/connect-sdk 0.20.0 → 0.21.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 +117 -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/user-menu.js +2 -6
- package/dist/cjs/client/index.js +5 -3
- package/dist/cjs/client/provider.js +88 -0
- package/dist/cjs/server/client.js +9 -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/identity.js +40 -0
- 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/user-menu.js +1 -5
- package/dist/esm/client/index.js +1 -0
- package/dist/esm/client/provider.js +88 -0
- package/dist/esm/server/client.js +9 -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/identity.js +39 -0
- 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/index.d.ts +3 -1
- package/dist/types/client/provider.d.ts +81 -0
- package/dist/types/server/client.d.ts +7 -1
- package/dist/types/server/index.d.ts +2 -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/identity.d.ts +35 -0
- package/dist/types/types.d.ts +172 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,8 +3,125 @@
|
|
|
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.21.0
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`<ConnectOneTap />`** - a signed-out visitor who is already signed into
|
|
11
|
+
Connect is offered their account, and one tap signs them in **without leaving
|
|
12
|
+
the page**.
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
<ConnectProvider>
|
|
16
|
+
<App />
|
|
17
|
+
<ConnectOneTap />
|
|
18
|
+
</ConnectProvider>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That is the whole integration. It needs no `clientId` in the browser and no
|
|
22
|
+
configuration: the bridge already knows the client, and now tells the page
|
|
23
|
+
where to ask on the `/session` read it was making anyway, so discovering One
|
|
24
|
+
Tap costs no extra request.
|
|
25
|
+
|
|
26
|
+
**What it replaces.** `silentSignIn` answers the same question - *is this
|
|
27
|
+
visitor already signed into Connect?* - by bouncing the browser to Connect
|
|
28
|
+
and back with `prompt=none`. That costs a visible full-page navigation on the
|
|
29
|
+
first load of **every** signed-out visit, shows the visitor nothing while it
|
|
30
|
+
happens, and gives them no way to decline. One Tap asks with a single
|
|
31
|
+
`fetch`, names the account before anything is signed, and completes with no
|
|
32
|
+
navigation at all. `silentSignIn` still works and is unchanged; prefer this.
|
|
33
|
+
|
|
34
|
+
**It only appears when it can help.** A visitor Connect does not recognise
|
|
35
|
+
sees nothing, ever - no prompt, no flicker, no redirect - so this is safe to
|
|
36
|
+
mount unconditionally, which `silentSignIn` never was.
|
|
37
|
+
|
|
38
|
+
**Requires your application to be same-site with Connect** - a
|
|
39
|
+
`cosmoops.com` subdomain. `SameSite` is judged on the registrable domain, so
|
|
40
|
+
the visitor's Connect cookie rides along from `crm.cosmoops.com` and from
|
|
41
|
+
nowhere else. Deployed off-site, the prompt simply never appears and
|
|
42
|
+
`ConnectLoginButton` behaves exactly as before. No third-party cookie is
|
|
43
|
+
involved at any point, so this works in every browser rather than only in
|
|
44
|
+
Chromium.
|
|
45
|
+
|
|
46
|
+
**Two prompts, because consent is not assumed.** A visitor who has authorised
|
|
47
|
+
your application before gets their name, email, workspace and photo. One who
|
|
48
|
+
has not gets an impersonal prompt whose tap leads to the real consent
|
|
49
|
+
screen - Connect releases no identifying detail at all until that has
|
|
50
|
+
happened once.
|
|
51
|
+
|
|
52
|
+
**Dismissal is remembered.** Closing the prompt buys 2 hours of quiet, then a
|
|
53
|
+
day, then a week; signing out buys a day. All in your own origin's
|
|
54
|
+
`localStorage`, and none of it leaves the browser.
|
|
55
|
+
|
|
56
|
+
- **`readOneTapHint()` and `oneTapSignIn()` on `useConnect()`** - the two calls
|
|
57
|
+
`<ConnectOneTap />` is built from, for applications that want to draw the
|
|
58
|
+
prompt themselves. Both report expected outcomes as values rather than
|
|
59
|
+
throwing: `consent_required` and `interaction_required` mean "hand off to the
|
|
60
|
+
interactive screen", which is a hand-off, not an error to show anybody.
|
|
61
|
+
|
|
62
|
+
- **`ConnectSession.oneTap`** - where the browser may ask Connect about the
|
|
63
|
+
visitor, or `null` when this deployment cannot run One Tap (no `clientId`, or
|
|
64
|
+
a session store with no `write`). Present on the signed-in and signed-out
|
|
65
|
+
branches alike, so no consumer has to test for key presence.
|
|
66
|
+
|
|
67
|
+
### Security
|
|
68
|
+
|
|
69
|
+
- The One Tap exchange is **PKCE-bound, and the verifier never reaches the
|
|
70
|
+
page**. Connect hands the authorization code to the browser, so unlike the
|
|
71
|
+
redirect flow the code alone must not be redeemable: `/one-tap/start` mints
|
|
72
|
+
the pair and keeps the verifier in an `HttpOnly`, `SameSite=Lax` cookie on
|
|
73
|
+
your own origin, and `/one-tap/complete` is the only thing that can spend it.
|
|
74
|
+
A code lifted out of the browser, or replayed cross-site, is worthless.
|
|
75
|
+
|
|
6
76
|
## 0.20.0
|
|
7
77
|
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- **`connect.channels` and `connect.calls`** - the phone numbers Connect has
|
|
81
|
+
assigned to your application, and the calls that arrived on them. Seven
|
|
82
|
+
endpoints that have been live on the API and reachable only by hand until
|
|
83
|
+
now.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const { id } = await connect.channels.claim({ workspaceId });
|
|
87
|
+
await connect.channels.setRouting(id, { agent: "sales", afterHours: "voicemail" });
|
|
88
|
+
|
|
89
|
+
const { items } = await connect.calls.list({ channelId: id });
|
|
90
|
+
const { call, turns } = await connect.calls.get(items[0].id);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**You never pick the number.** Inventory is a regulated asset bought under
|
|
94
|
+
Connect's carrier account, so `claim` takes a workspace and a kind and
|
|
95
|
+
Connect picks from a pool you cannot see - which is also why nothing here
|
|
96
|
+
exposes a carrier, a provider reference or a monthly rate. A workspace holds
|
|
97
|
+
exactly one number across every application it uses, and it is fixed for the
|
|
98
|
+
life of the engagement; only a Connect operator can unassign one. A `409`
|
|
99
|
+
means either "this workspace already has one" (fix the call) or "the pool is
|
|
100
|
+
empty right now" (retry - Connect is notified).
|
|
101
|
+
|
|
102
|
+
`setRouting`'s `destination` is an arbitrary JSON object Connect stores
|
|
103
|
+
verbatim and **never reads into**: which agent answers, business hours,
|
|
104
|
+
overflow and fallback are all your application's model. It replaces rather
|
|
105
|
+
than merges, and `clearRouting` is the only way to say "stop answering" -
|
|
106
|
+
overwriting a destination can only point the number somewhere else. Clearing
|
|
107
|
+
is idempotent and, unlike `setRouting`, is allowed on a suspended channel:
|
|
108
|
+
making a live number stop answering must never be the operation that is
|
|
109
|
+
unavailable.
|
|
110
|
+
|
|
111
|
+
`calls.list` filters on `channelId` and nothing else - each further filter
|
|
112
|
+
costs a Firestore composite index - and pages by cursor: **page until
|
|
113
|
+
`hasMore` is `false` rather than stopping on an empty `items`.**
|
|
114
|
+
`calls.get` adds the transcript, ordered by `sequence`, which is the sort to
|
|
115
|
+
trust since two turns can share a millisecond. `recordingFileId` and
|
|
116
|
+
`transcriptFileId` are Drive file ids readable with a `drive:read` key, and
|
|
117
|
+
are `null` until the carrier posts the audio - branch on `recordingStatus`
|
|
118
|
+
rather than reading `null` as a failure.
|
|
119
|
+
|
|
120
|
+
Scopes: `channels:read`, `channels:claim`, `channels:manage`, `calls:read`.
|
|
121
|
+
A channel or call belonging to another application answers `not_found`, not
|
|
122
|
+
`forbidden` - confirming it exists would make these reads an oracle for
|
|
123
|
+
enumerating another partner's ids.
|
|
124
|
+
|
|
8
125
|
### Changed
|
|
9
126
|
|
|
10
127
|
- **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
|
+
}
|
|
@@ -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) {
|
package/dist/cjs/client/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.
|
|
5
|
-
exports.
|
|
6
|
-
exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = void 0;
|
|
4
|
+
exports.flattenNav = exports.normalizeNav = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = exports.RAIL_EXPANDED = exports.RAIL_COLLAPSED = exports.sidebarRowClass = exports.useConnectShell = exports.ConnectShellProvider = exports.ConnectSidebarRow = exports.ConnectSidebarFooter = exports.ConnectSidebarBody = exports.ConnectSidebarHeader = exports.ConnectSidebar = exports.AppShell = exports.ConnectAccountSessions = exports.ConnectSignOutButton = exports.ConnectHomeButton = exports.ConnectProfileButton = exports.ConnectInvoicesButton = exports.ConnectManageSubscriptionButton = exports.ConnectUserMenu = exports.ConnectProfileModal = exports.ConnectUserButton = exports.ConnectOneTap = exports.ConnectRegisterButton = exports.ConnectLoginButton = exports.ConnectLogo = exports.ConnectMark = exports.ConnectGrievanceNotice = exports.isConnectError = exports.ConnectError = exports.normalizePlanFeatures = exports.blockingBlockers = exports.CONNECT_APP_URL = exports.useConnectResource = exports.useConnectIntegrations = exports.useConnectSessions = exports.useConnectAddonUsage = exports.useConnectCompanyProfile = exports.useConnectApps = exports.useConnectPlans = exports.useConnectEntitlements = exports.useConnectPlatformAccess = exports.useConnectAudience = exports.useConnectWorkspaceType = exports.useConnectUser = exports.useConnect = exports.ConnectProvider = void 0;
|
|
5
|
+
exports.resolveAction = exports.monthlyOf = exports.formatPrice = exports.buildCheckoutUrl = exports.useConnectCheckout = exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = exports.ConnectPlansPage = exports.ConnectUsageMeter = exports.ConnectPlatformGate = exports.ConnectWorkspaceTypeGate = exports.useConnectFeature = exports.ConnectFeatureGate = exports.useConnectAccessGate = exports.ConnectAccessGate = exports.ConnectReadinessGate = exports.ConnectReadinessBanner = exports.resolveSubscriptionState = exports.ConnectSeatUsage = exports.ConnectTrialBanner = exports.ConnectSubscriptionCard = exports.ConnectNoSubscriptionCard = exports.ConnectSubscriptionGate = exports.ConnectSubscribeDialog = exports.ConnectPlans = exports.ConnectIntegrationsModal = exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = exports.aiStatusTone = exports.isNumericAiFormat = exports.formatAiValue = exports.AiSources = exports.AiSteps = exports.AiDetails = exports.AiTimeline = exports.AiMetrics = exports.AiChart = exports.AiTable = exports.ConnectAskAi = exports.ConnectAppsGrid = exports.isLinkableConnectScreen = exports.matchConnectScreen = exports.filterNavForAudience = exports.gateExemptPaths = exports.isNavItemActive = exports.resolveActivePath = exports.normalizePath = void 0;
|
|
6
|
+
exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = exports.annualSaving = void 0;
|
|
7
7
|
/**
|
|
8
8
|
* Browser half of `@cosmoops/connect-sdk` - React components and hooks.
|
|
9
9
|
*
|
|
@@ -50,6 +50,8 @@ Object.defineProperty(exports, "ConnectLogo", { enumerable: true, get: function
|
|
|
50
50
|
var auth_buttons_js_1 = require("./components/auth-buttons.js");
|
|
51
51
|
Object.defineProperty(exports, "ConnectLoginButton", { enumerable: true, get: function () { return auth_buttons_js_1.ConnectLoginButton; } });
|
|
52
52
|
Object.defineProperty(exports, "ConnectRegisterButton", { enumerable: true, get: function () { return auth_buttons_js_1.ConnectRegisterButton; } });
|
|
53
|
+
var one_tap_js_1 = require("./components/one-tap.js");
|
|
54
|
+
Object.defineProperty(exports, "ConnectOneTap", { enumerable: true, get: function () { return one_tap_js_1.ConnectOneTap; } });
|
|
53
55
|
var user_menu_js_1 = require("./components/user-menu.js");
|
|
54
56
|
Object.defineProperty(exports, "ConnectUserButton", { enumerable: true, get: function () { return user_menu_js_1.ConnectUserButton; } });
|
|
55
57
|
Object.defineProperty(exports, "ConnectProfileModal", { enumerable: true, get: function () { return user_menu_js_1.ConnectProfileModal; } });
|
|
@@ -20,6 +20,7 @@ exports.useConnectSessions = useConnectSessions;
|
|
|
20
20
|
exports.useConnectResource = useConnectResource;
|
|
21
21
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
22
22
|
const react_1 = require("react");
|
|
23
|
+
const one_tap_state_js_1 = require("./components/one-tap-state.js");
|
|
23
24
|
exports.CONNECT_APP_URL = "https://connect.cosmoops.com";
|
|
24
25
|
const ConnectContext = (0, react_1.createContext)(null);
|
|
25
26
|
/** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
|
|
@@ -204,8 +205,91 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
204
205
|
credentials: "same-origin",
|
|
205
206
|
}).catch(() => { });
|
|
206
207
|
cache.current.clear();
|
|
208
|
+
// Someone who just signed out must not be offered the same account back on
|
|
209
|
+
// the next render - see `suppressOneTapAfterSignOut`.
|
|
210
|
+
(0, one_tap_state_js_1.suppressOneTapAfterSignOut)();
|
|
207
211
|
setResult({ session: { user: null, subscription: null, role: null, permissions: [], staff: false, audience: null }, error: null });
|
|
208
212
|
}, [basePath, fetchImpl]);
|
|
213
|
+
/**
|
|
214
|
+
* Where Connect answers One Tap, taken from the session read rather than
|
|
215
|
+
* assembled here - the bridge knows the `client_id` and the browser need not.
|
|
216
|
+
*/
|
|
217
|
+
const oneTapEndpoints = result?.session.oneTap ?? null;
|
|
218
|
+
const readOneTapHint = (0, react_1.useCallback)(async () => {
|
|
219
|
+
if (!oneTapEndpoints)
|
|
220
|
+
return null;
|
|
221
|
+
try {
|
|
222
|
+
// `credentials: "include"` because this is the one request in the SDK
|
|
223
|
+
// that leaves the application's own origin. It carries the visitor's
|
|
224
|
+
// Connect cookie only when the two are same-site; everywhere else it is
|
|
225
|
+
// simply sent without one and comes back `signed_out`.
|
|
226
|
+
const response = await (fetchImpl ?? globalThis.fetch)(oneTapEndpoints.hintUrl, {
|
|
227
|
+
credentials: "include",
|
|
228
|
+
headers: { accept: "application/json" },
|
|
229
|
+
});
|
|
230
|
+
if (!response.ok)
|
|
231
|
+
return null;
|
|
232
|
+
return (await response.json());
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Offline, blocked, or Connect is down. None of those is evidence about
|
|
236
|
+
// whether this visitor has a session, so the honest answer is silence.
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}, [fetchImpl, oneTapEndpoints]);
|
|
240
|
+
const oneTapSignIn = (0, react_1.useCallback)(async () => {
|
|
241
|
+
if (!oneTapEndpoints)
|
|
242
|
+
return { ok: false, reason: "unavailable" };
|
|
243
|
+
try {
|
|
244
|
+
// 1. The bridge mints the PKCE pair and keeps the verifier in an
|
|
245
|
+
// `HttpOnly` cookie on this origin.
|
|
246
|
+
const start = await mutateResource("/one-tap/start");
|
|
247
|
+
// 2. Connect mints a code against that challenge. The custom header is
|
|
248
|
+
// what forces the preflight, so an origin this client has not
|
|
249
|
+
// registered is refused before the request is ever sent.
|
|
250
|
+
const authorize = await (fetchImpl ?? globalThis.fetch)(oneTapEndpoints.authorizeUrl, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
credentials: "include",
|
|
253
|
+
headers: {
|
|
254
|
+
accept: "application/json",
|
|
255
|
+
"content-type": "application/json",
|
|
256
|
+
"x-connect-one-tap": "1",
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
redirect_uri: start.redirectUri,
|
|
260
|
+
code_challenge: start.codeChallenge,
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
const body = (await authorize.json().catch(() => null));
|
|
264
|
+
if (!authorize.ok || !body?.code) {
|
|
265
|
+
// Connect names the reason, and each one has a different next step -
|
|
266
|
+
// `consent_required` wants the interactive screen, `rate_limited` wants
|
|
267
|
+
// to be left alone. Anything unrecognised is reported as a plain
|
|
268
|
+
// failure rather than guessed at.
|
|
269
|
+
const reason = body?.error;
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
reason: reason === "login_required" ||
|
|
273
|
+
reason === "consent_required" ||
|
|
274
|
+
reason === "interaction_required" ||
|
|
275
|
+
reason === "rate_limited"
|
|
276
|
+
? reason
|
|
277
|
+
: "failed",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// 3. The bridge exchanges the code with the verifier the page never saw,
|
|
281
|
+
// and stores the session.
|
|
282
|
+
await mutateResource("/one-tap/complete", { code: body.code });
|
|
283
|
+
// The prompt was accepted, so whatever backoff earlier dismissals had
|
|
284
|
+
// accumulated is spent - it described reluctance that no longer holds.
|
|
285
|
+
(0, one_tap_state_js_1.clearOneTapSuppression)();
|
|
286
|
+
await refresh();
|
|
287
|
+
return { ok: true };
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return { ok: false, reason: "failed" };
|
|
291
|
+
}
|
|
292
|
+
}, [fetchImpl, mutateResource, oneTapEndpoints, refresh]);
|
|
209
293
|
const value = (0, react_1.useMemo)(() => ({
|
|
210
294
|
clientId,
|
|
211
295
|
appUrl,
|
|
@@ -219,6 +303,8 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
219
303
|
mutateResource,
|
|
220
304
|
invalidateResource,
|
|
221
305
|
streamResource,
|
|
306
|
+
readOneTapHint,
|
|
307
|
+
oneTapSignIn,
|
|
222
308
|
}), [
|
|
223
309
|
clientId,
|
|
224
310
|
appUrl,
|
|
@@ -232,6 +318,8 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
232
318
|
mutateResource,
|
|
233
319
|
invalidateResource,
|
|
234
320
|
streamResource,
|
|
321
|
+
readOneTapHint,
|
|
322
|
+
oneTapSignIn,
|
|
235
323
|
]);
|
|
236
324
|
return (0, jsx_runtime_1.jsx)(ConnectContext.Provider, { value: value, children: children });
|
|
237
325
|
}
|
|
@@ -8,6 +8,8 @@ const data_js_1 = require("./resources/data.js");
|
|
|
8
8
|
const workspaces_js_1 = require("./resources/workspaces.js");
|
|
9
9
|
const scheduling_js_1 = require("./resources/scheduling.js");
|
|
10
10
|
const contacts_js_1 = require("./resources/contacts.js");
|
|
11
|
+
const channels_js_1 = require("./resources/channels.js");
|
|
12
|
+
const calls_js_1 = require("./resources/calls.js");
|
|
11
13
|
const kv_js_1 = require("./resources/kv.js");
|
|
12
14
|
const billing_js_1 = require("./resources/billing.js");
|
|
13
15
|
const drive_js_1 = require("./resources/drive.js");
|
|
@@ -85,6 +87,10 @@ class ConnectClient {
|
|
|
85
87
|
scheduling;
|
|
86
88
|
/** Who a workspace already knows how to reach, by phone or by id. */
|
|
87
89
|
contacts;
|
|
90
|
+
/** The phone numbers Connect has assigned to your application, and where each one routes. */
|
|
91
|
+
channels;
|
|
92
|
+
/** The calls that arrived on those channels, with their transcripts. */
|
|
93
|
+
calls;
|
|
88
94
|
/** RAG chat and structured extraction. */
|
|
89
95
|
ai;
|
|
90
96
|
/** Your application's own document store - see `DataResource`. */
|
|
@@ -135,6 +141,8 @@ class ConnectClient {
|
|
|
135
141
|
this.drive = new drive_js_1.DriveResource(transport);
|
|
136
142
|
this.scheduling = new scheduling_js_1.SchedulingResource(transport);
|
|
137
143
|
this.contacts = new contacts_js_1.ContactsResource(transport);
|
|
144
|
+
this.channels = new channels_js_1.ChannelsResource(transport);
|
|
145
|
+
this.calls = new calls_js_1.CallsResource(transport);
|
|
138
146
|
this.ai = new ai_js_1.AiResource(transport);
|
|
139
147
|
this.data = new data_js_1.DataResource(transport);
|
|
140
148
|
this.kv = new kv_js_1.KvResource(transport);
|
|
@@ -150,4 +158,4 @@ class ConnectClient {
|
|
|
150
158
|
}
|
|
151
159
|
exports.ConnectClient = ConnectClient;
|
|
152
160
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
153
|
-
exports.SDK_VERSION = "0.
|
|
161
|
+
exports.SDK_VERSION = "0.21.0";
|