@cosmoops/connect-sdk 0.3.0 → 0.7.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 +24 -22
- package/dist/cjs/client/components/app-shell.js +157 -0
- package/dist/cjs/client/components/apps-grid.js +4 -3
- package/dist/cjs/client/components/feature-gate.js +2 -2
- package/dist/cjs/client/components/integrations-modal.js +95 -0
- package/dist/cjs/client/components/logo.js +3 -3
- package/dist/cjs/client/components/motion.js +27 -0
- package/dist/cjs/client/components/nav.js +97 -0
- package/dist/cjs/client/components/plan-pricing.js +1 -1
- package/dist/cjs/client/components/plans.js +42 -40
- package/dist/cjs/client/components/readiness.js +6 -4
- package/dist/cjs/client/components/side-nav.js +58 -0
- package/dist/cjs/client/components/subscribe-dialog.js +2 -2
- package/dist/cjs/client/components/subscription.js +3 -2
- package/dist/cjs/client/components/surfaces.js +54 -0
- package/dist/cjs/client/components/usage-meter.js +4 -3
- package/dist/cjs/client/components/user-menu.js +33 -6
- package/dist/cjs/client/index.js +30 -2
- package/dist/cjs/client/mark.js +1 -1
- package/dist/cjs/client/provider.js +60 -8
- package/dist/cjs/client/use-active-path.js +77 -0
- package/dist/cjs/client/use-checkout.js +3 -3
- package/dist/cjs/errors.js +4 -4
- package/dist/cjs/helpers.js +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/next/index.js +96 -15
- package/dist/cjs/server/client.js +22 -14
- package/dist/cjs/server/handler.js +99 -16
- package/dist/cjs/server/http.js +1 -1
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/server/resources/access.js +3 -3
- package/dist/cjs/server/resources/ai.js +1 -1
- package/dist/cjs/server/resources/analytics.js +66 -0
- package/dist/cjs/server/resources/apps.js +4 -4
- package/dist/cjs/server/resources/billing.js +5 -5
- package/dist/cjs/server/resources/drive.js +4 -4
- package/dist/cjs/server/resources/entitlements.js +7 -7
- package/dist/cjs/server/resources/identity.js +4 -4
- package/dist/cjs/server/resources/integrations.js +66 -0
- package/dist/cjs/server/resources/logs.js +3 -3
- package/dist/cjs/server/resources/sessions.js +6 -6
- package/dist/cjs/server/webhooks.js +127 -0
- package/dist/cjs/types.js +1 -1
- 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 +24 -22
- package/dist/esm/client/components/app-shell.js +154 -0
- package/dist/esm/client/components/apps-grid.js +4 -3
- package/dist/esm/client/components/feature-gate.js +2 -2
- package/dist/esm/client/components/integrations-modal.js +92 -0
- package/dist/esm/client/components/logo.js +3 -3
- package/dist/esm/client/components/motion.js +21 -0
- package/dist/esm/client/components/nav.js +89 -0
- package/dist/esm/client/components/plan-pricing.js +1 -1
- package/dist/esm/client/components/plans.js +42 -40
- package/dist/esm/client/components/readiness.js +6 -4
- package/dist/esm/client/components/side-nav.js +53 -0
- package/dist/esm/client/components/subscribe-dialog.js +2 -2
- package/dist/esm/client/components/subscription.js +3 -2
- package/dist/esm/client/components/surfaces.js +51 -0
- package/dist/esm/client/components/usage-meter.js +4 -3
- package/dist/esm/client/components/user-menu.js +34 -7
- package/dist/esm/client/index.js +16 -2
- package/dist/esm/client/mark.js +1 -1
- package/dist/esm/client/provider.js +59 -8
- package/dist/esm/client/use-active-path.js +74 -0
- package/dist/esm/client/use-checkout.js +3 -3
- package/dist/esm/errors.js +4 -4
- package/dist/esm/helpers.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/next/index.js +95 -15
- package/dist/esm/server/client.js +22 -14
- package/dist/esm/server/handler.js +99 -16
- package/dist/esm/server/http.js +1 -1
- package/dist/esm/server/index.js +5 -0
- package/dist/esm/server/resources/access.js +3 -3
- package/dist/esm/server/resources/ai.js +1 -1
- package/dist/esm/server/resources/analytics.js +62 -0
- package/dist/esm/server/resources/apps.js +4 -4
- package/dist/esm/server/resources/billing.js +5 -5
- package/dist/esm/server/resources/drive.js +4 -4
- package/dist/esm/server/resources/entitlements.js +7 -7
- package/dist/esm/server/resources/identity.js +4 -4
- package/dist/esm/server/resources/integrations.js +62 -0
- package/dist/esm/server/resources/logs.js +3 -3
- package/dist/esm/server/resources/sessions.js +6 -6
- package/dist/esm/server/webhooks.js +123 -0
- package/dist/esm/types.js +1 -1
- package/dist/theme.css +28 -0
- package/dist/types/client/components/access-gate.d.ts +79 -0
- package/dist/types/client/components/account-sessions.d.ts +1 -1
- package/dist/types/client/components/app-shell.d.ts +115 -0
- package/dist/types/client/components/apps-grid.d.ts +1 -1
- package/dist/types/client/components/feature-gate.d.ts +1 -1
- package/dist/types/client/components/integrations-modal.d.ts +21 -0
- package/dist/types/client/components/logo.d.ts +5 -5
- 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/plan-pricing.d.ts +2 -2
- package/dist/types/client/components/readiness.d.ts +2 -2
- package/dist/types/client/components/side-nav.d.ts +31 -0
- package/dist/types/client/components/subscribe-dialog.d.ts +3 -3
- package/dist/types/client/components/surfaces.d.ts +51 -0
- package/dist/types/client/components/usage-meter.d.ts +1 -1
- package/dist/types/client/components/user-menu.d.ts +2 -2
- package/dist/types/client/index.d.ts +13 -2
- package/dist/types/client/mark.d.ts +1 -1
- package/dist/types/client/provider.d.ts +44 -9
- package/dist/types/client/use-active-path.d.ts +8 -0
- package/dist/types/client/use-checkout.d.ts +3 -3
- package/dist/types/errors.d.ts +4 -4
- package/dist/types/helpers.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/next/index.d.ts +40 -0
- package/dist/types/server/client.d.ts +17 -11
- package/dist/types/server/handler.d.ts +2 -2
- package/dist/types/server/index.d.ts +7 -0
- package/dist/types/server/resources/access.d.ts +3 -3
- package/dist/types/server/resources/ai.d.ts +2 -2
- package/dist/types/server/resources/analytics.d.ts +31 -0
- package/dist/types/server/resources/apps.d.ts +4 -4
- package/dist/types/server/resources/billing.d.ts +4 -4
- package/dist/types/server/resources/drive.d.ts +4 -4
- package/dist/types/server/resources/entitlements.d.ts +4 -4
- package/dist/types/server/resources/identity.d.ts +14 -4
- package/dist/types/server/resources/integrations.d.ts +40 -0
- package/dist/types/server/resources/logs.d.ts +3 -3
- package/dist/types/server/resources/sessions.d.ts +5 -5
- package/dist/types/server/webhooks.d.ts +67 -0
- package/dist/types/types.d.ts +220 -39
- package/package.json +10 -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.
|
|
@@ -344,11 +405,16 @@ that URL yourself instead.
|
|
|
344
405
|
|
|
345
406
|
```tsx
|
|
346
407
|
import {
|
|
408
|
+
ConnectAppShell,
|
|
347
409
|
ConnectLoginButton, ConnectRegisterButton,
|
|
348
410
|
ConnectUserMenu, ConnectAppsGrid, ConnectPlans,
|
|
349
411
|
} from "@cosmoops/connect-sdk/client";
|
|
350
412
|
|
|
351
|
-
//
|
|
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.
|
|
352
418
|
<ConnectAppsGrid />
|
|
353
419
|
<ConnectLoginButton />
|
|
354
420
|
<ConnectUserMenu />
|
|
@@ -359,12 +425,15 @@ import {
|
|
|
359
425
|
|
|
360
426
|
| Component | What it does |
|
|
361
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. |
|
|
362
430
|
| `ConnectLoginButton` · `ConnectRegisterButton` | "Continue with Connect", in the shape people know from Google. Render nothing when signed in. |
|
|
363
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. |
|
|
364
432
|
| `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of every app this workspace can open. |
|
|
365
433
|
| `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
|
|
366
434
|
| `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
|
|
367
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. |
|
|
368
437
|
| `ConnectSubscriptionGate` | Renders your module for a subscribed workspace, and `ConnectNoSubscriptionCard` for everyone else. Never flashes the upsell while loading. |
|
|
369
438
|
| `ConnectNoSubscriptionCard` · `ConnectSubscriptionCard` | The same slot, unsubscribed and subscribed. Plan, status, renewal date, seats, and one way into Connect. |
|
|
370
439
|
| `ConnectReadinessBanner` · `ConnectReadinessGate` | Every gate between this workspace and your service — KYC, payment method, seat headroom — each with the Connect screen that fixes it. |
|
|
@@ -377,7 +446,7 @@ import {
|
|
|
377
446
|
|
|
378
447
|
### Hooks
|
|
379
448
|
|
|
380
|
-
`useConnect` · `useConnectUser` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
|
|
449
|
+
`useConnect` · `useConnectUser` · `useActivePath` · `useConnectAccessGate` · `useConnectEntitlements` · `useConnectPlans` · `useConnectApps` · `useConnectAddonUsage` · `useConnectSessions` · `useConnectResource` · `useConnectFeature` · `useConnectCheckout`
|
|
381
450
|
|
|
382
451
|
### Metering
|
|
383
452
|
|
|
@@ -396,13 +465,59 @@ The catalogue read (`useConnectPlans`, `<ConnectPlans />`) reports `features` as
|
|
|
396
465
|
|
|
397
466
|
### Gates
|
|
398
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
|
+
|
|
399
514
|
```tsx
|
|
400
515
|
<ConnectReadinessBanner /> {/* KYC, payment method, seats — each with a fix */}
|
|
401
516
|
<ConnectSubscriptionGate><Dashboard /></ConnectSubscriptionGate>
|
|
402
517
|
<ConnectFeatureGate feature="advanced_reports"><Reports /></ConnectFeatureGate>
|
|
403
518
|
```
|
|
404
519
|
|
|
405
|
-
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.
|
|
406
521
|
|
|
407
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)`.
|
|
408
523
|
|
|
@@ -458,6 +573,100 @@ const checkout = useConnectCheckout();
|
|
|
458
573
|
|
|
459
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.
|
|
460
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
|
+
|
|
461
670
|
### Logo
|
|
462
671
|
|
|
463
672
|
```tsx
|
|
@@ -479,7 +688,7 @@ Each platform passes only what differs — `name` and `icon`. Mark geometry, wor
|
|
|
479
688
|
|
|
480
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.
|
|
481
690
|
|
|
482
|
-
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`.
|
|
483
692
|
|
|
484
693
|
### Why payment happens on Connect
|
|
485
694
|
|
package/dist/cjs/cli/doctor.js
CHANGED
|
@@ -162,8 +162,8 @@ function checkEnv(root) {
|
|
|
162
162
|
status: "fail",
|
|
163
163
|
title: `${name} set`,
|
|
164
164
|
detail: declaredEmpty
|
|
165
|
-
? `Declared but empty in .env.local. The value is in your
|
|
166
|
-
: `Not found in .env.local or the environment. Both values are in your
|
|
165
|
+
? `Declared but empty in .env.local. The value is in your application's Integration tab in Connect.`
|
|
166
|
+
: `Not found in .env.local or the environment. Both values are in your application's Integration tab in Connect.`,
|
|
167
167
|
};
|
|
168
168
|
});
|
|
169
169
|
}
|
|
@@ -176,7 +176,7 @@ function checkRedirectUri() {
|
|
|
176
176
|
return {
|
|
177
177
|
status: "warn",
|
|
178
178
|
title: "Redirect URI registered in Connect",
|
|
179
|
-
detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your
|
|
179
|
+
detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your application's Integration tab - including the deployed origin, not just http://localhost:3000.",
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
|
package/dist/cjs/cli/init.js
CHANGED
|
@@ -44,7 +44,7 @@ const PLANS_PAGE = `"use client";
|
|
|
44
44
|
// with the other exports) whenever you want your own copy or layout.
|
|
45
45
|
export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
|
|
46
46
|
`;
|
|
47
|
-
const ENV_TEMPLATE = `# CosmoOps Connect - values come from your
|
|
47
|
+
const ENV_TEMPLATE = `# CosmoOps Connect - values come from your application's Integration tab.
|
|
48
48
|
CONNECT_API_KEY=
|
|
49
49
|
CONNECT_CLIENT_ID=
|
|
50
50
|
`;
|
|
@@ -111,7 +111,7 @@ function runInit(argv = []) {
|
|
|
111
111
|
console.log(` ${marker} ${(0, node_path_1.relative)(root, target)} - ${scaffold.description}`);
|
|
112
112
|
}
|
|
113
113
|
const env = ensureEnv(root);
|
|
114
|
-
console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your
|
|
114
|
+
console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your application's Integration tab`);
|
|
115
115
|
// The two steps a scaffolder must not do for you: editing an existing layout
|
|
116
116
|
// means parsing and rewriting JSX someone else wrote, and the redirect URI
|
|
117
117
|
// lives in Connect, not in this repo.
|
|
@@ -137,7 +137,7 @@ Three things left, all one-time:
|
|
|
137
137
|
Tailwind roots its content scan inside node_modules and skips your app.
|
|
138
138
|
|
|
139
139
|
3. Register {your origin}/api/connect/callback as a redirect URI in your
|
|
140
|
-
|
|
140
|
+
application's Integration tab in Connect.
|
|
141
141
|
|
|
142
142
|
No next.config.ts changes needed - not even transpilePackages: dist/ is
|
|
143
143
|
already ES2022 with "use client" hoisted, so Turbopack and webpack consume it
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.useConnectAccessGate = useConnectAccessGate;
|
|
5
|
+
exports.ConnectAccessGate = ConnectAccessGate;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const react_2 = require("@heroui/react");
|
|
10
|
+
const helpers_js_1 = require("../../helpers.js");
|
|
11
|
+
const provider_js_1 = require("../provider.js");
|
|
12
|
+
const motion_js_1 = require("./motion.js");
|
|
13
|
+
const readiness_js_1 = require("./readiness.js");
|
|
14
|
+
function formatDate(iso) {
|
|
15
|
+
return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The single composed answer to "can this workspace use my app right now,
|
|
19
|
+
* and if not, which of the four things is it": choose a plan, wait on
|
|
20
|
+
* approval, resolve an overdue payment, or - a suspended workspace - none of
|
|
21
|
+
* the above, because nothing short of a platform admin fixes that one.
|
|
22
|
+
*
|
|
23
|
+
* Reuses the existing single sources of truth rather than re-deriving them:
|
|
24
|
+
* `blockingBlockers()` (`helpers.ts`) for severity, the same readiness read
|
|
25
|
+
* `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
|
|
26
|
+
* is a priority ordering over the same data, not a second opinion about it.
|
|
27
|
+
*
|
|
28
|
+
* Priority, first match wins: `suspended` > `no_subscription` >
|
|
29
|
+
* `onboarding_pending` > any other blocking code (`blocked`) >
|
|
30
|
+
* `payment_retry` (advisory - `children` still render) > `clear`.
|
|
31
|
+
*/
|
|
32
|
+
function useConnectAccessGate() {
|
|
33
|
+
const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
|
|
34
|
+
const { data, isLoading: entitlementsLoading } = (0, provider_js_1.useConnectEntitlements)();
|
|
35
|
+
if (sessionLoading || (session?.user && entitlementsLoading)) {
|
|
36
|
+
return { state: "loading", blocker: null, paymentRetry: null };
|
|
37
|
+
}
|
|
38
|
+
if (!session?.user) {
|
|
39
|
+
return { state: "signed_out", blocker: null, paymentRetry: null };
|
|
40
|
+
}
|
|
41
|
+
if (!data) {
|
|
42
|
+
// Signed in, not loading, yet nothing came back - a failed read (see
|
|
43
|
+
// `error` on `useConnectEntitlements`). Fail open rather than hard-block
|
|
44
|
+
// the whole app on a network hiccup; `ConnectReadinessBanner` makes the
|
|
45
|
+
// same call for its own "no data" branch.
|
|
46
|
+
return { state: "clear", blocker: null, paymentRetry: null };
|
|
47
|
+
}
|
|
48
|
+
const readiness = data.readiness;
|
|
49
|
+
const blocking = (0, helpers_js_1.blockingBlockers)(readiness);
|
|
50
|
+
const suspended = blocking.find((b) => b.code === "workspace_suspended");
|
|
51
|
+
if (suspended)
|
|
52
|
+
return { state: "suspended", blocker: suspended, paymentRetry: null };
|
|
53
|
+
const noSubscription = blocking.find((b) => b.code === "subscription_missing");
|
|
54
|
+
if (noSubscription)
|
|
55
|
+
return { state: "no_subscription", blocker: noSubscription, paymentRetry: null };
|
|
56
|
+
const onboardingPending = blocking.find((b) => b.code === "onboarding_approval_pending");
|
|
57
|
+
if (onboardingPending) {
|
|
58
|
+
return { state: "onboarding_pending", blocker: onboardingPending, paymentRetry: null };
|
|
59
|
+
}
|
|
60
|
+
if (blocking.length > 0) {
|
|
61
|
+
return { state: "blocked", blocker: blocking[0] ?? null, paymentRetry: null };
|
|
62
|
+
}
|
|
63
|
+
const paymentRetry = readiness.readiness.blockers.find((b) => b.code === "payment_retry_pending");
|
|
64
|
+
if (paymentRetry)
|
|
65
|
+
return { state: "payment_retry", blocker: null, paymentRetry };
|
|
66
|
+
return { state: "clear", blocker: null, paymentRetry: null };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Wrap your app's content once - typically in your root layout, alongside
|
|
70
|
+
* (not instead of) your own header/nav - and it renders exactly one of: the
|
|
71
|
+
* hard-block card for a suspended workspace, one with no plan, or one
|
|
72
|
+
* waiting on approval; your `children` plus a dismissible payment-due banner
|
|
73
|
+
* during an automatic dunning retry; or just `children` once everything is
|
|
74
|
+
* clear.
|
|
75
|
+
*
|
|
76
|
+
* **Do not wrap your own plans/pricing page with this.** The `no_subscription`
|
|
77
|
+
* card's button sends someone to Connect's subscribe page directly (see
|
|
78
|
+
* `GateCard`'s doc comment on why - it used to link to your in-app `/plans`
|
|
79
|
+
* instead, which is a dead end once this gate hides that very page too), but
|
|
80
|
+
* a customer can still navigate to your plans page on their own (a nav link,
|
|
81
|
+
* a bookmark) and must actually see it there. Since the gate is mounted once
|
|
82
|
+
* around every route, carving out that exception is your router's job, not
|
|
83
|
+
* a prop on this component: check the current path in your layout/shell and
|
|
84
|
+
* skip wrapping `children` in `<ConnectAccessGate>` for that one route.
|
|
85
|
+
*
|
|
86
|
+
* **Signed-out renders `children`, not a fallback** - deliberately different
|
|
87
|
+
* from `ConnectReadinessGate`/`ConnectSubscriptionGate`, which show nothing
|
|
88
|
+
* until signed in. None of the four states this component composes is a
|
|
89
|
+
* property of a signed-out visitor, and a component mounted around your
|
|
90
|
+
* entire app must not hide a public landing page behind an empty gate. Your
|
|
91
|
+
* own sign-in flow decides what a signed-out visitor sees.
|
|
92
|
+
*
|
|
93
|
+
* **This is a UX gate, not a security boundary** - same caveat as every
|
|
94
|
+
* other gate in this package. The authoritative check is
|
|
95
|
+
* `client.entitlements.getReadiness` on your server, before you do the
|
|
96
|
+
* thing.
|
|
97
|
+
*/
|
|
98
|
+
function ConnectAccessGate({ children, fallback, loading, className }) {
|
|
99
|
+
const gate = useConnectAccessGate();
|
|
100
|
+
if (gate.state === "loading") {
|
|
101
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: loading ?? (0, jsx_runtime_1.jsx)(react_2.Skeleton, { className: `h-32 rounded-xl ${className ?? ""}` }) });
|
|
102
|
+
}
|
|
103
|
+
if (gate.state === "signed_out" || gate.state === "clear") {
|
|
104
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: children });
|
|
105
|
+
}
|
|
106
|
+
if (gate.state === "payment_retry") {
|
|
107
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [gate.paymentRetry && (0, jsx_runtime_1.jsx)(PaymentRetryBanner, { blocker: gate.paymentRetry, className: className }), children] }));
|
|
108
|
+
}
|
|
109
|
+
return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? (0, jsx_runtime_1.jsx)(AccessGateFallback, { state: gate.state, blocker: gate.blocker, className: className }) }));
|
|
110
|
+
}
|
|
111
|
+
function AccessGateFallback({ state, blocker, className, }) {
|
|
112
|
+
if (state === "no_subscription") {
|
|
113
|
+
return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.CreditCard, iconClassName: "text-accent", title: "Choose a plan to continue", message: blocker?.message ?? "This workspace isn't subscribed to this application yet.", actionLabel: "See plans", resolveUrl: blocker?.resolveUrl ?? null, className: className }));
|
|
114
|
+
}
|
|
115
|
+
if (state === "suspended") {
|
|
116
|
+
return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.Ban, iconClassName: "text-danger", title: "Workspace suspended", message: blocker?.message ?? "This workspace has been suspended.", resolveUrl: null, className: className }));
|
|
117
|
+
}
|
|
118
|
+
if (state === "onboarding_pending") {
|
|
119
|
+
return ((0, jsx_runtime_1.jsx)(GateCard, { icon: lucide_react_1.Clock, iconClassName: "text-muted", title: "Waiting on approval", message: blocker?.message ?? "This workspace is waiting on approval before it can use this application.", resolveUrl: null, className: className }));
|
|
120
|
+
}
|
|
121
|
+
// "blocked" - any other blocking code (verification, audience, seats, ...).
|
|
122
|
+
// Full fidelity for a code this component doesn't special-case: the same
|
|
123
|
+
// banner-in-a-card `ConnectReadinessGate` falls back to, rather than
|
|
124
|
+
// `GateCard`'s single-blocker shape - there can be more than one of these
|
|
125
|
+
// at once, and each already carries its own action.
|
|
126
|
+
return ((0, jsx_runtime_1.jsx)(react_2.Card, { className: className, children: (0, jsx_runtime_1.jsx)(react_2.Card.Content, { className: "py-6", children: (0, jsx_runtime_1.jsx)(readiness_js_1.ConnectReadinessBanner, {}) }) }));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* One card shape for every hard-block state: an icon, what's wrong, and -
|
|
130
|
+
* when the server sent a `resolveUrl` - the one button that fixes it,
|
|
131
|
+
* opening Connect directly in a new tab. The same pattern every other
|
|
132
|
+
* resolvable blocker in this package uses (`BlockerRow`, `PaymentRetryBanner`
|
|
133
|
+
* below) - `no_subscription` used to be the one exception, sending someone
|
|
134
|
+
* to the host app's own `/plans` page instead. That was a deliberate choice
|
|
135
|
+
* on `ConnectNoSubscriptionCard` (browsing plans belongs in your product),
|
|
136
|
+
* but inside a gate mounted around your *entire* app it is a dead end: the
|
|
137
|
+
* card replaces whatever page it is rendered on, including your own plans
|
|
138
|
+
* page if you did not carve it out (see `ConnectAccessGate`'s doc comment),
|
|
139
|
+
* so "see plans" landing back on the same blocked screen never gets anyone
|
|
140
|
+
* to a choice they can act on. Sending them to Connect's own subscribe page
|
|
141
|
+
* always resolves.
|
|
142
|
+
*/
|
|
143
|
+
function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
|
|
144
|
+
return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_2.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)(react_2.Card.Title, { children: title })] }), (0, jsx_runtime_1.jsx)(react_2.Card.Description, { children: message })] }), resolveUrl && ((0, jsx_runtime_1.jsx)(react_2.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "primary", onPress: () => window.open(resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel ?? "Resolve on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The skippable payment-due warning. Dismissal is scoped to this specific
|
|
148
|
+
* dunning cycle - keyed by `graceEndsAt`, so a dismissed banner reappears on
|
|
149
|
+
* its own if a *new* retry cycle starts (a different `graceEndsAt`) even
|
|
150
|
+
* though an old dismissal is still on record.
|
|
151
|
+
*/
|
|
152
|
+
function PaymentRetryBanner({ blocker, className, }) {
|
|
153
|
+
const dismissKey = blocker.graceEndsAt ? `connect_dismissed_payment_retry_pending_${blocker.graceEndsAt}` : null;
|
|
154
|
+
const [dismissed, setDismissed] = (0, react_1.useState)(() => dismissKey !== null && typeof window !== "undefined" && window.localStorage.getItem(dismissKey) === "1");
|
|
155
|
+
if (dismissed)
|
|
156
|
+
return null;
|
|
157
|
+
return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(motion_js_1.m.div, { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent ${className ?? ""}`, role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: ["Resolve payment", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
|
|
158
|
+
setDismissed(true);
|
|
159
|
+
if (dismissKey && typeof window !== "undefined")
|
|
160
|
+
window.localStorage.setItem(dismissKey, "1");
|
|
161
|
+
}, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] })] }) }));
|
|
162
|
+
}
|