@cosmoops/connect-sdk 0.2.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 +284 -7
- package/dist/cjs/cli/doctor.js +209 -0
- package/dist/cjs/cli/init.js +20 -8
- 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/cjs/this-entry-is-server-only.js +31 -0
- package/dist/esm/cli/doctor.js +206 -0
- package/dist/esm/cli/init.js +20 -8
- 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/esm/this-entry-is-server-only.js +30 -0
- package/dist/theme.css +28 -0
- package/dist/types/cli/doctor.d.ts +23 -0
- package/dist/types/cli/init.d.ts +2 -3
- 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 +17 -2
- 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/this-entry-is-server-only.d.ts +1 -0
- package/dist/types/types.d.ts +182 -1
- package/package.json +22 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
`@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
|
|
4
|
+
versions may carry breaking changes** and patch versions never do.
|
|
5
|
+
|
|
6
|
+
## 0.6.0
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`ConnectAppShell`** - the application frame every service platform was
|
|
11
|
+
otherwise writing by hand: a collapsible sidebar, a top bar carrying the app
|
|
12
|
+
launcher and the account menu, a mobile drawer below `md`, and your pages in
|
|
13
|
+
the middle. Each platform passes `name`, `icon` and `nav`; the lockup, the
|
|
14
|
+
rail geometry, the active-route treatment and the chrome come from one
|
|
15
|
+
place, which is what keeps a family of platforms looking like one product.
|
|
16
|
+
|
|
17
|
+
It mounts `ConnectAccessGate` around `children` by default. That is a
|
|
18
|
+
deliberate default rather than a convenience: the shell is exactly the "wrap
|
|
19
|
+
your app content once" place that gate asks for, and a platform that ships a
|
|
20
|
+
shell and forgets the gate is a platform where a suspended workspace keeps
|
|
21
|
+
working. The chrome is never gated, and routes that *resolve* a block -
|
|
22
|
+
your pricing page - are flagged `gateExempt: true` on the nav item itself,
|
|
23
|
+
so the exemption lives next to the route rather than in a second list that
|
|
24
|
+
has to be kept in step.
|
|
25
|
+
|
|
26
|
+
- **`ConnectSideNav`** - the sidebar's nav column on its own, for an app that
|
|
27
|
+
already has chrome it likes. Sections, one level of children, and the same
|
|
28
|
+
active-route resolution the shell uses.
|
|
29
|
+
|
|
30
|
+
- **The nav model as data**: `ConnectNav` / `ConnectNavItem` /
|
|
31
|
+
`ConnectNavSection` types plus `normalizeNav`, `flattenNav`, `normalizePath`,
|
|
32
|
+
`resolveActivePath`, `isNavItemActive` and `gateExemptPaths`. Pure functions
|
|
33
|
+
over plain data, exported because a breadcrumb or a page title derived from
|
|
34
|
+
your own nav should not be able to disagree with what the rail highlights.
|
|
35
|
+
|
|
36
|
+
- **`useActivePath`** - the current path, from your router when you pass one
|
|
37
|
+
and from the browser when you don't.
|
|
38
|
+
|
|
39
|
+
Routing is a prop throughout (`renderLink`), because this package ships into
|
|
40
|
+
any React app and cannot import `next/link`. The default is a plain `<a>`.
|
|
41
|
+
Uncontrolled, `useActivePath` patches `history.pushState`/`replaceState` to
|
|
42
|
+
dispatch an event - neither fires one, so a client-side router moves the
|
|
43
|
+
address bar and tells nothing outside itself, and a sidebar reading
|
|
44
|
+
`location.pathname` would highlight the route the page first loaded on
|
|
45
|
+
forever. Pass `activePath` (Next: `usePathname()`) and none of that runs.
|
|
46
|
+
|
|
47
|
+
## 0.5.0
|
|
48
|
+
|
|
49
|
+
### Breaking
|
|
50
|
+
|
|
51
|
+
- **`react` and `react-dom` peers raised to `>=19`** (from `>=18`). Both stay
|
|
52
|
+
`optional` - a `/server`-only consumer still needs neither - but `/next`'s
|
|
53
|
+
`getConnectSession()` now imports `react`'s `cache()` unconditionally,
|
|
54
|
+
which React 18 doesn't export. Every current App Router project is already
|
|
55
|
+
on 19; this only bites a project still pinned to 18.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- **`getConnectSession()` is now memoized per request.** The plain,
|
|
60
|
+
zero-argument call - the one every page, layout and Server Action actually
|
|
61
|
+
uses - is wrapped in React's `cache()`, so calling it from a layout *and*
|
|
62
|
+
the page beneath it costs one round trip to Connect, not two. Passing an
|
|
63
|
+
explicit `request`, `client` or `session` still bypasses the cache and
|
|
64
|
+
always makes a live call, as before.
|
|
65
|
+
- **`requireConnectSession()`** - `getConnectSession()` plus the
|
|
66
|
+
`if (!session) redirect(...)` every integration was writing by hand.
|
|
67
|
+
Redirects to `/api/connect/signin` (or `signInPath`) when signed out,
|
|
68
|
+
optionally appending `next`. See "Authentication & authorization" in the
|
|
69
|
+
docs for where role/entitlement checks still belong - this only covers the
|
|
70
|
+
"is anyone signed in" half.
|
|
71
|
+
|
|
72
|
+
## 0.4.0
|
|
73
|
+
|
|
74
|
+
### Breaking
|
|
75
|
+
|
|
76
|
+
- **`ConnectUser.photoUrl` is a new required field.** Additive for anything
|
|
77
|
+
*reading* a `ConnectUser`, breaking for anything *constructing* one - a test
|
|
78
|
+
fixture or a hand-rolled mock will stop type-checking until it sets
|
|
79
|
+
`photoUrl: string | null`. Nothing else about the type changed.
|
|
80
|
+
|
|
81
|
+
Connect has always stored a profile picture; it was simply never carried on
|
|
82
|
+
the wire, so `<ConnectUserMenu>` showed initials for every account no matter
|
|
83
|
+
what the member had set. It now flows through `/sso/token` and `/sso/user`.
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- **`<ConnectAccessGate>` and `useConnectAccessGate()`** - one gate composing
|
|
88
|
+
the four states a partner actually needs, in priority order: workspace
|
|
89
|
+
suspended, no subscription, onboarding approval pending, and a dismissible
|
|
90
|
+
payment-due banner during a dunning retry. Replaces hand-assembling
|
|
91
|
+
`ConnectReadinessGate` + `ConnectSubscriptionGate` and deciding the ordering
|
|
92
|
+
yourself.
|
|
93
|
+
|
|
94
|
+
Mount it once around your app's content, but **carve out your own plans and
|
|
95
|
+
pricing routes** - its "Choose a plan" state is where blocked customers are
|
|
96
|
+
sent, so gating that page too traps them. See the component's doc comment.
|
|
97
|
+
|
|
98
|
+
- **`<ConnectProvider silentSignIn>`** - OIDC silent authentication. A visitor
|
|
99
|
+
already signed into Connect who has authorised your app before is signed in
|
|
100
|
+
with no click and no screen. Off by default: it costs one full-page redirect
|
|
101
|
+
on the first load of a signed-out visit, and is attempted at most once per
|
|
102
|
+
tab.
|
|
103
|
+
|
|
104
|
+
First contact still shows Connect's consent screen once - that grant is
|
|
105
|
+
exactly what makes every later visit silent.
|
|
106
|
+
|
|
107
|
+
- `ReadinessBlockerCode` gains `organization_suspended` and
|
|
108
|
+
`payment_retry_pending`; `ReadinessBlocker` gains an optional `graceEndsAt`
|
|
109
|
+
carrying the dunning-suspension deadline, so a payment-due warning can show a
|
|
110
|
+
real countdown instead of a bare "something failed".
|
|
111
|
+
|
|
112
|
+
- `identity.authorizeUrl({ prompt })` accepts `"none"` alongside `"create"`.
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- `ConnectUserMenu` renders the member's actual profile picture, falling back
|
|
117
|
+
to initials while it loads or if they have none.
|
|
118
|
+
- Panels inside the account modal shared one elevation and one icon-medallion
|
|
119
|
+
spec; the destination tiles and session list previously used a fill that is
|
|
120
|
+
the *same lightness* as the modal itself in both light and dark, so they read
|
|
121
|
+
as hairline outlines beside a solid identity card.
|
|
122
|
+
- `theme.css` now defines `--default` and `--surface-tertiary`. Without them,
|
|
123
|
+
every `secondary`/`tertiary` Button and default Chip fell back to
|
|
124
|
+
`@heroui/styles`' stock cool-grey ramp while the rest of the palette sat on a
|
|
125
|
+
warm one - close enough to read as a mistake rather than a choice.
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Browser ──► your backend ──► Connect
|
|
|
25
25
|
|
|
26
26
|
## Server setup
|
|
27
27
|
|
|
28
|
-
One credential does three jobs — the API key plaintext from your service's
|
|
28
|
+
One credential does three jobs — the API key plaintext from your service's Integration tab is also your OAuth2 `client_secret` and the key your `id_token` is signed with. Revoking it revokes all three.
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
31
|
import { ConnectClient } from "@cosmoops/connect-sdk/server";
|
|
@@ -87,6 +87,49 @@ if (!serviceRole.granted.includes("invoice:write")) return forbidden();
|
|
|
87
87
|
await connect.identity.logout(sessionToken);
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
## Sign returning visitors in with no click
|
|
91
|
+
|
|
92
|
+
A visitor already signed into Connect should not have to press "Sign in"
|
|
93
|
+
again. OIDC calls this silent authentication, and on `./next` it is one prop:
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
<ConnectProvider silentSignIn>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
On the first load of a signed-out visit the browser makes one round trip to
|
|
100
|
+
Connect with `prompt=none`. If they have a Connect session **and** have
|
|
101
|
+
authorised your app before, they come back signed in having seen nothing. If
|
|
102
|
+
not, they come back signed out — no error, no screen, your normal signed-out
|
|
103
|
+
UI a beat later.
|
|
104
|
+
|
|
105
|
+
Rolling it yourself is the same flow with `prompt: "none"`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
redirect(connect.identity.authorizeUrl({ redirectUri, state, prompt: "none" }));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Three things to get right, all handled for you by `./next`:
|
|
112
|
+
|
|
113
|
+
- **`prompt=none` never shows a login screen — that is its contract.** Use it
|
|
114
|
+
to *resume* a session, never to start one. Sending a genuinely signed-out
|
|
115
|
+
visitor through it in a loop is the classic way to build a redirect loop.
|
|
116
|
+
- **`login_required` from a silent attempt is not the `login_required` above.**
|
|
117
|
+
The retry in step 2 is right for an interactive sign-in and wrong here: it
|
|
118
|
+
escalates a quiet "nobody home" into a forced login. Track which kind of
|
|
119
|
+
attempt is in flight.
|
|
120
|
+
- **Attempt it at most once per tab.** A visitor with no Connect session will
|
|
121
|
+
answer the same way on every page load; without a marker, every navigation
|
|
122
|
+
pays a full redirect.
|
|
123
|
+
|
|
124
|
+
**First contact still shows Connect's consent screen once.** That grant is
|
|
125
|
+
exactly what makes every later visit silent, and it is per user, per client —
|
|
126
|
+
so `consent_required` means "they have never authorised you", not "something
|
|
127
|
+
broke". Treat all three of `login_required`, `consent_required` and
|
|
128
|
+
`interaction_required` as "carry on signed out".
|
|
129
|
+
|
|
130
|
+
It costs a full-page redirect on that first load, which is why it is off by
|
|
131
|
+
default: worth it for an app shell, less so for a marketing page.
|
|
132
|
+
|
|
90
133
|
## Gate on entitlements
|
|
91
134
|
|
|
92
135
|
```ts
|
|
@@ -149,6 +192,7 @@ try {
|
|
|
149
192
|
| `logs` | `write` |
|
|
150
193
|
| `apps` | `list` |
|
|
151
194
|
| `sessions` | `list` · `revoke` · `revokeOthers` |
|
|
195
|
+
| `integrations` | `list` · `initiateConnect` · `disconnect` · `execute` |
|
|
152
196
|
|
|
153
197
|
### Attributing a log to a user
|
|
154
198
|
|
|
@@ -215,7 +259,7 @@ cookie, `/session`, `/entitlements`, `/plans`, `/addons`, `/apps`, `/sessions`,
|
|
|
215
259
|
| `CONNECT_SESSION_COOKIE` | no | session cookie name |
|
|
216
260
|
|
|
217
261
|
Register `{your origin}/api/connect/callback` as a redirect URI in the service's
|
|
218
|
-
|
|
262
|
+
Integration tab — the one step this cannot do for you.
|
|
219
263
|
|
|
220
264
|
No `next.config.ts` changes needed, including `transpilePackages`: `dist/` is
|
|
221
265
|
already ES2022 with `"use client"` hoisted, so Turbopack and webpack both
|
|
@@ -235,9 +279,26 @@ export default async function DashboardPage() {
|
|
|
235
279
|
}
|
|
236
280
|
```
|
|
237
281
|
|
|
282
|
+
Or skip the check-and-redirect entirely — `requireConnectSession()` does both:
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
import { requireConnectSession } from "@cosmoops/connect-sdk/next";
|
|
286
|
+
|
|
287
|
+
export default async function DashboardPage() {
|
|
288
|
+
const session = await requireConnectSession(); // UserResponse — redirects if signed out
|
|
289
|
+
return <h1>{session.user.organizationName}</h1>;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
238
293
|
Pass a `Request` — `getConnectSession(request)` — anywhere `next/headers` does
|
|
239
294
|
not exist: a framework-agnostic handler, a custom server, a test.
|
|
240
295
|
|
|
296
|
+
Both are memoized per request when called with no arguments (React's
|
|
297
|
+
`cache()`), so a layout reading the session for a nav avatar and the page
|
|
298
|
+
beneath it reading it again for a role check share one round trip to Connect,
|
|
299
|
+
not two. An explicit `request`, `client` or `session` argument bypasses the
|
|
300
|
+
cache and always makes a live call.
|
|
301
|
+
|
|
241
302
|
It reads `cookies()` transitively, so under `cacheComponents` it belongs inside
|
|
242
303
|
a `<Suspense>` boundary like any other request-scoped read. A page that awaits
|
|
243
304
|
it above every boundary gives up its static shell.
|
|
@@ -245,6 +306,68 @@ it above every boundary gives up its static shell.
|
|
|
245
306
|
You do **not** need `transpilePackages`. `dist/` is ES2022 with `"use client"`
|
|
246
307
|
already hoisted, so Turbopack and webpack consume it as-is.
|
|
247
308
|
|
|
309
|
+
## Route protection and `proxy.ts`
|
|
310
|
+
|
|
311
|
+
Nothing here is auto-wired, and nothing needs to be: this SDK ships no proxy and
|
|
312
|
+
never writes one. Next 16 renamed Middleware to **Proxy** — a single `proxy.ts`
|
|
313
|
+
at the project root (or in `src/`, beside `app/`).
|
|
314
|
+
|
|
315
|
+
You only need one if you want unauthenticated visitors bounced *before* a page
|
|
316
|
+
renders. If you are happy redirecting inside the page, `getConnectSession()`
|
|
317
|
+
above already does the job and there is nothing to add.
|
|
318
|
+
|
|
319
|
+
If you do add one, keep it to a cookie **presence** check:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// proxy.ts
|
|
323
|
+
import { NextResponse } from "next/server";
|
|
324
|
+
import type { NextRequest } from "next/server";
|
|
325
|
+
|
|
326
|
+
const COOKIE = process.env.CONNECT_SESSION_COOKIE ?? "connect_session";
|
|
327
|
+
|
|
328
|
+
export function proxy(request: NextRequest) {
|
|
329
|
+
if (request.cookies.has(COOKIE)) return NextResponse.next();
|
|
330
|
+
|
|
331
|
+
const signin = new URL("/api/connect/signin", request.url);
|
|
332
|
+
signin.searchParams.set("next", request.nextUrl.pathname);
|
|
333
|
+
return NextResponse.redirect(signin);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Must not match /api/connect/* — the sign-in route has no session cookie yet,
|
|
337
|
+
// so matching it would redirect the sign-in to itself.
|
|
338
|
+
export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**A cookie is not a session, and this check is not authorization.** The cookie
|
|
342
|
+
holds an opaque Connect `sessionToken`; deciding whether it is still valid means
|
|
343
|
+
asking Connect, and proxy runs on every matched request — so a real check here
|
|
344
|
+
would put a network round trip in front of each one. Next's own guidance says
|
|
345
|
+
the same thing: proxy is for [optimistic
|
|
346
|
+
checks](https://nextjs.org/docs/app/guides/authentication#optimistic-checks-with-proxy-optional),
|
|
347
|
+
"not ... a full session management or authorization solution."
|
|
348
|
+
|
|
349
|
+
An expired or revoked token still arrives as a cookie and still passes the
|
|
350
|
+
snippet above. The authoritative check is `getConnectSession()` in the Server
|
|
351
|
+
Component, or `client.entitlements.*` on your server — treat the proxy purely as
|
|
352
|
+
a way to skip rendering a page the visitor cannot use.
|
|
353
|
+
|
|
354
|
+
### The server/client boundary
|
|
355
|
+
|
|
356
|
+
`@cosmoops/connect-sdk/server` and `/next` build a client around
|
|
357
|
+
`CONNECT_API_KEY`; `/client` holds no credential and calls your own origin. That
|
|
358
|
+
split is enforced, not just documented — both credential-holding entries resolve
|
|
359
|
+
to a stub in a browser bundle, so importing one from a client component fails the
|
|
360
|
+
build:
|
|
361
|
+
|
|
362
|
+
```
|
|
363
|
+
No matching export in ".../dist/esm/this-entry-is-server-only.js" for import "ConnectClient"
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Import from `/client` instead, or use `import type`, which erases before any
|
|
367
|
+
bundler sees it. Server Components, Route Handlers, Server Actions and non-Next
|
|
368
|
+
backends are unaffected — the stub is wired to the `browser` export condition,
|
|
369
|
+
which only a client bundle resolves.
|
|
370
|
+
|
|
248
371
|
## Other frameworks
|
|
249
372
|
|
|
250
373
|
Mount the bridge once in your backend:
|
|
@@ -282,11 +405,16 @@ that URL yourself instead.
|
|
|
282
405
|
|
|
283
406
|
```tsx
|
|
284
407
|
import {
|
|
408
|
+
ConnectAppShell,
|
|
285
409
|
ConnectLoginButton, ConnectRegisterButton,
|
|
286
410
|
ConnectUserMenu, ConnectAppsGrid, ConnectPlans,
|
|
287
411
|
} from "@cosmoops/connect-sdk/client";
|
|
288
412
|
|
|
289
|
-
//
|
|
413
|
+
// The whole frame — sidebar, top bar, access gate. See "App shell" below.
|
|
414
|
+
<ConnectAppShell name="Dispatch" nav={nav}>{children}</ConnectAppShell>
|
|
415
|
+
|
|
416
|
+
// Or the pieces, if you already have a layout. The pair swaps automatically
|
|
417
|
+
// on sign-in state.
|
|
290
418
|
<ConnectAppsGrid />
|
|
291
419
|
<ConnectLoginButton />
|
|
292
420
|
<ConnectUserMenu />
|
|
@@ -297,12 +425,15 @@ import {
|
|
|
297
425
|
|
|
298
426
|
| Component | What it does |
|
|
299
427
|
| --- | --- |
|
|
428
|
+
| `ConnectAppShell` | **The whole application frame** — collapsible sidebar, top bar with the launcher and account menu, and your pages in the middle. Mounts `ConnectAccessGate` for you. |
|
|
429
|
+
| `ConnectSideNav` | The sidebar's nav column on its own — sections, one level of children, active-route highlight — for an app that already has its own chrome. |
|
|
300
430
|
| `ConnectLoginButton` · `ConnectRegisterButton` | "Continue with Connect", in the shape people know from Google. Render nothing when signed in. |
|
|
301
431
|
| `ConnectUserMenu` | Avatar, name, workspace. Profile, Manage subscription and Open Connect each open Connect in a new tab; Sign out acts in place. Renders nothing when signed out. |
|
|
302
432
|
| `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of every app this workspace can open. |
|
|
303
433
|
| `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
|
|
304
434
|
| `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
|
|
305
435
|
| `ConnectSubscribeDialog` | The review step `ConnectPlans` opens — plan, GST, what happens to the current cycle. Exported so you can drive the flow from your own pricing page. |
|
|
436
|
+
| `ConnectAccessGate` | **The one gate to mount if you mount one.** Composes suspended / no-plan / onboarding-pending / payment-due into a single priority-ordered decision. Wrap your app content once. |
|
|
306
437
|
| `ConnectSubscriptionGate` | Renders your module for a subscribed workspace, and `ConnectNoSubscriptionCard` for everyone else. Never flashes the upsell while loading. |
|
|
307
438
|
| `ConnectNoSubscriptionCard` · `ConnectSubscriptionCard` | The same slot, unsubscribed and subscribed. Plan, status, renewal date, seats, and one way into Connect. |
|
|
308
439
|
| `ConnectReadinessBanner` · `ConnectReadinessGate` | Every gate between this workspace and your service — KYC, payment method, seat headroom — each with the Connect screen that fixes it. |
|
|
@@ -315,7 +446,7 @@ import {
|
|
|
315
446
|
|
|
316
447
|
### Hooks
|
|
317
448
|
|
|
318
|
-
`useConnect` · `useConnectUser` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
|
|
449
|
+
`useConnect` · `useConnectUser` · `useActivePath` · `useConnectAccessGate` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
|
|
319
450
|
|
|
320
451
|
### Metering
|
|
321
452
|
|
|
@@ -334,13 +465,59 @@ The catalogue read (`useConnectPlans`, `<ConnectPlans />`) reports `features` as
|
|
|
334
465
|
|
|
335
466
|
### Gates
|
|
336
467
|
|
|
468
|
+
Most apps want **one** gate around everything, and finer ones inside it.
|
|
469
|
+
|
|
470
|
+
```tsx
|
|
471
|
+
// Mount once, around your app's content — not around your header.
|
|
472
|
+
<ConnectAccessGate>
|
|
473
|
+
<YourApp />
|
|
474
|
+
</ConnectAccessGate>
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`ConnectAccessGate` answers "can this workspace use my app right now" in one
|
|
478
|
+
priority-ordered pass, so you are not left assembling the pieces and choosing
|
|
479
|
+
the order yourself:
|
|
480
|
+
|
|
481
|
+
| State | What renders |
|
|
482
|
+
| --- | --- |
|
|
483
|
+
| `suspended` | A hard stop. Nothing the customer does clears it — only a Connect admin reactivating the workspace. |
|
|
484
|
+
| `no_subscription` | "Choose a plan", linking to Connect's subscribe page for your service. |
|
|
485
|
+
| `onboarding_pending` | Waiting on a reviewer. No action offered, because there is none. |
|
|
486
|
+
| `blocked` | Any other blocking readiness code — verification, audience, seats — each with its own fix. |
|
|
487
|
+
| `payment_retry` | **Your app still renders.** A dismissible banner counts down to the dunning deadline. |
|
|
488
|
+
| `clear` | Your app, nothing else. |
|
|
489
|
+
|
|
490
|
+
**Carve out your own plans/pricing routes.** The `no_subscription` card sends
|
|
491
|
+
people to choose a plan; if the gate also covers the page they land on, they
|
|
492
|
+
are trapped. The SDK cannot do this for you — it imports nothing from `next`
|
|
493
|
+
and so cannot know your router:
|
|
494
|
+
|
|
495
|
+
```tsx
|
|
496
|
+
const EXEMPT = new Set(["/plans", "/pricing"]);
|
|
497
|
+
const gated = !EXEMPT.has(usePathname());
|
|
498
|
+
|
|
499
|
+
<main>{gated ? <ConnectAccessGate>{children}</ConnectAccessGate> : children}</main>
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
For per-state UI, call the hook instead of overriding `fallback`:
|
|
503
|
+
|
|
504
|
+
```tsx
|
|
505
|
+
const { state, blocker, paymentRetry } = useConnectAccessGate();
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Signed-out renders `children`, deliberately — unlike the gates below. None of
|
|
509
|
+
those states describes a signed-out visitor, and a gate around your whole app
|
|
510
|
+
must not hide a public landing page.
|
|
511
|
+
|
|
512
|
+
The narrower gates still exist for gating *within* a cleared workspace:
|
|
513
|
+
|
|
337
514
|
```tsx
|
|
338
515
|
<ConnectReadinessBanner /> {/* KYC, payment method, seats — each with a fix */}
|
|
339
516
|
<ConnectSubscriptionGate><Dashboard /></ConnectSubscriptionGate>
|
|
340
517
|
<ConnectFeatureGate feature="advanced_reports"><Reports /></ConnectFeatureGate>
|
|
341
518
|
```
|
|
342
519
|
|
|
343
|
-
All
|
|
520
|
+
All of them render `loading` — never the fallback — while the answer is unknown, so a paying customer is never flashed an upsell for something they already have.
|
|
344
521
|
|
|
345
522
|
**They are UX gates, not security boundaries.** Everything they read reaches your own origin and a browser can ignore it. The authoritative check is on your server: `client.entitlements.getReadiness(orgId)` and `client.entitlements.hasFeature(orgId, feature)`.
|
|
346
523
|
|
|
@@ -350,7 +527,13 @@ All three render `loading` — never the fallback — while the answer is unknow
|
|
|
350
527
|
npx @cosmoops/connect-sdk init
|
|
351
528
|
```
|
|
352
529
|
|
|
353
|
-
Writes `app/api/connect/[...connect]/route.ts`, `app/plans/page.tsx` and an `.env.local` template, then prints the
|
|
530
|
+
Writes `app/api/connect/[...connect]/route.ts`, `app/plans/page.tsx` and an `.env.local` template, then prints the steps it will not do for you (wrapping your layout in `<ConnectProvider>`, importing the theme, and registering the redirect URI in Connect). It never overwrites, so re-running is safe.
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
npx @cosmoops/connect-sdk doctor
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Checks a wired-up project and names what is missing: the bridge route (wherever you mounted it) exporting both verbs, exactly one `<ConnectProvider>`, the theme imported *after* `@heroui/styles`, and both credentials actually set rather than declared empty. Exits non-zero on a real problem, so it works as a CI step. It is entirely offline — no credentials, no network — which is also its one limit: Connect exposes no endpoint listing a client's registered redirect URIs, so that step is reported as something only you can confirm.
|
|
354
537
|
|
|
355
538
|
Next has no plugin API that adds routes — `next.config` can rewrite and redirect, but both need a route to point at, and the App Router only discovers routes in your own `app/` directory. So this is a scaffolder rather than a plugin: `ConnectPlansPage` makes the file that must exist one line, and `init` writes that line.
|
|
356
539
|
|
|
@@ -390,6 +573,100 @@ const checkout = useConnectCheckout();
|
|
|
390
573
|
|
|
391
574
|
Cancelling uses the same machinery via `buildCancelUrl`. There is no *resume*: Connect has no uncancel path, so a subscription already set to cancel gets a statement of when access ends, not a button that cannot work.
|
|
392
575
|
|
|
576
|
+
### App shell
|
|
577
|
+
|
|
578
|
+
```tsx
|
|
579
|
+
"use client";
|
|
580
|
+
import Link from "next/link";
|
|
581
|
+
import { usePathname } from "next/navigation";
|
|
582
|
+
import { ConnectAppShell } from "@cosmoops/connect-sdk/client";
|
|
583
|
+
import { LayoutDashboard, Receipt, Settings, Truck } from "lucide-react";
|
|
584
|
+
|
|
585
|
+
export function Shell({ children }: { children: React.ReactNode }) {
|
|
586
|
+
return (
|
|
587
|
+
<ConnectAppShell
|
|
588
|
+
name="Dispatch"
|
|
589
|
+
icon={<Truck />}
|
|
590
|
+
activePath={usePathname()}
|
|
591
|
+
renderLink={(props) => <Link {...props} />}
|
|
592
|
+
nav={[
|
|
593
|
+
{ label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
|
|
594
|
+
{ label: "Shipments", href: "/shipments", icon: <Truck className="size-5" /> },
|
|
595
|
+
{
|
|
596
|
+
title: "Workspace",
|
|
597
|
+
items: [
|
|
598
|
+
{
|
|
599
|
+
label: "Settings",
|
|
600
|
+
href: "/settings",
|
|
601
|
+
icon: <Settings className="size-5" />,
|
|
602
|
+
items: [
|
|
603
|
+
{ label: "Members", href: "/settings/members" },
|
|
604
|
+
{ label: "Billing", href: "/settings/billing" },
|
|
605
|
+
],
|
|
606
|
+
},
|
|
607
|
+
{ label: "Plans", href: "/plans", icon: <Receipt className="size-5" />, gateExempt: true },
|
|
608
|
+
],
|
|
609
|
+
},
|
|
610
|
+
]}
|
|
611
|
+
>
|
|
612
|
+
{children}
|
|
613
|
+
</ConnectAppShell>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
That is the entire layout of a service platform. Everything that should look
|
|
619
|
+
identical across the family — the lockup, the rail geometry, the active-route
|
|
620
|
+
treatment, the app launcher, the account menu, the mobile drawer — comes from
|
|
621
|
+
here; only `name`, `icon` and `nav` differ per platform.
|
|
622
|
+
|
|
623
|
+
**Routing is a prop.** The SDK ships into any React app and cannot import
|
|
624
|
+
`next/link`, so `renderLink` is how your router gets involved; the default is a
|
|
625
|
+
plain `<a>`, which works everywhere and costs a full page load. Pass
|
|
626
|
+
`activePath` from your router too (`usePathname()`) — without it the nav reads
|
|
627
|
+
`window.location` and patches `history.pushState` to hear about client-side
|
|
628
|
+
navigations, which is guesswork your router never has to do.
|
|
629
|
+
|
|
630
|
+
**The access gate is on by default.** `ConnectAppShell` is exactly the "wrap
|
|
631
|
+
your app content once" place `ConnectAccessGate` asks for, so it mounts one
|
|
632
|
+
around `children` — an app that ships a shell and forgets the gate is an app
|
|
633
|
+
that lets a suspended workspace keep working. The chrome is never gated: the
|
|
634
|
+
sidebar, the lockup, the account menu and sign-out stay reachable in every
|
|
635
|
+
blocked state. Routes that resolve a block rather than needing one — your
|
|
636
|
+
pricing page — are flagged where they are declared:
|
|
637
|
+
|
|
638
|
+
```tsx
|
|
639
|
+
{ label: "Plans", href: "/plans", gateExempt: true }
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Gate a route any other way and a customer with no subscription reaches the
|
|
643
|
+
page that sells them one, and is told to choose a plan. Pass `gate={false}` to
|
|
644
|
+
opt out entirely, or `gateExemptRoutes` for a path that is not in the sidebar
|
|
645
|
+
at all.
|
|
646
|
+
|
|
647
|
+
Other things worth knowing:
|
|
648
|
+
|
|
649
|
+
- **The rail collapses to icons** and remembers the choice per browser
|
|
650
|
+
(`localStorage`). Drive it yourself with `collapsed` + `onCollapsedChange`,
|
|
651
|
+
or pin it open with `collapsible={false}`.
|
|
652
|
+
- **Below `md` there is no rail**, only a drawer behind the top bar's menu
|
|
653
|
+
button — a 64px icon strip on a phone is a worse drawer, not a smaller one.
|
|
654
|
+
- **Children unfold only on the branch you are on.** A sidebar that opens
|
|
655
|
+
every branch at once is a sitemap.
|
|
656
|
+
- **No `isSignedIn` branch, in here or in yours.** The auth buttons render
|
|
657
|
+
nothing once a session exists, the account menu renders nothing without one,
|
|
658
|
+
and the launcher renders nothing when signed out.
|
|
659
|
+
- `title`, `headerActions`, `sidebarFooter` and `footer` are slots;
|
|
660
|
+
`contentClassName` replaces the content pane's default max-width and
|
|
661
|
+
padding.
|
|
662
|
+
- `theme` / `onThemeChange` pass straight through to `ConnectUserMenu` — the
|
|
663
|
+
SDK has no theme provider of its own, deliberately.
|
|
664
|
+
|
|
665
|
+
`ConnectSideNav` is the nav column alone, for an app with chrome it already
|
|
666
|
+
likes. `resolveActivePath` and `isNavItemActive` are the same pure functions
|
|
667
|
+
the rail highlights with, exported so a breadcrumb or a document title derived
|
|
668
|
+
from your nav cannot disagree with it.
|
|
669
|
+
|
|
393
670
|
### Logo
|
|
394
671
|
|
|
395
672
|
```tsx
|
|
@@ -411,7 +688,7 @@ Each platform passes only what differs — `name` and `icon`. Mark geometry, wor
|
|
|
411
688
|
|
|
412
689
|
One shared **HeroUI v3** theme for every service platform. The components paint with semantic tokens only — `accent`, `muted`, `surface`, `separator`, `danger` — never a hardcoded colour, so one edit to the theme restyles all of them everywhere at once. To diverge on brand, redefine individual tokens *after* the import rather than forking the file.
|
|
413
690
|
|
|
414
|
-
Requires `@heroui/react
|
|
691
|
+
Requires `@heroui/react`, `lucide-react` and `motion` as peers (optional — server-only consumers need none of them). `motion` drives the components' entrance and state transitions, and respects `prefers-reduced-motion`.
|
|
415
692
|
|
|
416
693
|
### Why payment happens on Connect
|
|
417
694
|
|