@cosmoops/connect-sdk 0.3.0 → 0.6.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 +125 -0
- package/README.md +215 -6
- package/dist/cjs/cli/doctor.js +3 -3
- package/dist/cjs/cli/init.js +3 -3
- package/dist/cjs/client/components/access-gate.js +162 -0
- package/dist/cjs/client/components/account-sessions.js +21 -19
- package/dist/cjs/client/components/app-shell.js +157 -0
- package/dist/cjs/client/components/apps-grid.js +2 -1
- package/dist/cjs/client/components/integrations-modal.js +95 -0
- package/dist/cjs/client/components/motion.js +27 -0
- package/dist/cjs/client/components/nav.js +97 -0
- package/dist/cjs/client/components/plans.js +39 -37
- package/dist/cjs/client/components/readiness.js +3 -1
- package/dist/cjs/client/components/side-nav.js +58 -0
- package/dist/cjs/client/components/subscription.js +2 -1
- package/dist/cjs/client/components/surfaces.js +54 -0
- package/dist/cjs/client/components/usage-meter.js +3 -2
- package/dist/cjs/client/components/user-menu.js +30 -3
- package/dist/cjs/client/index.js +29 -1
- package/dist/cjs/client/provider.js +54 -2
- package/dist/cjs/client/use-active-path.js +77 -0
- package/dist/cjs/errors.js +1 -1
- package/dist/cjs/next/index.js +96 -15
- package/dist/cjs/server/client.js +10 -2
- package/dist/cjs/server/handler.js +89 -6
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/server/resources/analytics.js +66 -0
- package/dist/cjs/server/resources/identity.js +1 -1
- package/dist/cjs/server/resources/integrations.js +66 -0
- package/dist/cjs/server/resources/sessions.js +1 -1
- package/dist/cjs/server/webhooks.js +127 -0
- package/dist/esm/cli/doctor.js +3 -3
- package/dist/esm/cli/init.js +3 -3
- package/dist/esm/client/components/access-gate.js +158 -0
- package/dist/esm/client/components/account-sessions.js +21 -19
- package/dist/esm/client/components/app-shell.js +154 -0
- package/dist/esm/client/components/apps-grid.js +2 -1
- package/dist/esm/client/components/integrations-modal.js +92 -0
- package/dist/esm/client/components/motion.js +21 -0
- package/dist/esm/client/components/nav.js +89 -0
- package/dist/esm/client/components/plans.js +39 -37
- package/dist/esm/client/components/readiness.js +3 -1
- package/dist/esm/client/components/side-nav.js +53 -0
- package/dist/esm/client/components/subscription.js +2 -1
- package/dist/esm/client/components/surfaces.js +51 -0
- package/dist/esm/client/components/usage-meter.js +3 -2
- package/dist/esm/client/components/user-menu.js +31 -4
- package/dist/esm/client/index.js +15 -1
- package/dist/esm/client/provider.js +53 -2
- package/dist/esm/client/use-active-path.js +74 -0
- package/dist/esm/errors.js +1 -1
- package/dist/esm/next/index.js +95 -15
- package/dist/esm/server/client.js +10 -2
- package/dist/esm/server/handler.js +89 -6
- package/dist/esm/server/index.js +5 -0
- package/dist/esm/server/resources/analytics.js +62 -0
- package/dist/esm/server/resources/identity.js +1 -1
- package/dist/esm/server/resources/integrations.js +62 -0
- package/dist/esm/server/resources/sessions.js +1 -1
- package/dist/esm/server/webhooks.js +123 -0
- package/dist/theme.css +28 -0
- package/dist/types/client/components/access-gate.d.ts +79 -0
- package/dist/types/client/components/app-shell.d.ts +115 -0
- package/dist/types/client/components/integrations-modal.d.ts +21 -0
- package/dist/types/client/components/motion.d.ts +22 -0
- package/dist/types/client/components/nav.d.ts +110 -0
- package/dist/types/client/components/side-nav.d.ts +31 -0
- package/dist/types/client/components/surfaces.d.ts +51 -0
- package/dist/types/client/index.d.ts +12 -1
- package/dist/types/client/provider.d.ts +38 -3
- package/dist/types/client/use-active-path.d.ts +8 -0
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/next/index.d.ts +40 -0
- package/dist/types/server/client.d.ts +8 -2
- package/dist/types/server/index.d.ts +7 -0
- package/dist/types/server/resources/analytics.d.ts +31 -0
- package/dist/types/server/resources/identity.d.ts +12 -2
- package/dist/types/server/resources/integrations.d.ts +40 -0
- package/dist/types/server/webhooks.d.ts +67 -0
- package/dist/types/types.d.ts +182 -1
- package/package.json +10 -4
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* Composio-hosted third-party connections (Google Calendar, Slack,
|
|
4
|
+
* Twitter/X, ...) an organization has connected. Connect proxies every call
|
|
5
|
+
* here - your service never sees or holds the underlying OAuth token, which
|
|
6
|
+
* stays with Composio.
|
|
7
|
+
*/
|
|
8
|
+
export class IntegrationsResource {
|
|
9
|
+
transport;
|
|
10
|
+
constructor(transport) {
|
|
11
|
+
this.transport = transport;
|
|
12
|
+
}
|
|
13
|
+
/** Enabled toolkits and this organization's connection status for each. */
|
|
14
|
+
async list(organizationId) {
|
|
15
|
+
const result = await request(this.transport, {
|
|
16
|
+
method: "GET",
|
|
17
|
+
path: "/integrations",
|
|
18
|
+
query: { organizationId },
|
|
19
|
+
});
|
|
20
|
+
// `?? []` for the same reason `AppsResource.list` has it - a collection
|
|
21
|
+
// every caller immediately maps or measures.
|
|
22
|
+
return result.toolkits ?? [];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Starts a Composio-hosted connection for one toolkit. The returned
|
|
26
|
+
* `redirectUrl` is where the browser completes the provider's OAuth
|
|
27
|
+
* screen, then Composio bounces it back to `returnUrl`.
|
|
28
|
+
*
|
|
29
|
+
* **The connection is not active when this returns.** Treat the toolkit
|
|
30
|
+
* as `pending` until a later `list()` call reports `active` - only
|
|
31
|
+
* Connect's own webhook from Composio confirms that, never the redirect
|
|
32
|
+
* itself. Reconnecting a disconnected/failed toolkit calls this same
|
|
33
|
+
* method - there is no separate reconnect.
|
|
34
|
+
*
|
|
35
|
+
* Meant to be called on behalf of a signed-in org admin (this is what the
|
|
36
|
+
* bridge's fixed `POST /integrations/connect` route does), not as a bare
|
|
37
|
+
* background action with no admin present to complete the OAuth screen.
|
|
38
|
+
*/
|
|
39
|
+
async initiateConnect(organizationId, toolkitSlug, returnUrl) {
|
|
40
|
+
return request(this.transport, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
path: "/integrations/connect",
|
|
43
|
+
json: { organizationId, toolkitSlug, returnUrl },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Idempotent - disconnecting a toolkit that was never connected, or is already disconnected, is a success. */
|
|
47
|
+
async disconnect(organizationId, toolkitSlug) {
|
|
48
|
+
return request(this.transport, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
path: "/integrations/disconnect",
|
|
51
|
+
json: { organizationId, toolkitSlug },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Calls a Composio action against this organization's connected account for the named toolkit. */
|
|
55
|
+
async execute(organizationId, input) {
|
|
56
|
+
return request(this.transport, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
path: "/integrations/actions/execute",
|
|
59
|
+
json: { organizationId, ...input },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -69,7 +69,7 @@ export class SessionsResource {
|
|
|
69
69
|
throw new ConnectError({
|
|
70
70
|
status: 0,
|
|
71
71
|
code: "missing_client_id",
|
|
72
|
-
message: "sessions.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's
|
|
72
|
+
message: "sessions.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Integration tab, under Connect client.",
|
|
73
73
|
endpoint: "sessions",
|
|
74
74
|
});
|
|
75
75
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* How far out of date a timestamp may be. Five minutes, the same value
|
|
4
|
+
* Connect signs against and the same one Stripe uses - so a backend that
|
|
5
|
+
* already verifies Stripe webhooks needs no new assumptions.
|
|
6
|
+
*/
|
|
7
|
+
const TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
8
|
+
const SIGNATURE_HEADER = "connect-signature";
|
|
9
|
+
/**
|
|
10
|
+
* Verifies a `Connect-Signature` header against the raw request body.
|
|
11
|
+
*
|
|
12
|
+
* Exported on its own for anyone wiring this into a framework
|
|
13
|
+
* `createConnectWebhookHandler` does not cover. The **raw** body matters:
|
|
14
|
+
* verify before parsing, and never re-serialise - `JSON.stringify(await
|
|
15
|
+
* req.json())` produces a different string whenever key order or number
|
|
16
|
+
* formatting differs, and the signature will not match.
|
|
17
|
+
*
|
|
18
|
+
* Returns a boolean and never explains which check failed. Reporting "bad
|
|
19
|
+
* timestamp" separately from "bad signature" tells whoever is probing which
|
|
20
|
+
* half of a forgery to keep working on.
|
|
21
|
+
*/
|
|
22
|
+
export function verifyConnectWebhook(rawBody, signatureHeader, secret, now = new Date()) {
|
|
23
|
+
if (!signatureHeader)
|
|
24
|
+
return false;
|
|
25
|
+
const parts = new Map(signatureHeader
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((part) => part.trim().split("="))
|
|
28
|
+
.filter((pair) => pair.length === 2)
|
|
29
|
+
.map(([key, value]) => [key, value]));
|
|
30
|
+
const timestamp = Number(parts.get("t"));
|
|
31
|
+
const presented = parts.get("v1");
|
|
32
|
+
if (!presented || !Number.isFinite(timestamp))
|
|
33
|
+
return false;
|
|
34
|
+
// Absolute difference: a timestamp from the future is as much a sign of a
|
|
35
|
+
// forgery as a stale one, and accepting it would let somebody mint a
|
|
36
|
+
// signature that stays valid indefinitely.
|
|
37
|
+
const nowSeconds = Math.floor(now.getTime() / 1000);
|
|
38
|
+
if (Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS)
|
|
39
|
+
return false;
|
|
40
|
+
const expected = createHmac("sha256", secret)
|
|
41
|
+
.update(`${timestamp}.${rawBody}`)
|
|
42
|
+
.digest("hex");
|
|
43
|
+
// `timingSafeEqual` throws on a length mismatch, and a wrong-length hex
|
|
44
|
+
// string is exactly what a probe looks like - so lengths are compared
|
|
45
|
+
// first and only equal-length buffers reach it.
|
|
46
|
+
if (presented.length !== expected.length)
|
|
47
|
+
return false;
|
|
48
|
+
return timingSafeEqual(Buffer.from(presented, "utf8"), Buffer.from(expected, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A ready-made `POST` route for Connect's webhooks.
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* export const POST = createConnectWebhookHandler({
|
|
55
|
+
* secret: process.env.CONNECT_WEBHOOK_SECRET!,
|
|
56
|
+
* on: {
|
|
57
|
+
* "organization.deletion_scheduled": ({ organizationId, purgeAfter }) =>
|
|
58
|
+
* freeze(organizationId, purgeAfter),
|
|
59
|
+
* "organization.deletion_cancelled": ({ organizationId }) => unfreeze(organizationId),
|
|
60
|
+
* "organization.deleted": ({ organizationId }) => purge(organizationId),
|
|
61
|
+
* },
|
|
62
|
+
* })
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* Three behaviours worth knowing, because each of them is a decision rather
|
|
66
|
+
* than an accident:
|
|
67
|
+
*
|
|
68
|
+
* **An unverified request gets a 401 and nothing else happens.** No parsing,
|
|
69
|
+
* no logging of the body, no handler. The endpoint is a public URL, so
|
|
70
|
+
* anything reachable before the signature check is reachable by anyone.
|
|
71
|
+
*
|
|
72
|
+
* **An unhandled event type is a 200.** Connect adds event types over time,
|
|
73
|
+
* and answering 4xx to one you have not written code for would dead-letter
|
|
74
|
+
* it immediately and, after enough of them, disable your endpoint. Silence
|
|
75
|
+
* is the correct answer to "we do not care about this one".
|
|
76
|
+
*
|
|
77
|
+
* **A throwing handler produces a 500, on purpose.** That is what makes
|
|
78
|
+
* Connect retry. Swallowing the error would acknowledge an event you failed
|
|
79
|
+
* to process, and the retry ladder is the only thing standing between a
|
|
80
|
+
* transient failure and a permanently missed deletion.
|
|
81
|
+
*
|
|
82
|
+
* Delivery is at-least-once and unordered. Dedupe on `envelope.id`, and
|
|
83
|
+
* reconcile against the readiness endpoint rather than trusting arrival
|
|
84
|
+
* order - the pull channel is authoritative, this one is fast.
|
|
85
|
+
*/
|
|
86
|
+
export function createConnectWebhookHandler(options) {
|
|
87
|
+
return async function POST(request) {
|
|
88
|
+
// Read as text and verify before parsing. `JSON.stringify(await
|
|
89
|
+
// request.json())` is not the same string that was signed - key order and
|
|
90
|
+
// number formatting both differ - so a handler that parses first can
|
|
91
|
+
// never verify.
|
|
92
|
+
const rawBody = await request.text();
|
|
93
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
94
|
+
if (!verifyConnectWebhook(rawBody, signature, options.secret)) {
|
|
95
|
+
return new Response("Invalid signature", { status: 401 });
|
|
96
|
+
}
|
|
97
|
+
let envelope;
|
|
98
|
+
try {
|
|
99
|
+
envelope = JSON.parse(rawBody);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Signed by us and still unparseable is not something a retry fixes.
|
|
103
|
+
return new Response("Malformed payload", { status: 400 });
|
|
104
|
+
}
|
|
105
|
+
const handler = options.on[envelope.type];
|
|
106
|
+
if (!handler)
|
|
107
|
+
return new Response(null, { status: 200 });
|
|
108
|
+
try {
|
|
109
|
+
await handler(envelope.data, envelope);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (options.onError) {
|
|
113
|
+
await options.onError(error, envelope);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
console.error(`Connect webhook ${envelope.type} (${envelope.id}) failed:`, error);
|
|
117
|
+
}
|
|
118
|
+
// Deliberately a 500: it is what makes Connect retry. See the doc above.
|
|
119
|
+
return new Response("Handler failed", { status: 500 });
|
|
120
|
+
}
|
|
121
|
+
return new Response(null, { status: 200 });
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/theme.css
CHANGED
|
@@ -50,6 +50,31 @@
|
|
|
50
50
|
--separator: oklch(92% 0.0067 145.67);
|
|
51
51
|
--surface: oklch(100% 0.0034 145.67);
|
|
52
52
|
--surface-secondary: oklch(94% 0.0067 145.67);
|
|
53
|
+
/*
|
|
54
|
+
* The neutral ramp every `secondary`/`tertiary` Button and every default
|
|
55
|
+
* Chip actually paints with - `.button--tertiary` resolves to
|
|
56
|
+
* `--button-bg: var(--default)`, not to `--surface`.
|
|
57
|
+
*
|
|
58
|
+
* Defining these is the difference between "the palette below is the
|
|
59
|
+
* palette" and it merely being most of it. Left out, `--default` and
|
|
60
|
+
* `--surface-tertiary` keep @heroui/styles' stock values, which sit at
|
|
61
|
+
* hue 286 - a cool blue-grey - while every token below is hue 145.67, a
|
|
62
|
+
* warm green-grey. The two ramps are near-neutral enough that neither
|
|
63
|
+
* reads as "blue" or "green" on its own, and precisely similar enough
|
|
64
|
+
* that a grey button next to a grey panel reads as an accident. In dark
|
|
65
|
+
* mode they also disagreed on lightness (27.4% against a 25% panel), so
|
|
66
|
+
* a tertiary button sat a step *above* the panel containing it.
|
|
67
|
+
*
|
|
68
|
+
* Lightness is kept at HeroUI's stock values so component relationships
|
|
69
|
+
* are unchanged; only the hue is brought onto the ramp.
|
|
70
|
+
*
|
|
71
|
+
* Everything ending in `-soft`, plus `--default-hover`/`--default-foreground`,
|
|
72
|
+
* is derived by @heroui/styles via `color-mix` from the base token, so
|
|
73
|
+
* those follow from these and must NOT be redefined here - hardcoding one
|
|
74
|
+
* pins it and stops it tracking a partner's override.
|
|
75
|
+
*/
|
|
76
|
+
--default: oklch(94% 0.0067 145.67);
|
|
77
|
+
--surface-tertiary: oklch(93.73% 0.0067 145.67);
|
|
53
78
|
--danger: oklch(65.32% 0.2359 12.76);
|
|
54
79
|
--danger-foreground: oklch(99.11% 0 0);
|
|
55
80
|
--warning: oklch(79% 0.16 76);
|
|
@@ -70,6 +95,9 @@
|
|
|
70
95
|
--separator: oklch(26% 0.0067 145.67);
|
|
71
96
|
--surface: oklch(21% 0.0067 145.67);
|
|
72
97
|
--surface-secondary: oklch(25% 0.0067 145.67);
|
|
98
|
+
/* See the light block above. */
|
|
99
|
+
--default: oklch(27.4% 0.0067 145.67);
|
|
100
|
+
--surface-tertiary: oklch(27.21% 0.0067 145.67);
|
|
73
101
|
--danger: oklch(65.32% 0.2359 12.76);
|
|
74
102
|
--danger-foreground: oklch(99.11% 0 0);
|
|
75
103
|
--warning: oklch(79% 0.16 76);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ReadinessBlocker } from "../../types.js";
|
|
3
|
+
export type ConnectAccessGateState = "loading"
|
|
4
|
+
/** No signed-in user - not this component's concern, see the doc comment on `ConnectAccessGate`. */
|
|
5
|
+
| "signed_out"
|
|
6
|
+
/** `organization_suspended` - a platform admin took this workspace offline. Hard block, no dismiss. */
|
|
7
|
+
| "suspended"
|
|
8
|
+
/** `subscription_missing` - nothing to subscribe to yet. Hard block. */
|
|
9
|
+
| "no_subscription"
|
|
10
|
+
/** `onboarding_approval_pending` - waiting on a reviewer. Hard block, no action to offer. */
|
|
11
|
+
| "onboarding_pending"
|
|
12
|
+
/** Any other `blocking` readiness code (verification, audience, seats, ...). Hard block. */
|
|
13
|
+
| "blocked"
|
|
14
|
+
/** `payment_retry_pending` - an automatic dunning retry is scheduled. Not blocking - `children` still render. */
|
|
15
|
+
| "payment_retry"
|
|
16
|
+
/** Nothing outstanding. */
|
|
17
|
+
| "clear";
|
|
18
|
+
export interface ConnectAccessGateResult {
|
|
19
|
+
state: ConnectAccessGateState;
|
|
20
|
+
/** The blocker driving a hard-block state (`suspended`/`no_subscription`/`onboarding_pending`/`blocked`). `null` otherwise. */
|
|
21
|
+
blocker: ReadinessBlocker | null;
|
|
22
|
+
/** The `payment_retry_pending` blocker, only when `state === "payment_retry"`. Carries `graceEndsAt`. */
|
|
23
|
+
paymentRetry: ReadinessBlocker | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The single composed answer to "can this workspace use my app right now,
|
|
27
|
+
* and if not, which of the four things is it": choose a plan, wait on
|
|
28
|
+
* approval, resolve an overdue payment, or - a suspended workspace - none of
|
|
29
|
+
* the above, because nothing short of a platform admin fixes that one.
|
|
30
|
+
*
|
|
31
|
+
* Reuses the existing single sources of truth rather than re-deriving them:
|
|
32
|
+
* `blockingBlockers()` (`helpers.ts`) for severity, the same readiness read
|
|
33
|
+
* `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
|
|
34
|
+
* is a priority ordering over the same data, not a second opinion about it.
|
|
35
|
+
*
|
|
36
|
+
* Priority, first match wins: `suspended` > `no_subscription` >
|
|
37
|
+
* `onboarding_pending` > any other blocking code (`blocked`) >
|
|
38
|
+
* `payment_retry` (advisory - `children` still render) > `clear`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function useConnectAccessGate(): ConnectAccessGateResult;
|
|
41
|
+
export interface ConnectAccessGateProps {
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
/** Replaces the default card for every hard-block state (`suspended`/`no_subscription`/`onboarding_pending`/`blocked`) uniformly. For a different card per state, call `useConnectAccessGate()` directly instead. */
|
|
44
|
+
fallback?: ReactNode;
|
|
45
|
+
/** Shown while the first read is in flight. */
|
|
46
|
+
loading?: ReactNode;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wrap your app's content once - typically in your root layout, alongside
|
|
51
|
+
* (not instead of) your own header/nav - and it renders exactly one of: the
|
|
52
|
+
* hard-block card for a suspended workspace, one with no plan, or one
|
|
53
|
+
* waiting on approval; your `children` plus a dismissible payment-due banner
|
|
54
|
+
* during an automatic dunning retry; or just `children` once everything is
|
|
55
|
+
* clear.
|
|
56
|
+
*
|
|
57
|
+
* **Do not wrap your own plans/pricing page with this.** The `no_subscription`
|
|
58
|
+
* card's button sends someone to Connect's subscribe page directly (see
|
|
59
|
+
* `GateCard`'s doc comment on why - it used to link to your in-app `/plans`
|
|
60
|
+
* instead, which is a dead end once this gate hides that very page too), but
|
|
61
|
+
* a customer can still navigate to your plans page on their own (a nav link,
|
|
62
|
+
* a bookmark) and must actually see it there. Since the gate is mounted once
|
|
63
|
+
* around every route, carving out that exception is your router's job, not
|
|
64
|
+
* a prop on this component: check the current path in your layout/shell and
|
|
65
|
+
* skip wrapping `children` in `<ConnectAccessGate>` for that one route.
|
|
66
|
+
*
|
|
67
|
+
* **Signed-out renders `children`, not a fallback** - deliberately different
|
|
68
|
+
* from `ConnectReadinessGate`/`ConnectSubscriptionGate`, which show nothing
|
|
69
|
+
* until signed in. None of the four states this component composes is a
|
|
70
|
+
* property of a signed-out visitor, and a component mounted around your
|
|
71
|
+
* entire app must not hide a public landing page behind an empty gate. Your
|
|
72
|
+
* own sign-in flow decides what a signed-out visitor sees.
|
|
73
|
+
*
|
|
74
|
+
* **This is a UX gate, not a security boundary** - same caveat as every
|
|
75
|
+
* other gate in this package. The authoritative check is
|
|
76
|
+
* `client.entitlements.getReadiness` on your server, before you do the
|
|
77
|
+
* thing.
|
|
78
|
+
*/
|
|
79
|
+
export declare function ConnectAccessGate({ children, fallback, loading, className }: ConnectAccessGateProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type ConnectLinkRenderer, type ConnectNav } from "./nav.js";
|
|
3
|
+
import type { ThemeMode } from "../../types.js";
|
|
4
|
+
export interface ConnectAppShellProps {
|
|
5
|
+
/** This service's name, in the `cosmo·ops VOICE` lockup. See `ConnectLogo`. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** This service's glyph, drawn on the CosmoOps badge. Falls back to the Connect rings. */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
/** Where the lockup links. Defaults to `/`. */
|
|
10
|
+
homeHref?: string;
|
|
11
|
+
/** The sidebar's destinations - items, sections, or a mix. See `ConnectNav`. */
|
|
12
|
+
nav: ConnectNav;
|
|
13
|
+
/** Current path. Pass `usePathname()` in Next; see `ConnectSideNavProps.activePath`. */
|
|
14
|
+
activePath?: string;
|
|
15
|
+
/** See `ConnectLinkRenderer` - how this app renders a link. Defaults to a plain `<a>`. */
|
|
16
|
+
renderLink?: ConnectLinkRenderer;
|
|
17
|
+
/**
|
|
18
|
+
* Page heading in the top bar. A string renders as the page's `<h1>`, so
|
|
19
|
+
* don't also put one at the top of the page; pass a node for breadcrumbs or
|
|
20
|
+
* anything else.
|
|
21
|
+
*/
|
|
22
|
+
title?: ReactNode;
|
|
23
|
+
/** Your own controls in the top bar, left of the Connect ones. */
|
|
24
|
+
headerActions?: ReactNode;
|
|
25
|
+
/** Extra content pinned to the bottom of the sidebar, above the fold-away. */
|
|
26
|
+
sidebarFooter?: ReactNode;
|
|
27
|
+
/** Rendered below `children`, inside the scrolling content pane. */
|
|
28
|
+
footer?: ReactNode;
|
|
29
|
+
/** Start the rail collapsed. Ignored when `collapsed` is supplied. */
|
|
30
|
+
defaultCollapsed?: boolean;
|
|
31
|
+
/** Drive the rail yourself. Supply `onCollapsedChange` with it. */
|
|
32
|
+
collapsed?: boolean;
|
|
33
|
+
onCollapsedChange?: (collapsed: boolean) => void;
|
|
34
|
+
/** Remove the collapse control and pin the rail expanded. */
|
|
35
|
+
collapsible?: boolean;
|
|
36
|
+
/** The app launcher in the top bar. On by default. */
|
|
37
|
+
showAppsGrid?: boolean;
|
|
38
|
+
/** "Continue with Connect" / "Create account" in the top bar when signed out. On by default. */
|
|
39
|
+
showAuthButtons?: boolean;
|
|
40
|
+
/** Passed to `ConnectUserMenu` - the SDK has no theme provider, so this is host-controlled. */
|
|
41
|
+
theme?: ThemeMode;
|
|
42
|
+
onThemeChange?: (theme: ThemeMode) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Wrap `children` in `ConnectAccessGate` - suspended, no plan, onboarding
|
|
45
|
+
* pending, payment due, decided once for the whole app. **On by default**,
|
|
46
|
+
* because the shell is exactly the "wrap your app content once" place that
|
|
47
|
+
* gate asks for, and an app that mounts a shell and forgets the gate is an
|
|
48
|
+
* app that lets a suspended workspace keep working.
|
|
49
|
+
*
|
|
50
|
+
* The chrome is never gated: the sidebar, the lockup, the user menu and
|
|
51
|
+
* sign-out stay reachable in every blocked state, so a blocked customer can
|
|
52
|
+
* always get to the page that unblocks them.
|
|
53
|
+
*
|
|
54
|
+
* Routes flagged `gateExempt` on their nav item render outside it - see
|
|
55
|
+
* `ConnectNavItem.gateExempt` and `gateExemptRoutes`.
|
|
56
|
+
*/
|
|
57
|
+
gate?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Extra ungated paths, on top of the ones flagged `gateExempt` in `nav`.
|
|
60
|
+
* For a route that is not in the sidebar at all - a `/checkout/callback`,
|
|
61
|
+
* say. Prefix-matched, same as the nav's active resolution.
|
|
62
|
+
*/
|
|
63
|
+
gateExemptRoutes?: string[];
|
|
64
|
+
className?: string;
|
|
65
|
+
/** Classes on the scrolling content pane - override the default max-width and padding here. */
|
|
66
|
+
contentClassName?: string;
|
|
67
|
+
children: ReactNode;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The application frame every CosmoOps service platform runs inside: a
|
|
71
|
+
* collapsible sidebar, a top bar carrying the app launcher and the account
|
|
72
|
+
* menu, and your pages in the middle.
|
|
73
|
+
*
|
|
74
|
+
* This is the piece each partner was otherwise writing by hand, and writing
|
|
75
|
+
* slightly differently - which is how a family of services stops looking like
|
|
76
|
+
* one product. Everything that should be identical across platforms (the
|
|
77
|
+
* lockup, the rail geometry, the active-route treatment, the launcher, the
|
|
78
|
+
* account menu, the access gate) lives here; everything that genuinely
|
|
79
|
+
* differs is a prop:
|
|
80
|
+
*
|
|
81
|
+
* ```tsx
|
|
82
|
+
* "use client";
|
|
83
|
+
* import Link from "next/link";
|
|
84
|
+
* import { usePathname } from "next/navigation";
|
|
85
|
+
* import { ConnectAppShell } from "@cosmoops/connect-sdk/client";
|
|
86
|
+
* import { LayoutDashboard, Truck } from "lucide-react";
|
|
87
|
+
*
|
|
88
|
+
* export function Shell({ children }: { children: React.ReactNode }) {
|
|
89
|
+
* return (
|
|
90
|
+
* <ConnectAppShell
|
|
91
|
+
* name="Dispatch"
|
|
92
|
+
* icon={<Truck />}
|
|
93
|
+
* activePath={usePathname()}
|
|
94
|
+
* renderLink={(props) => <Link {...props} />}
|
|
95
|
+
* nav={[
|
|
96
|
+
* { label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
|
|
97
|
+
* { label: "Plans", href: "/plans", icon: <Truck className="size-5" />, gateExempt: true },
|
|
98
|
+
* ]}
|
|
99
|
+
* >
|
|
100
|
+
* {children}
|
|
101
|
+
* </ConnectAppShell>
|
|
102
|
+
* );
|
|
103
|
+
* }
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* There is no `isSignedIn` branch anywhere in here, and there should be none
|
|
107
|
+
* in yours: the auth buttons render nothing once a session exists, the user
|
|
108
|
+
* menu renders nothing without one, and the launcher renders nothing when
|
|
109
|
+
* signed out. The swap happens inside the components.
|
|
110
|
+
*
|
|
111
|
+
* It renders no provider of its own - mount `ConnectProvider` above it, once,
|
|
112
|
+
* so a page with a shell, a plan table and a usage meter still makes one
|
|
113
|
+
* session read.
|
|
114
|
+
*/
|
|
115
|
+
export declare function ConnectAppShell({ name, icon, homeHref, nav, activePath, renderLink, title, headerActions, sidebarFooter, footer, defaultCollapsed, collapsed: controlledCollapsed, onCollapsedChange, collapsible, showAppsGrid, showAuthButtons, theme, onThemeChange, gate, gateExemptRoutes, className, contentClassName, children, }: ConnectAppShellProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ConnectIntegrationsModalProps {
|
|
2
|
+
/** Extra classes on the trigger button. */
|
|
3
|
+
className?: string;
|
|
4
|
+
/** Trigger label, both visible and for screen readers. */
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A grid of every Composio-hosted toolkit this organization can connect -
|
|
9
|
+
* Google Calendar, Slack, Twitter/X, whatever a platform admin enabled in
|
|
10
|
+
* Connect's own Console. Each tile carries its own connect/reconnect/
|
|
11
|
+
* disconnect action and its own pending state, independent of every other
|
|
12
|
+
* tile - see the doc comment on `pendingSlug` below for why that matters.
|
|
13
|
+
*
|
|
14
|
+
* Not optimistic: a tile stays whatever it already was after "Connect" is
|
|
15
|
+
* pressed, because the connection isn't settled until Composio's webhook
|
|
16
|
+
* confirms it - `mutateResource` here only ever gets as far as `pending`
|
|
17
|
+
* before the browser navigates away to complete the provider's OAuth screen.
|
|
18
|
+
*
|
|
19
|
+
* Renders nothing when signed out, same as `ConnectAppsGrid`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ConnectIntegrationsModal({ className, label }: ConnectIntegrationsModalProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export { m, AnimatePresence } from "motion/react";
|
|
3
|
+
/** HeroUI's --ease-out-quart, transcribed for motion transitions. */
|
|
4
|
+
export declare const EASE_OUT_QUART: readonly [0.165, 0.84, 0.44, 1];
|
|
5
|
+
export declare const ENTER: {
|
|
6
|
+
readonly duration: 0.3;
|
|
7
|
+
readonly ease: readonly [0.165, 0.84, 0.44, 1];
|
|
8
|
+
};
|
|
9
|
+
export declare const METER_SPRING: {
|
|
10
|
+
readonly type: "spring";
|
|
11
|
+
readonly stiffness: 170;
|
|
12
|
+
readonly damping: 26;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Per-component motion root. domAnimation keeps the feature bundle small,
|
|
16
|
+
* `strict` restricts rendering to `m.*` components, and reducedMotion="user"
|
|
17
|
+
* drops transform animation (keeping opacity) under prefers-reduced-motion -
|
|
18
|
+
* the same contract HeroUI's own `motion-reduce` styles follow.
|
|
19
|
+
*/
|
|
20
|
+
export declare function MotionProvider({ children }: Readonly<{
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}>): import("react").JSX.Element;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* One destination in the shell's sidebar.
|
|
4
|
+
*
|
|
5
|
+
* `href` is a path in *your* app, not a Connect URL - the shell never
|
|
6
|
+
* routes anywhere itself, it hands `href` to `renderLink` and lets your
|
|
7
|
+
* router do the work.
|
|
8
|
+
*/
|
|
9
|
+
export interface ConnectNavItem {
|
|
10
|
+
label: string;
|
|
11
|
+
href: string;
|
|
12
|
+
/**
|
|
13
|
+
* A pre-rendered glyph, e.g. `<LayoutDashboard className="size-5" />`.
|
|
14
|
+
* Required in practice for anything in the primary rail: the collapsed rail
|
|
15
|
+
* is icon-only, and an item with no icon collapses to an empty box.
|
|
16
|
+
*/
|
|
17
|
+
icon?: ReactNode;
|
|
18
|
+
/** Trailing content on the row - a count `<Chip>`, a status dot. Hidden while collapsed. */
|
|
19
|
+
badge?: ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* Match this route only when the path is exactly `href`, never as a prefix.
|
|
22
|
+
* `/` is always treated as exact regardless, since every path starts with it.
|
|
23
|
+
*/
|
|
24
|
+
exact?: boolean;
|
|
25
|
+
/** Opens in a new tab, with a trailing arrow. For docs, status pages, anything outside the app. */
|
|
26
|
+
external?: boolean;
|
|
27
|
+
/** One level of children, revealed beneath the parent when the parent or a child is active. */
|
|
28
|
+
items?: ConnectNavItem[];
|
|
29
|
+
/**
|
|
30
|
+
* Leave this route *outside* `ConnectAccessGate` - see
|
|
31
|
+
* `ConnectAppShellProps.gate`. Declared here, next to the route it applies
|
|
32
|
+
* to, rather than as a second list somewhere else that has to be kept in
|
|
33
|
+
* step with this one.
|
|
34
|
+
*/
|
|
35
|
+
gateExempt?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** A titled group of items. Untitled groups render as a plain divider-free block. */
|
|
38
|
+
export interface ConnectNavSection {
|
|
39
|
+
title?: string;
|
|
40
|
+
items: ConnectNavItem[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* What `ConnectAppShell`/`ConnectSideNav` accept for `nav`: a flat list of
|
|
44
|
+
* items, a list of sections, or a mix. Consecutive bare items fold into one
|
|
45
|
+
* untitled section, so the common case is just an array of items.
|
|
46
|
+
*/
|
|
47
|
+
export type ConnectNav = ReadonlyArray<ConnectNavItem | ConnectNavSection>;
|
|
48
|
+
/** Props the shell hands to `renderLink`. Spread them onto whatever your router's link component is. */
|
|
49
|
+
export interface ConnectNavLinkProps {
|
|
50
|
+
href: string;
|
|
51
|
+
className: string;
|
|
52
|
+
children: ReactNode;
|
|
53
|
+
onClick?: () => void;
|
|
54
|
+
"aria-current"?: "page";
|
|
55
|
+
/**
|
|
56
|
+
* Always set, and the only name the link has while the rail is collapsed -
|
|
57
|
+
* spread it through rather than dropping it, or an icon-only rail reads as
|
|
58
|
+
* a column of unlabelled links.
|
|
59
|
+
*/
|
|
60
|
+
"aria-label": string;
|
|
61
|
+
target?: string;
|
|
62
|
+
rel?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* How this app renders a link.
|
|
66
|
+
*
|
|
67
|
+
* The SDK ships into any React app and cannot import `next/link`,
|
|
68
|
+
* `react-router` or anything else framework-specific, so routing is a prop.
|
|
69
|
+
* The default is a plain `<a>`, which is correct but costs a full page load;
|
|
70
|
+
* an app with a client router should pass its own:
|
|
71
|
+
*
|
|
72
|
+
* ```tsx
|
|
73
|
+
* renderLink={(props) => <Link {...props} />} // next/link
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export type ConnectLinkRenderer = (props: ConnectNavLinkProps) => ReactNode;
|
|
77
|
+
/** Folds a mixed `ConnectNav` into sections, gathering consecutive bare items into one untitled group. */
|
|
78
|
+
export declare function normalizeNav(nav: ConnectNav): ConnectNavSection[];
|
|
79
|
+
/** Every item in the tree, parents and children alike, in render order. */
|
|
80
|
+
export declare function flattenNav(nav: ConnectNav): ConnectNavItem[];
|
|
81
|
+
/**
|
|
82
|
+
* `https://x.test/billing/?tab=1#top` -> `/billing`.
|
|
83
|
+
*
|
|
84
|
+
* Trailing slashes go too, so `/billing/` and `/billing` are the same route -
|
|
85
|
+
* they are, to every router, and treating them as different is how a nav item
|
|
86
|
+
* ends up unhighlighted on exactly one of the two URLs a user can arrive at.
|
|
87
|
+
*/
|
|
88
|
+
export declare function normalizePath(path: string): string;
|
|
89
|
+
/**
|
|
90
|
+
* The href of the nav item the current path belongs to, or `null`.
|
|
91
|
+
*
|
|
92
|
+
* Longest prefix wins, so `/settings/billing` lights up Billing and not also
|
|
93
|
+
* its parent Settings - a plain `startsWith` over an unordered list lights up
|
|
94
|
+
* both, and the sidebar grows two highlights.
|
|
95
|
+
*
|
|
96
|
+
* External items never match: they leave the app, so no path inside it is
|
|
97
|
+
* ever "on" them.
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolveActivePath(pathname: string, nav: ConnectNav): string | null;
|
|
100
|
+
/**
|
|
101
|
+
* Whether `item` is the destination `resolveActivePath` picked.
|
|
102
|
+
*
|
|
103
|
+
* Not just `normalizePath(item.href) === activeHref`, which is the trap this
|
|
104
|
+
* exists to close: `normalizePath` strips the origin off an absolute URL, so
|
|
105
|
+
* an external item pointed at `https://docs.example.com` normalizes to `/` and
|
|
106
|
+
* lights up on every app's home route. External items are never active.
|
|
107
|
+
*/
|
|
108
|
+
export declare function isNavItemActive(item: ConnectNavItem, activeHref: string | null): boolean;
|
|
109
|
+
/** Hrefs declared `gateExempt` - the routes `ConnectAppShell` renders outside its `ConnectAccessGate`. */
|
|
110
|
+
export declare function gateExemptPaths(nav: ConnectNav): string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ConnectLinkRenderer, type ConnectNav } from "./nav.js";
|
|
2
|
+
/** A plain anchor - correct anywhere, and a full page load. See `ConnectLinkRenderer`. */
|
|
3
|
+
export declare const defaultRenderLink: ConnectLinkRenderer;
|
|
4
|
+
export interface ConnectSideNavProps {
|
|
5
|
+
/** Items, sections, or a mix - see `ConnectNav`. */
|
|
6
|
+
nav: ConnectNav;
|
|
7
|
+
/**
|
|
8
|
+
* The current path. Pass your router's own (`usePathname()` in Next) -
|
|
9
|
+
* without it the nav reads `window.location` and patches `history` to hear
|
|
10
|
+
* about client-side navigations, which works but is guesswork the router
|
|
11
|
+
* never has to do. See `useActivePath`.
|
|
12
|
+
*/
|
|
13
|
+
activePath?: string;
|
|
14
|
+
/** See `ConnectLinkRenderer`. Defaults to a plain `<a>`. */
|
|
15
|
+
renderLink?: ConnectLinkRenderer;
|
|
16
|
+
/** Icon-only rail. Labels move into tooltips and children are hidden. */
|
|
17
|
+
collapsed?: boolean;
|
|
18
|
+
/** Called after any nav link is pressed - the mobile drawer closes itself with this. */
|
|
19
|
+
onNavigate?: () => void;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The sidebar's nav column on its own - sections, items, one level of
|
|
24
|
+
* children, and the active highlight.
|
|
25
|
+
*
|
|
26
|
+
* `ConnectAppShell` renders this inside its rail and its mobile drawer, and it
|
|
27
|
+
* is exported for a host that wants the shell's navigation without its
|
|
28
|
+
* chrome: the same items, the same active resolution, dropped into a layout
|
|
29
|
+
* you already have.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ConnectSideNav({ nav, activePath, renderLink, collapsed, onNavigate, className, }: ConnectSideNavProps): import("react").JSX.Element;
|