@cosmoops/connect-sdk 0.7.0 → 0.9.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 +165 -0
- package/README.md +273 -91
- package/dist/cjs/client/components/access-gate.js +39 -4
- package/dist/cjs/client/components/ai-format.js +135 -0
- package/dist/cjs/client/components/ai-widgets.js +232 -0
- package/dist/cjs/client/components/app-shell.js +7 -6
- package/dist/cjs/client/components/ask-ai.js +334 -0
- package/dist/cjs/client/components/nav.js +1 -1
- package/dist/cjs/client/components/side-nav.js +1 -1
- package/dist/cjs/client/components/user-menu.js +28 -5
- package/dist/cjs/client/components/workspace-type-gate.js +48 -0
- package/dist/cjs/client/index.js +31 -3
- package/dist/cjs/client/provider.js +45 -2
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +43 -0
- package/dist/cjs/server/http.js +46 -24
- package/dist/cjs/server/resources/entitlements.js +41 -0
- package/dist/cjs/server/resources/identity.js +2 -0
- package/dist/esm/client/components/access-gate.js +40 -5
- package/dist/esm/client/components/ai-format.js +129 -0
- package/dist/esm/client/components/ai-widgets.js +223 -0
- package/dist/esm/client/components/app-shell.js +6 -5
- package/dist/esm/client/components/ask-ai.js +331 -0
- package/dist/esm/client/components/nav.js +1 -1
- package/dist/esm/client/components/side-nav.js +1 -1
- package/dist/esm/client/components/user-menu.js +28 -5
- package/dist/esm/client/components/workspace-type-gate.js +45 -0
- package/dist/esm/client/index.js +13 -2
- package/dist/esm/client/provider.js +43 -2
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +43 -0
- package/dist/esm/server/http.js +46 -24
- package/dist/esm/server/resources/entitlements.js +41 -0
- package/dist/esm/server/resources/identity.js +2 -0
- package/dist/theme.css +87 -0
- package/dist/types/client/components/access-gate.d.ts +17 -3
- package/dist/types/client/components/ai-format.d.ts +24 -0
- package/dist/types/client/components/ai-widgets.d.ts +42 -0
- package/dist/types/client/components/app-shell.d.ts +21 -5
- package/dist/types/client/components/ask-ai.d.ts +73 -0
- package/dist/types/client/components/nav.d.ts +3 -3
- package/dist/types/client/components/side-nav.d.ts +1 -1
- package/dist/types/client/components/user-menu.d.ts +2 -1
- package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
- package/dist/types/client/index.d.ts +10 -3
- package/dist/types/client/provider.d.ts +44 -1
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/http.d.ts +6 -1
- package/dist/types/server/resources/entitlements.d.ts +33 -1
- package/dist/types/types.d.ts +174 -0
- package/package.json +8 -2
|
@@ -34,7 +34,7 @@ function NavRow({ item, isActive, hasActiveChild, collapsed, depth, renderLink,
|
|
|
34
34
|
* The sidebar's nav column on its own - sections, items, one level of
|
|
35
35
|
* children, and the active highlight.
|
|
36
36
|
*
|
|
37
|
-
* `
|
|
37
|
+
* `AppShell` renders this inside its rail and its mobile drawer, and it
|
|
38
38
|
* is exported for a host that wants the shell's navigation without its
|
|
39
39
|
* chrome: the same items, the same active resolution, dropped into a layout
|
|
40
40
|
* you already have.
|
|
@@ -19,6 +19,28 @@ function initials(name, email) {
|
|
|
19
19
|
function openExternal(url) {
|
|
20
20
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Connect's profile page, with the context it needs to offer a way back here.
|
|
24
|
+
*
|
|
25
|
+
* Connect answers `/profile` for any signed-in user, so the bare URL works on
|
|
26
|
+
* its own - these two params only add the "Back to <your app>" banner. It
|
|
27
|
+
* validates `return_to` against this `client_id`'s registered redirect
|
|
28
|
+
* origins and silently drops the banner if they don't match, so a wrong or
|
|
29
|
+
* stale `appUrl`/`clientId` pairing degrades to what it did before rather
|
|
30
|
+
* than erroring.
|
|
31
|
+
*
|
|
32
|
+
* `window.location.href` rather than a configured home URL: the point is to
|
|
33
|
+
* come back to the page they left, and only the browser knows which one that
|
|
34
|
+
* is.
|
|
35
|
+
*/
|
|
36
|
+
function profileUrl(appUrl, clientId) {
|
|
37
|
+
const url = new URL("/profile", appUrl);
|
|
38
|
+
url.searchParams.set("client_id", clientId);
|
|
39
|
+
if (typeof window !== "undefined") {
|
|
40
|
+
url.searchParams.set("return_to", window.location.href);
|
|
41
|
+
}
|
|
42
|
+
return url.toString();
|
|
43
|
+
}
|
|
22
44
|
/**
|
|
23
45
|
* The member's photo, with their initials underneath it.
|
|
24
46
|
*
|
|
@@ -114,7 +136,7 @@ function DestinationTile({ icon, label, url, }) {
|
|
|
114
136
|
* the modal programmatically; sign-out uses `slot="close"` instead.
|
|
115
137
|
*/
|
|
116
138
|
export function ConnectUserMenu({ compact = false, onSignOut, className, theme, onThemeChange, }) {
|
|
117
|
-
const { session, isLoading, signOut, appUrl } = useConnect();
|
|
139
|
+
const { session, isLoading, signOut, appUrl, clientId } = useConnect();
|
|
118
140
|
if (isLoading) {
|
|
119
141
|
return (_jsxs("div", { className: `flex items-center gap-2 ${className ?? ""}`, children: [_jsx(Skeleton, { className: "size-8 shrink-0 rounded-full" }), !compact && _jsx(Skeleton, { className: "h-4 w-24" })] }));
|
|
120
142
|
}
|
|
@@ -128,7 +150,7 @@ export function ConnectUserMenu({ compact = false, onSignOut, className, theme,
|
|
|
128
150
|
// `pl-1` against the default right padding is optical, not sloppy: a
|
|
129
151
|
// circular avatar needs less inset than a text label to look evenly
|
|
130
152
|
// seated in the button.
|
|
131
|
-
className: `gap-2 pl-1 ${className ?? ""}`, children: [_jsx(UserAvatar, { user: user, size: "sm" }), !compact && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url:
|
|
153
|
+
className: `gap-2 pl-1 ${className ?? ""}`, children: [_jsx(UserAvatar, { user: user, size: "sm" }), !compact && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url: profileUrl(appUrl, clientId) }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
|
|
132
154
|
const [next] = [...keys];
|
|
133
155
|
if (typeof next === "string")
|
|
134
156
|
onThemeChange(next);
|
|
@@ -167,13 +189,14 @@ export function ConnectInvoicesButton({ className }) {
|
|
|
167
189
|
* `ConnectHomeButton`.
|
|
168
190
|
*
|
|
169
191
|
* It was reachable only from inside `ConnectUserMenu`, which meant an app that
|
|
170
|
-
* builds its own account menu had to hardcode `${appUrl}/profile` to offer it
|
|
192
|
+
* builds its own account menu had to hardcode `${appUrl}/profile` to offer it -
|
|
193
|
+
* and got no `return_to`, so the user had no way back.
|
|
171
194
|
*/
|
|
172
195
|
export function ConnectProfileButton({ className }) {
|
|
173
|
-
const { session, appUrl } = useConnect();
|
|
196
|
+
const { session, appUrl, clientId } = useConnect();
|
|
174
197
|
if (!session?.user)
|
|
175
198
|
return null;
|
|
176
|
-
return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(
|
|
199
|
+
return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(profileUrl(appUrl, clientId)), children: [_jsx(UserRound, { className: "size-4 shrink-0" }), "Profile", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
|
|
177
200
|
}
|
|
178
201
|
/** A plain "Open Connect" button, for a footer or a help menu. */
|
|
179
202
|
export function ConnectHomeButton({ className }) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useConnect } from "../provider.js";
|
|
4
|
+
/**
|
|
5
|
+
* Renders `children` only for a workspace of the given type.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <ConnectWorkspaceTypeGate type="business">
|
|
9
|
+
* <TeamMembersTable />
|
|
10
|
+
* </ConnectWorkspaceTypeGate>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The point is dead controls, not access: an `"individual"` workspace has
|
|
14
|
+
* exactly one seat and can never have another, so a members table, an invite
|
|
15
|
+
* button and a seat meter are all things that can only ever fail there.
|
|
16
|
+
* Hiding them is a better answer than letting someone press one and read an
|
|
17
|
+
* error about an account shape they did not choose.
|
|
18
|
+
*
|
|
19
|
+
* This is emphatically **not** the audience gate. It does not know what your
|
|
20
|
+
* application is sold to and will happily render a business-only subtree to a
|
|
21
|
+
* business workspace that has no business being in your application at all -
|
|
22
|
+
* that is `<ConnectAccessGate>`'s job, off the `audience_mismatch` readiness
|
|
23
|
+
* blocker. Use this one *inside* an application both audiences can reach, to
|
|
24
|
+
* vary what each sees.
|
|
25
|
+
*
|
|
26
|
+
* Like every gate in this package it is a **UX gate, not a security
|
|
27
|
+
* boundary**: the browser can ignore it. Anything that must not happen for an
|
|
28
|
+
* individual workspace has to be refused on your server too.
|
|
29
|
+
*
|
|
30
|
+
* Renders `loading` - not `fallback` - while the session is unknown, so a
|
|
31
|
+
* business account never flickers through the individual-account layout.
|
|
32
|
+
*/
|
|
33
|
+
export function ConnectWorkspaceTypeGate({ type, children, fallback = null, loading = null, }) {
|
|
34
|
+
const { session, isLoading } = useConnect();
|
|
35
|
+
const workspaceType = session?.user?.workspaceType ?? null;
|
|
36
|
+
if (isLoading)
|
|
37
|
+
return _jsx(_Fragment, { children: loading });
|
|
38
|
+
// Signed out is not "the other type" - there is no workspace to be a type
|
|
39
|
+
// of yet, and rendering the fallback here would put an individual-account
|
|
40
|
+
// layout in front of a visitor who has not signed in.
|
|
41
|
+
if (!workspaceType)
|
|
42
|
+
return _jsx(_Fragment, { children: loading });
|
|
43
|
+
const accepted = Array.isArray(type) ? type : [type];
|
|
44
|
+
return _jsx(_Fragment, { children: accepted.includes(workspaceType) ? children : fallback });
|
|
45
|
+
}
|
package/dist/esm/client/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* whatever theme the host application platform already configured, and one theme
|
|
12
12
|
* change lands identically in all of them.
|
|
13
13
|
*/
|
|
14
|
-
export { ConnectProvider, useConnect, useConnectUser, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
|
|
14
|
+
export { ConnectProvider, useConnect, useConnectUser, useConnectWorkspaceType, useConnectAudience, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
|
|
15
15
|
// Re-exported from the root entry rather than kept there alone: it is a pure
|
|
16
16
|
// helper, but every caller is UI code, and making a component file reach two
|
|
17
17
|
// entry points for one function is friction with nothing behind it.
|
|
@@ -28,7 +28,7 @@ export { ConnectAccountSessions } from "./components/account-sessions.js";
|
|
|
28
28
|
// pieces it composes rather than in a section of its own - an integration
|
|
29
29
|
// reaching for a sidebar wants to find the launcher and the account menu in
|
|
30
30
|
// the same breath, since the shell already mounts both.
|
|
31
|
-
export {
|
|
31
|
+
export { AppShell } from "./components/app-shell.js";
|
|
32
32
|
export { ConnectSideNav } from "./components/side-nav.js";
|
|
33
33
|
export { useActivePath } from "./use-active-path.js";
|
|
34
34
|
// The nav model is plain data and pure functions - a host generating its nav
|
|
@@ -37,6 +37,16 @@ export { useActivePath } from "./use-active-path.js";
|
|
|
37
37
|
// page title derived from it cannot disagree with the rail.
|
|
38
38
|
export { normalizeNav, flattenNav, normalizePath, resolveActivePath, isNavItemActive, gateExemptPaths, } from "./components/nav.js";
|
|
39
39
|
export { ConnectAppsGrid } from "./components/apps-grid.js";
|
|
40
|
+
// `AppShell askAi` mounts this; exported for an app that wants the panel
|
|
41
|
+
// without the frame, or wants it somewhere other than the shell's flex row
|
|
42
|
+
// (at which point it overlays rather than pushes - see its doc comment).
|
|
43
|
+
export { ConnectAskAi } from "./components/ask-ai.js";
|
|
44
|
+
// The seven widgets an Ask AI answer can draw, and the formatter they share -
|
|
45
|
+
// exported so a partner rendering Connect's chat somewhere else entirely (a
|
|
46
|
+
// support console, an embedded assistant) gets the same table, chart and metric
|
|
47
|
+
// tiles rather than reimplementing them from the wire types.
|
|
48
|
+
export { AiTable, AiChart, AiMetrics, AiTimeline, AiDetails, AiSteps, AiSources, } from "./components/ai-widgets.js";
|
|
49
|
+
export { formatAiValue, isNumericAiFormat, aiStatusTone, AI_EMPTY_VALUE, AI_TONE_CHIP, AI_TONE_DOT, } from "./components/ai-format.js";
|
|
40
50
|
export { ConnectIntegrationsModal } from "./components/integrations-modal.js";
|
|
41
51
|
export { ConnectPlans } from "./components/plans.js";
|
|
42
52
|
export { ConnectSubscribeDialog } from "./components/subscribe-dialog.js";
|
|
@@ -49,6 +59,7 @@ export { ConnectSubscriptionGate, ConnectNoSubscriptionCard, ConnectSubscription
|
|
|
49
59
|
export { ConnectReadinessBanner, ConnectReadinessGate } from "./components/readiness.js";
|
|
50
60
|
export { ConnectAccessGate, useConnectAccessGate } from "./components/access-gate.js";
|
|
51
61
|
export { ConnectFeatureGate, useConnectFeature } from "./components/feature-gate.js";
|
|
62
|
+
export { ConnectWorkspaceTypeGate } from "./components/workspace-type-gate.js";
|
|
52
63
|
export { ConnectUsageMeter } from "./components/usage-meter.js";
|
|
53
64
|
export { ConnectPlansPage } from "./components/plans-page.js";
|
|
54
65
|
export { useConnectCheckout, buildCheckoutUrl } from "./use-checkout.js";
|
|
@@ -111,6 +111,20 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
111
111
|
const invalidateResource = useCallback((path) => {
|
|
112
112
|
cache.current.delete(`${basePath}${path}`);
|
|
113
113
|
}, [basePath]);
|
|
114
|
+
const streamResource = useCallback(async (path, body, options) => {
|
|
115
|
+
const response = await (fetchImpl ?? globalThis.fetch)(`${basePath}${path}`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
credentials: "same-origin",
|
|
118
|
+
headers: { accept: "text/event-stream", "content-type": "application/json" },
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
signal: options?.signal,
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const envelope = await response.json().catch(() => null);
|
|
124
|
+
throw new Error(envelope?.error?.message ?? `Connect bridge returned ${response.status}`);
|
|
125
|
+
}
|
|
126
|
+
return response;
|
|
127
|
+
}, [basePath, fetchImpl]);
|
|
114
128
|
const refresh = useCallback(async () => {
|
|
115
129
|
cache.current.clear();
|
|
116
130
|
try {
|
|
@@ -122,7 +136,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
122
136
|
// error, so a Login button still renders rather than the UI hanging on
|
|
123
137
|
// skeletons forever.
|
|
124
138
|
setResult({
|
|
125
|
-
session: { user: null, subscription: null, applicationRole: null },
|
|
139
|
+
session: { user: null, subscription: null, applicationRole: null, audience: null },
|
|
126
140
|
error: cause instanceof Error ? cause.message : "Could not reach Connect",
|
|
127
141
|
});
|
|
128
142
|
}
|
|
@@ -171,7 +185,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
171
185
|
credentials: "same-origin",
|
|
172
186
|
}).catch(() => { });
|
|
173
187
|
cache.current.clear();
|
|
174
|
-
setResult({ session: { user: null, subscription: null, applicationRole: null }, error: null });
|
|
188
|
+
setResult({ session: { user: null, subscription: null, applicationRole: null, audience: null }, error: null });
|
|
175
189
|
}, [basePath, fetchImpl]);
|
|
176
190
|
const value = useMemo(() => ({
|
|
177
191
|
clientId,
|
|
@@ -185,6 +199,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
185
199
|
fetchResource,
|
|
186
200
|
mutateResource,
|
|
187
201
|
invalidateResource,
|
|
202
|
+
streamResource,
|
|
188
203
|
}), [
|
|
189
204
|
clientId,
|
|
190
205
|
appUrl,
|
|
@@ -197,6 +212,7 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
197
212
|
fetchResource,
|
|
198
213
|
mutateResource,
|
|
199
214
|
invalidateResource,
|
|
215
|
+
streamResource,
|
|
200
216
|
]);
|
|
201
217
|
return _jsx(ConnectContext.Provider, { value: value, children: children });
|
|
202
218
|
}
|
|
@@ -211,6 +227,31 @@ export function useConnect() {
|
|
|
211
227
|
export function useConnectUser() {
|
|
212
228
|
return useConnect().session?.user ?? null;
|
|
213
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Whether the signed-in workspace is a one-seat personal account or a team -
|
|
232
|
+
* `null` while the session is loading or signed out.
|
|
233
|
+
*
|
|
234
|
+
* Branch on this to stop rendering team UI at a solo account: a members
|
|
235
|
+
* table, an invite button and a seat meter are all dead controls on an
|
|
236
|
+
* `"individual"` workspace, which has exactly one seat and cannot gain
|
|
237
|
+
* another. `<ConnectWorkspaceTypeGate>` is the declarative form.
|
|
238
|
+
*/
|
|
239
|
+
export function useConnectWorkspaceType() {
|
|
240
|
+
return useConnect().session?.user?.workspaceType ?? null;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Whether this account is the kind your application is sold to - `null` while
|
|
244
|
+
* the session is loading or signed out.
|
|
245
|
+
*
|
|
246
|
+
* `audience.matches === false` is a hard block, not a hint: readiness reports
|
|
247
|
+
* `audience_mismatch` and subscribing is refused. `<ConnectAccessGate>`
|
|
248
|
+
* already blocks the shell on it, so reach for this only when you need the
|
|
249
|
+
* verdict somewhere the gate does not run - a marketing page, say, or a
|
|
250
|
+
* sign-up CTA you would rather not show to an account that cannot complete it.
|
|
251
|
+
*/
|
|
252
|
+
export function useConnectAudience() {
|
|
253
|
+
return useConnect().session?.audience ?? null;
|
|
254
|
+
}
|
|
214
255
|
/** Readiness gates and the live subscription, fetched on first use. */
|
|
215
256
|
export function useConnectEntitlements() {
|
|
216
257
|
return useConnectResource("/entitlements");
|
|
@@ -193,6 +193,49 @@ export function createConnectHandler(options) {
|
|
|
193
193
|
const { user } = await whoami(request);
|
|
194
194
|
return json({ apps: await client.apps.list(user.workspaceId) });
|
|
195
195
|
},
|
|
196
|
+
/**
|
|
197
|
+
* Ask AI. The one streaming route here, and the only one that does not
|
|
198
|
+
* answer JSON - it forwards Connect's server-sent event stream through
|
|
199
|
+
* untouched so `<ConnectAskAi>` can render tokens as they arrive.
|
|
200
|
+
*
|
|
201
|
+
* Same rule as every other route: the workspace comes from the caller's
|
|
202
|
+
* own session. It matters more here than anywhere else, because the
|
|
203
|
+
* answers are *retrieved from that workspace's Context Index* - a
|
|
204
|
+
* client-supplied `workspaceId` would be a read of another tenant's
|
|
205
|
+
* documents, laundered through a chat reply.
|
|
206
|
+
*
|
|
207
|
+
* Messages are forwarded as-is (part-based `UIMessage`s), and only their
|
|
208
|
+
* shape is checked: the conversation is the caller's own, and Connect
|
|
209
|
+
* validates it again on arrival.
|
|
210
|
+
*/
|
|
211
|
+
"POST /ai/chat": async (request) => {
|
|
212
|
+
const { user } = await whoami(request);
|
|
213
|
+
const body = (await request.json().catch(() => null));
|
|
214
|
+
if (!Array.isArray(body?.messages)) {
|
|
215
|
+
throw new ConnectError({
|
|
216
|
+
status: 400,
|
|
217
|
+
code: "validation_error",
|
|
218
|
+
message: "messages is required",
|
|
219
|
+
endpoint: "POST /ai/chat",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const upstream = await client.ai.chat({
|
|
223
|
+
workspaceId: user.workspaceId,
|
|
224
|
+
messages: body.messages,
|
|
225
|
+
});
|
|
226
|
+
// `upstream.body` is piped, never buffered - awaiting it would collect
|
|
227
|
+
// the whole answer server-side and deliver it in one lump, which is the
|
|
228
|
+
// only thing streaming was for. The browser cancelling its read
|
|
229
|
+
// propagates down this pipe and stops generation upstream.
|
|
230
|
+
return new Response(upstream.body, {
|
|
231
|
+
status: upstream.status,
|
|
232
|
+
headers: {
|
|
233
|
+
"content-type": upstream.headers.get("content-type") ?? "text/event-stream",
|
|
234
|
+
"cache-control": "no-store",
|
|
235
|
+
"x-content-type-options": "nosniff",
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
},
|
|
196
239
|
"GET /integrations": async (request) => {
|
|
197
240
|
const { user } = await whoami(request);
|
|
198
241
|
return json({ toolkits: await client.integrations.list(user.workspaceId) });
|
package/dist/esm/server/http.js
CHANGED
|
@@ -124,33 +124,55 @@ export async function request(options, spec) {
|
|
|
124
124
|
// request for minutes.
|
|
125
125
|
await sleep(Math.min(lastError?.retryAfterMs ?? 250 * 2 ** (attempt - 1), 8_000));
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
// A cancellable timer rather than `AbortSignal.timeout`, because the
|
|
128
|
+
// timeout has to be *stopped* once this attempt is done reading - and a
|
|
129
|
+
// timeout signal cannot be cleared.
|
|
130
|
+
//
|
|
131
|
+
// That distinction is the whole point for a streaming endpoint. `fetch`
|
|
132
|
+
// resolves the moment the headers land, but its signal keeps governing
|
|
133
|
+
// the body: an `AbortSignal.timeout(20_000)` handed to `ai.chat` tears the
|
|
134
|
+
// SSE stream down mid-answer, twenty seconds in, on every conversation
|
|
135
|
+
// long enough to matter. Clearing the timer in `finally` bounds the
|
|
136
|
+
// request the same way it always did (a `raw` caller still gets its
|
|
137
|
+
// timeout on *reaching* Connect, and a JSON caller still gets it across
|
|
138
|
+
// the body read, which happens inside the `try`) while leaving a stream
|
|
139
|
+
// that has actually started to run as long as it needs. A caller that
|
|
140
|
+
// wants to bound the stream itself passes `spec.signal`.
|
|
141
|
+
const timeoutController = new AbortController();
|
|
142
|
+
const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
|
|
143
|
+
const signal = spec.signal
|
|
144
|
+
? AbortSignal.any([spec.signal, timeoutController.signal])
|
|
145
|
+
: timeoutController.signal;
|
|
130
146
|
try {
|
|
131
|
-
response
|
|
147
|
+
let response;
|
|
148
|
+
try {
|
|
149
|
+
response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
|
|
150
|
+
}
|
|
151
|
+
catch (cause) {
|
|
152
|
+
// A caller-supplied abort is a decision, not a fault - never retried.
|
|
153
|
+
if (spec.signal?.aborted)
|
|
154
|
+
throw cause;
|
|
155
|
+
lastError = new ConnectError({
|
|
156
|
+
status: 0,
|
|
157
|
+
code: "network_error",
|
|
158
|
+
message: `${endpoint} could not reach Connect`,
|
|
159
|
+
endpoint,
|
|
160
|
+
cause,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (response.ok) {
|
|
165
|
+
if (spec.raw)
|
|
166
|
+
return response;
|
|
167
|
+
return (await readBody(response));
|
|
168
|
+
}
|
|
169
|
+
lastError = toConnectError(response, await readBody(response), endpoint);
|
|
170
|
+
if (!RETRYABLE_STATUS.has(response.status))
|
|
171
|
+
throw lastError;
|
|
132
172
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (spec.signal?.aborted)
|
|
136
|
-
throw cause;
|
|
137
|
-
lastError = new ConnectError({
|
|
138
|
-
status: 0,
|
|
139
|
-
code: "network_error",
|
|
140
|
-
message: `${endpoint} could not reach Connect`,
|
|
141
|
-
endpoint,
|
|
142
|
-
cause,
|
|
143
|
-
});
|
|
144
|
-
continue;
|
|
173
|
+
finally {
|
|
174
|
+
clearTimeout(timer);
|
|
145
175
|
}
|
|
146
|
-
if (response.ok) {
|
|
147
|
-
if (spec.raw)
|
|
148
|
-
return response;
|
|
149
|
-
return (await readBody(response));
|
|
150
|
-
}
|
|
151
|
-
lastError = toConnectError(response, await readBody(response), endpoint);
|
|
152
|
-
if (!RETRYABLE_STATUS.has(response.status))
|
|
153
|
-
throw lastError;
|
|
154
176
|
}
|
|
155
177
|
throw lastError;
|
|
156
178
|
}
|
|
@@ -42,4 +42,45 @@ export class EntitlementsResource {
|
|
|
42
42
|
const result = await this.getSubscription(workspaceId);
|
|
43
43
|
return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* One page of the workspaces subscribed to your application, newest first.
|
|
47
|
+
*
|
|
48
|
+
* The inverse of `getSubscription`, which asks about one workspace you
|
|
49
|
+
* already know. This asks who your subscribers *are* - what a partner
|
|
50
|
+
* dashboard, an MRR total or a churn report is built on.
|
|
51
|
+
*
|
|
52
|
+
* Rate-limited far more tightly than the per-workspace read (10/min per
|
|
53
|
+
* key, against 60): it walks your whole subscriber base and is a reporting
|
|
54
|
+
* call, not a per-request gate. Use `getSubscription` in a hot path.
|
|
55
|
+
*
|
|
56
|
+
* Your application is resolved from the API key, so there is no parameter
|
|
57
|
+
* for it and no way to ask about anyone else's.
|
|
58
|
+
*/
|
|
59
|
+
listSubscribers(options = {}) {
|
|
60
|
+
return request(this.transport, {
|
|
61
|
+
method: "GET",
|
|
62
|
+
path: "/subscriptions",
|
|
63
|
+
query: { status: options.status, before: options.before, limit: options.limit },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Every subscriber, paged through for you.
|
|
68
|
+
*
|
|
69
|
+
* An async generator rather than an array: a large subscriber base is
|
|
70
|
+
* exactly where an accidental full materialisation hurts, and `for await`
|
|
71
|
+
* reads the same as a loop over one page.
|
|
72
|
+
*/
|
|
73
|
+
async *iterateSubscribers(options = {}) {
|
|
74
|
+
let before;
|
|
75
|
+
do {
|
|
76
|
+
const page = await this.listSubscribers({
|
|
77
|
+
status: options.status,
|
|
78
|
+
limit: options.pageSize,
|
|
79
|
+
before,
|
|
80
|
+
});
|
|
81
|
+
for (const item of page.items)
|
|
82
|
+
yield item;
|
|
83
|
+
before = page.nextBefore ?? undefined;
|
|
84
|
+
} while (before);
|
|
85
|
+
}
|
|
45
86
|
}
|
|
@@ -67,6 +67,7 @@ export class IdentityResource {
|
|
|
67
67
|
user: wire.user,
|
|
68
68
|
subscription: wire.subscription,
|
|
69
69
|
applicationRole: wire.applicationRole,
|
|
70
|
+
audience: wire.audience,
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
@@ -137,6 +138,7 @@ export class IdentityResource {
|
|
|
137
138
|
user: wire.user,
|
|
138
139
|
subscription: wire.subscription,
|
|
139
140
|
applicationRole: wire.applicationRole,
|
|
141
|
+
audience: wire.audience,
|
|
140
142
|
sessionExpiresAt: wire.session_expires_at,
|
|
141
143
|
};
|
|
142
144
|
}
|
package/dist/theme.css
CHANGED
|
@@ -110,3 +110,90 @@ body {
|
|
|
110
110
|
background: var(--background);
|
|
111
111
|
color: var(--foreground);
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/* ---------------------------------------------------------------------------
|
|
115
|
+
* Ask AI: Streamdown token compatibility.
|
|
116
|
+
*
|
|
117
|
+
* `ConnectAskAi` renders markdown with Streamdown, which paints using
|
|
118
|
+
* shadcn/ui's token names. HeroUI defines a different set - four happen to line
|
|
119
|
+
* up already (`--background`, `--foreground`, `--border`, `--radius`), and the
|
|
120
|
+
* rest are aliased here so an answer inherits the platform's own theme instead
|
|
121
|
+
* of rendering with missing backgrounds and borders.
|
|
122
|
+
*
|
|
123
|
+
* This lives in the shipped theme rather than in each partner's CSS because it
|
|
124
|
+
* is a fact about how these two libraries name colours, not a design choice
|
|
125
|
+
* anyone should have to rediscover.
|
|
126
|
+
*
|
|
127
|
+
* Additive - HeroUI uses none of these names - with exactly one real collision,
|
|
128
|
+
* handled below.
|
|
129
|
+
* ------------------------------------------------------------------------ */
|
|
130
|
+
:root,
|
|
131
|
+
.light,
|
|
132
|
+
.default,
|
|
133
|
+
[data-theme="light"],
|
|
134
|
+
[data-theme="default"],
|
|
135
|
+
.dark,
|
|
136
|
+
[data-theme="dark"] {
|
|
137
|
+
--card: var(--surface);
|
|
138
|
+
--card-foreground: var(--surface-foreground);
|
|
139
|
+
--input: var(--border);
|
|
140
|
+
--primary: var(--accent);
|
|
141
|
+
--primary-foreground: var(--accent-foreground);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ---------------------------------------------------------------------------
|
|
145
|
+
* The collision: `--muted` is a *surface* in shadcn (code-block fills, table
|
|
146
|
+
* header rows) and a *text colour* in HeroUI. Reading it as HeroUI's would
|
|
147
|
+
* paint every code block mid-grey and leave the text on it unreadable.
|
|
148
|
+
*
|
|
149
|
+
* So it is shadowed - but only inside the answer body, and only after the host
|
|
150
|
+
* element has captured HeroUI's value under shadcn's name for it. Splitting it
|
|
151
|
+
* across two elements is what makes this safe: `--muted-foreground` is computed
|
|
152
|
+
* on `.connect-ai-host`, where `--muted` still means HeroUI's text grey, and
|
|
153
|
+
* inherits that resolved colour downward. Both declarations on one element
|
|
154
|
+
* would resolve `var(--muted)` against the new value and lose the original.
|
|
155
|
+
* ------------------------------------------------------------------------ */
|
|
156
|
+
.connect-ai-host {
|
|
157
|
+
--muted-foreground: var(--muted);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.connect-ai-host .connect-ai-body {
|
|
161
|
+
--muted: var(--surface-secondary);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ---------------------------------------------------------------------------
|
|
165
|
+
* The Ask AI "thinking" shimmer - a highlight sweeping through the label while
|
|
166
|
+
* the model works, rather than a spinner.
|
|
167
|
+
*
|
|
168
|
+
* A gradient clipped to the glyphs, so the motion is *in* the word instead of
|
|
169
|
+
* beside it. This is the one state where the panel has nothing at all to show:
|
|
170
|
+
* retrieval and the model's first thought take a second or two, and a static
|
|
171
|
+
* "Thinking" reads as frozen.
|
|
172
|
+
* ------------------------------------------------------------------------ */
|
|
173
|
+
.connect-ai-shimmer {
|
|
174
|
+
background: linear-gradient(100deg, var(--muted) 32%, var(--accent) 48%, var(--muted) 64%);
|
|
175
|
+
background-size: 220% 100%;
|
|
176
|
+
-webkit-background-clip: text;
|
|
177
|
+
background-clip: text;
|
|
178
|
+
color: transparent;
|
|
179
|
+
animation: connect-ai-shimmer 1.7s linear infinite;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@keyframes connect-ai-shimmer {
|
|
183
|
+
from {
|
|
184
|
+
background-position: 180% 0;
|
|
185
|
+
}
|
|
186
|
+
to {
|
|
187
|
+
background-position: -40% 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* Motion is the whole affordance here, so with it suppressed the label has to
|
|
192
|
+
go back to being a legible static word rather than a transparent one. */
|
|
193
|
+
@media (prefers-reduced-motion: reduce) {
|
|
194
|
+
.connect-ai-shimmer {
|
|
195
|
+
background: none;
|
|
196
|
+
color: var(--muted);
|
|
197
|
+
animation: none;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -5,6 +5,12 @@ export type ConnectAccessGateState = "loading"
|
|
|
5
5
|
| "signed_out"
|
|
6
6
|
/** `workspace_suspended` - a platform admin took this workspace offline. Hard block, no dismiss. */
|
|
7
7
|
| "suspended"
|
|
8
|
+
/**
|
|
9
|
+
* `audience_mismatch` - this application serves individual accounts and the
|
|
10
|
+
* workspace is a business, or the reverse. Hard block with nothing to
|
|
11
|
+
* offer: `Workspace.type` is fixed at sign-up.
|
|
12
|
+
*/
|
|
13
|
+
| "audience_mismatch"
|
|
8
14
|
/** `subscription_missing` - nothing to subscribe to yet. Hard block. */
|
|
9
15
|
| "no_subscription"
|
|
10
16
|
/** `onboarding_approval_pending` - waiting on a reviewer. Hard block, no action to offer. */
|
|
@@ -33,9 +39,17 @@ export interface ConnectAccessGateResult {
|
|
|
33
39
|
* `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
|
|
34
40
|
* is a priority ordering over the same data, not a second opinion about it.
|
|
35
41
|
*
|
|
36
|
-
* Priority, first match wins: `suspended` > `
|
|
37
|
-
* `onboarding_pending` > any other blocking code
|
|
38
|
-
* `payment_retry` (advisory - `children` still render) >
|
|
42
|
+
* Priority, first match wins: `suspended` > `audience_mismatch` >
|
|
43
|
+
* `no_subscription` > `onboarding_pending` > any other blocking code
|
|
44
|
+
* (`blocked`) > `payment_retry` (advisory - `children` still render) >
|
|
45
|
+
* `clear`.
|
|
46
|
+
*
|
|
47
|
+
* `audience_mismatch` outranks `no_subscription` deliberately, and the
|
|
48
|
+
* ordering is the whole point of it: a workspace of the wrong account type
|
|
49
|
+
* carries BOTH blockers at once, and showing the subscription one first sent
|
|
50
|
+
* the customer to a plan picker whose subscribe call
|
|
51
|
+
* (`ActivateSubscriptionUseCase`) rejects them for the audience anyway. The
|
|
52
|
+
* account type is the reason; the missing subscription is a consequence.
|
|
39
53
|
*/
|
|
40
54
|
export declare function useConnectAccessGate(): ConnectAccessGateResult;
|
|
41
55
|
export interface ConnectAccessGateProps {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AiValue, AiValueFormat } from "../../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The one place a model-supplied value becomes a string.
|
|
4
|
+
*
|
|
5
|
+
* Ask AI's presentation tools send raw values with a `format` tag beside them
|
|
6
|
+
* rather than pre-formatted strings (see `AiValueFormat`), and this is the half
|
|
7
|
+
* that makes that worth doing: an amount renders in the viewer's own locale, a
|
|
8
|
+
* table sorts numerically, and the same figure reads identically on a chart
|
|
9
|
+
* axis, in a tooltip and in a metric tile.
|
|
10
|
+
*
|
|
11
|
+
* Every branch is total. A value that cannot be formatted the way it was
|
|
12
|
+
* tagged - a `date` that is not a date, a `currency` that is not a number -
|
|
13
|
+
* falls back to its plain string rather than throwing inside a render.
|
|
14
|
+
*/
|
|
15
|
+
/** Renders in place of a missing value - an em dash, not the word "null". */
|
|
16
|
+
export declare const AI_EMPTY_VALUE = "\u2014";
|
|
17
|
+
export declare function formatAiValue(value: AiValue, format?: AiValueFormat): string;
|
|
18
|
+
/** Numeric columns read far better right-aligned; everything else stays with the text. */
|
|
19
|
+
export declare function isNumericAiFormat(format?: AiValueFormat): boolean;
|
|
20
|
+
export type AiStatusTone = "neutral" | "positive" | "warning" | "danger";
|
|
21
|
+
export declare function aiStatusTone(value: AiValue): AiStatusTone;
|
|
22
|
+
/** HeroUI semantic tokens only, so these inherit whatever theme the host defined. */
|
|
23
|
+
export declare const AI_TONE_CHIP: Record<AiStatusTone, string>;
|
|
24
|
+
export declare const AI_TONE_DOT: Record<AiStatusTone, string>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { AiChartInput, AiDetailsInput, AiMetricsInput, AiSourcesInput, AiStepsInput, AiTableInput, AiTimelineInput } from "../../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* `showTable`. Sorting and CSV are what a table is *for* - once an answer is
|
|
5
|
+
* tabular the next thing anyone wants is to reorder it or take it elsewhere,
|
|
6
|
+
* and a markdown table can do neither. Both work off the raw values, so
|
|
7
|
+
* "₹12,500" sorts as 12500 and the export carries numbers a spreadsheet can add.
|
|
8
|
+
*/
|
|
9
|
+
export declare function AiTable({ title, columns, rows, caption }: Readonly<AiTableInput>): import("react").JSX.Element;
|
|
10
|
+
/**
|
|
11
|
+
* `showChart`. Cartesian kinds are drawn as SVG against a fixed viewBox and
|
|
12
|
+
* scaled by the browser, which is why there is no resize observer here: the
|
|
13
|
+
* chart is resolution-independent, so "responsive" costs nothing at runtime.
|
|
14
|
+
*
|
|
15
|
+
* `pie` is deliberately *not* a pie. In a 400px column a pie plus its legend
|
|
16
|
+
* is smaller than the labels it needs, and reading angles is worse than
|
|
17
|
+
* reading lengths at any size - so proportions render as a labelled bar list,
|
|
18
|
+
* which answers the same question and stays legible.
|
|
19
|
+
*/
|
|
20
|
+
export declare function AiChart({ title, kind, xLabel, yLabel, valueFormat, series, caption }: Readonly<AiChartInput>): import("react").JSX.Element;
|
|
21
|
+
/**
|
|
22
|
+
* `showMetrics`. A delta is coloured by `deltaMeaning`, not by its sign -
|
|
23
|
+
* churn rising is bad and revenue rising is good, and painting both green
|
|
24
|
+
* because the arrow points up is exactly the confidently-wrong signal a
|
|
25
|
+
* dashboard must never send. The arrow still shows direction, so meaning and
|
|
26
|
+
* direction stay two separate channels.
|
|
27
|
+
*/
|
|
28
|
+
export declare function AiMetrics({ title, metrics }: Readonly<AiMetricsInput>): import("react").JSX.Element;
|
|
29
|
+
/** `showTimeline`: dated events, oldest first, on a single rail. */
|
|
30
|
+
export declare function AiTimeline({ title, events }: Readonly<AiTimelineInput>): import("react").JSX.Element;
|
|
31
|
+
/** `showDetails`: one entity's fields, as a definition list rather than a one-row table. */
|
|
32
|
+
export declare function AiDetails({ title, fields }: Readonly<AiDetailsInput>): import("react").JSX.Element;
|
|
33
|
+
/** `showSteps`: an ordered procedure. `renderDetail` is how the panel feeds each step back through its markdown renderer. */
|
|
34
|
+
export declare function AiSteps({ title, steps, renderDetail, }: Readonly<AiStepsInput & {
|
|
35
|
+
renderDetail?: (markdown: string) => ReactNode;
|
|
36
|
+
}>): import("react").JSX.Element;
|
|
37
|
+
/**
|
|
38
|
+
* `showSources`: what a grounded answer actually rested on. The point of a
|
|
39
|
+
* citation is that it can be opened, so a source with a `uri` is a link and one
|
|
40
|
+
* without is still a named row - never a marker with nothing behind it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function AiSources({ sources }: Readonly<AiSourcesInput>): import("react").JSX.Element;
|