@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `npx @cosmoops/connect-sdk doctor` - checks a wired-up project for the
|
|
4
|
+
* mistakes that `init` cannot prevent.
|
|
5
|
+
*
|
|
6
|
+
* `init` writes the two files it owns. Everything else in the setup is a step
|
|
7
|
+
* someone performs by hand in a file this package must not rewrite - the
|
|
8
|
+
* provider goes into a layout somebody else wrote, the theme import goes into
|
|
9
|
+
* their stylesheet in a specific position, and the redirect URI lives in
|
|
10
|
+
* Connect rather than in the repo. Those are where setup actually breaks, and
|
|
11
|
+
* each one fails in a way that does not name its cause: a missing provider
|
|
12
|
+
* throws from a hook, a mis-ordered theme import silently loses to HeroUI's
|
|
13
|
+
* defaults, and an unregistered redirect URI only shows up as an OAuth error
|
|
14
|
+
* after a round trip to Connect.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately offline. Connect exposes no endpoint that lists a client's
|
|
17
|
+
* registered redirect URIs, so there is nothing to check remotely that would
|
|
18
|
+
* justify the cost - and an offline check needs no credentials, which is what
|
|
19
|
+
* lets this run in CI on a pull request.
|
|
20
|
+
*
|
|
21
|
+
* The exit code is the point: `fail` means the app is broken and returns 1,
|
|
22
|
+
* `warn` means this cannot tell from the outside and returns 0.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.runDoctor = runDoctor;
|
|
26
|
+
const node_fs_1 = require("node:fs");
|
|
27
|
+
const node_path_1 = require("node:path");
|
|
28
|
+
/** Directories that are never a project's own source, and are large enough to matter. */
|
|
29
|
+
const SKIP = new Set(["node_modules", ".next", ".git", "dist", "build", ".turbo", "coverage"]);
|
|
30
|
+
function walk(dir, extensions, found = [], depth = 0) {
|
|
31
|
+
if (depth > 8 || !(0, node_fs_1.existsSync)(dir))
|
|
32
|
+
return found;
|
|
33
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir)) {
|
|
34
|
+
if (SKIP.has(entry))
|
|
35
|
+
continue;
|
|
36
|
+
const path = (0, node_path_1.join)(dir, entry);
|
|
37
|
+
if ((0, node_fs_1.statSync)(path).isDirectory())
|
|
38
|
+
walk(path, extensions, found, depth + 1);
|
|
39
|
+
else if (extensions.some((extension) => entry.endsWith(extension)))
|
|
40
|
+
found.push(path);
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
function read(path) {
|
|
45
|
+
try {
|
|
46
|
+
return (0, node_fs_1.readFileSync)(path, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function findAppDir(root) {
|
|
53
|
+
for (const candidate of [(0, node_path_1.join)(root, "app"), (0, node_path_1.join)(root, "src", "app")]) {
|
|
54
|
+
if ((0, node_fs_1.existsSync)(candidate))
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The bridge is found by what it exports, not by where it sits.
|
|
61
|
+
*
|
|
62
|
+
* `createConnectHandlers({ basePath })` exists precisely so the route can be
|
|
63
|
+
* mounted somewhere other than `/api/connect`, so checking for that one path
|
|
64
|
+
* would report a false failure on every app that took the option.
|
|
65
|
+
*/
|
|
66
|
+
function checkBridge(appDir, root) {
|
|
67
|
+
const routes = walk(appDir, ["route.ts", "route.tsx", "route.js"]);
|
|
68
|
+
const bridge = routes.find((path) => /@cosmoops\/connect-sdk\/next/.test(read(path)));
|
|
69
|
+
if (!bridge) {
|
|
70
|
+
return {
|
|
71
|
+
status: "fail",
|
|
72
|
+
title: "Bridge route handler",
|
|
73
|
+
detail: 'No route re-exports the bridge. Create app/api/connect/[...connect]/route.ts containing:\n export { GET, POST } from "@cosmoops/connect-sdk/next";\n or run `npx @cosmoops/connect-sdk init`.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const source = read(bridge);
|
|
77
|
+
const exportsBoth = /\bGET\b/.test(source) && /\bPOST\b/.test(source);
|
|
78
|
+
if (!exportsBoth) {
|
|
79
|
+
return {
|
|
80
|
+
status: "fail",
|
|
81
|
+
title: "Bridge route handler",
|
|
82
|
+
detail: `${(0, node_path_1.relative)(root, bridge)} exports only one verb. GET and POST are the same function - the bridge routes on method itself - and sign-out POSTs, so both must be exported.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { status: "pass", title: `Bridge route handler (${(0, node_path_1.relative)(root, bridge)})` };
|
|
86
|
+
}
|
|
87
|
+
function checkProvider(appDir, root) {
|
|
88
|
+
const layouts = walk(appDir, ["layout.tsx", "layout.jsx"]);
|
|
89
|
+
const hosting = layouts.filter((path) => /<ConnectProvider/.test(read(path)));
|
|
90
|
+
if (hosting.length === 0) {
|
|
91
|
+
return {
|
|
92
|
+
status: "fail",
|
|
93
|
+
title: "ConnectProvider in a layout",
|
|
94
|
+
detail: 'Nothing renders <ConnectProvider>. Every client hook and component below it throws without one. In app/layout.tsx:\n import { ConnectProvider } from "@cosmoops/connect-sdk/client";\n <ConnectProvider>{children}</ConnectProvider>',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// A nested provider re-fetches the session and keeps a second cache that the
|
|
98
|
+
// first one's mutations never invalidate, so a plan change updates one half
|
|
99
|
+
// of the page. Worth naming even though both layouts look correct alone.
|
|
100
|
+
if (hosting.length > 1) {
|
|
101
|
+
return {
|
|
102
|
+
status: "warn",
|
|
103
|
+
title: "ConnectProvider in a layout",
|
|
104
|
+
detail: `Found in ${hosting.length} layouts (${hosting.map((path) => (0, node_path_1.relative)(root, path)).join(", ")}). Nested providers keep separate caches, so a mutation in one is invisible to the other. Keep the outermost one only.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { status: "pass", title: `ConnectProvider in a layout (${(0, node_path_1.relative)(root, hosting[0])})` };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Order is the whole check.
|
|
111
|
+
*
|
|
112
|
+
* `theme.css` sets the HeroUI token values Connect's components read. Imported
|
|
113
|
+
* before `@heroui/styles`, HeroUI's own defaults are declared later, win on
|
|
114
|
+
* source order, and the theme silently does nothing - the app looks fine, just
|
|
115
|
+
* not themed. The import also has to live in the app's own stylesheet: from
|
|
116
|
+
* inside node_modules, Tailwind roots its content scan there and never scans
|
|
117
|
+
* the app.
|
|
118
|
+
*/
|
|
119
|
+
function checkTheme(root) {
|
|
120
|
+
const stylesheets = walk(root, [".css"]).filter((path) => !path.includes("node_modules"));
|
|
121
|
+
const importing = stylesheets.filter((path) => read(path).includes("@cosmoops/connect-sdk/theme.css"));
|
|
122
|
+
if (importing.length === 0) {
|
|
123
|
+
return {
|
|
124
|
+
status: "fail",
|
|
125
|
+
title: "Theme stylesheet imported",
|
|
126
|
+
detail: 'No stylesheet imports the Connect theme, so the components render with HeroUI defaults. Add it last of the three, in your own global CSS:\n @import "tailwindcss";\n @import "@heroui/styles";\n @import "@cosmoops/connect-sdk/theme.css";',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
for (const path of importing) {
|
|
130
|
+
const source = read(path);
|
|
131
|
+
const connect = source.indexOf("@cosmoops/connect-sdk/theme.css");
|
|
132
|
+
const heroui = source.indexOf("@heroui/styles");
|
|
133
|
+
if (heroui !== -1 && heroui > connect) {
|
|
134
|
+
return {
|
|
135
|
+
status: "fail",
|
|
136
|
+
title: "Theme stylesheet imported",
|
|
137
|
+
detail: `${(0, node_path_1.relative)(root, path)} imports @heroui/styles *after* the Connect theme, so HeroUI's defaults win on source order and the theme has no effect. Put "@cosmoops/connect-sdk/theme.css" last.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { status: "pass", title: `Theme stylesheet imported (${(0, node_path_1.relative)(root, importing[0])})` };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `.env.local` is read directly rather than trusting `process.env`, because
|
|
145
|
+
* the interesting case is a developer running this in a shell that never
|
|
146
|
+
* loaded it - exactly the shell `next dev` will inherit.
|
|
147
|
+
*/
|
|
148
|
+
function checkEnv(root) {
|
|
149
|
+
const files = [".env.local", ".env.development.local", ".env"].map((name) => (0, node_path_1.join)(root, name));
|
|
150
|
+
const combined = files.filter(node_fs_1.existsSync).map(read).join("\n");
|
|
151
|
+
return ["CONNECT_API_KEY", "CONNECT_CLIENT_ID"].map((name) => {
|
|
152
|
+
// Horizontal whitespace only. `\s*` also matches the newline, so a bare
|
|
153
|
+
// `CONNECT_API_KEY=` was satisfied by the *next* line's value and an empty
|
|
154
|
+
// credential reported as set - which is the exact failure this check exists
|
|
155
|
+
// to catch.
|
|
156
|
+
const assigned = new RegExp(`^[ \\t]*${name}[ \\t]*=[ \\t]*\\S`, "m").test(combined);
|
|
157
|
+
if (assigned || process.env[name]) {
|
|
158
|
+
return { status: "pass", title: `${name} set` };
|
|
159
|
+
}
|
|
160
|
+
const declaredEmpty = new RegExp(`^[ \\t]*${name}[ \\t]*=`, "m").test(combined);
|
|
161
|
+
return {
|
|
162
|
+
status: "fail",
|
|
163
|
+
title: `${name} set`,
|
|
164
|
+
detail: declaredEmpty
|
|
165
|
+
? `Declared but empty in .env.local. The value is in your service's Integration tab in Connect.`
|
|
166
|
+
: `Not found in .env.local or the environment. Both values are in your service's Integration tab in Connect.`,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Nothing here can verify this - Connect has no endpoint listing a client's
|
|
172
|
+
* redirect URIs - but it is the single most common reason a correct setup
|
|
173
|
+
* still fails at sign-in, so it is worth a line that always prints.
|
|
174
|
+
*/
|
|
175
|
+
function checkRedirectUri() {
|
|
176
|
+
return {
|
|
177
|
+
status: "warn",
|
|
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 service's Integration tab - including the deployed origin, not just http://localhost:3000.",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
|
|
183
|
+
function runDoctor(argv = []) {
|
|
184
|
+
const root = (0, node_path_1.resolve)(argv[0] ?? process.cwd());
|
|
185
|
+
const appDir = findAppDir(root);
|
|
186
|
+
if (!appDir) {
|
|
187
|
+
console.error(`[connect-sdk] No App Router directory found in ${root} - looked for "app/" and "src/app/".\n` +
|
|
188
|
+
"Run this from your Next.js project root, or pass the path: npx @cosmoops/connect-sdk doctor ./my-app");
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
console.log(`[connect-sdk] Checking ${(0, node_path_1.relative)(process.cwd(), root) || root}\n`);
|
|
192
|
+
const checks = [
|
|
193
|
+
checkBridge(appDir, root),
|
|
194
|
+
checkProvider(appDir, root),
|
|
195
|
+
checkTheme(root),
|
|
196
|
+
...checkEnv(root),
|
|
197
|
+
checkRedirectUri(),
|
|
198
|
+
];
|
|
199
|
+
for (const check of checks) {
|
|
200
|
+
console.log(` ${MARKER[check.status]} ${check.title}`);
|
|
201
|
+
if (check.detail)
|
|
202
|
+
console.log(` ${check.detail.replace(/\n/g, "\n ")}\n`);
|
|
203
|
+
}
|
|
204
|
+
const failed = checks.filter((check) => check.status === "fail").length;
|
|
205
|
+
console.log(failed === 0
|
|
206
|
+
? "\nNo problems found.\n"
|
|
207
|
+
: `\n${failed} problem${failed === 1 ? "" : "s"} to fix. Run \`npx @cosmoops/connect-sdk init\` to scaffold anything missing.\n`);
|
|
208
|
+
return failed === 0 ? 0 : 1;
|
|
209
|
+
}
|
package/dist/cjs/cli/init.js
CHANGED
|
@@ -27,6 +27,7 @@ exports.runInit = runInit;
|
|
|
27
27
|
exports.main = main;
|
|
28
28
|
const node_fs_1 = require("node:fs");
|
|
29
29
|
const node_path_1 = require("node:path");
|
|
30
|
+
const doctor_js_1 = require("./doctor.js");
|
|
30
31
|
const BRIDGE_ROUTE = `// The Connect bridge - the only place your API key is used.
|
|
31
32
|
//
|
|
32
33
|
// It answers your own origin, so the browser half of the SDK never holds a
|
|
@@ -43,7 +44,7 @@ const PLANS_PAGE = `"use client";
|
|
|
43
44
|
// with the other exports) whenever you want your own copy or layout.
|
|
44
45
|
export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
|
|
45
46
|
`;
|
|
46
|
-
const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's
|
|
47
|
+
const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's Integration tab.
|
|
47
48
|
CONNECT_API_KEY=
|
|
48
49
|
CONNECT_CLIENT_ID=
|
|
49
50
|
`;
|
|
@@ -110,7 +111,7 @@ function runInit(argv = []) {
|
|
|
110
111
|
console.log(` ${marker} ${(0, node_path_1.relative)(root, target)} - ${scaffold.description}`);
|
|
111
112
|
}
|
|
112
113
|
const env = ensureEnv(root);
|
|
113
|
-
console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your service's
|
|
114
|
+
console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your service's Integration tab`);
|
|
114
115
|
// The two steps a scaffolder must not do for you: editing an existing layout
|
|
115
116
|
// means parsing and rewriting JSX someone else wrote, and the redirect URI
|
|
116
117
|
// lives in Connect, not in this repo.
|
|
@@ -136,7 +137,7 @@ Three things left, all one-time:
|
|
|
136
137
|
Tailwind roots its content scan inside node_modules and skips your app.
|
|
137
138
|
|
|
138
139
|
3. Register {your origin}/api/connect/callback as a redirect URI in your
|
|
139
|
-
service's
|
|
140
|
+
service's Integration tab in Connect.
|
|
140
141
|
|
|
141
142
|
No next.config.ts changes needed - not even transpilePackages: dist/ is
|
|
142
143
|
already ES2022 with "use client" hoisted, so Turbopack and webpack consume it
|
|
@@ -145,15 +146,17 @@ as-is.
|
|
|
145
146
|
Then <ConnectPlans />, <ConnectSubscriptionGate>, <ConnectFeatureGate> and the
|
|
146
147
|
rest work anywhere below the provider, and getConnectSession() reads the same
|
|
147
148
|
session from any Server Component.
|
|
149
|
+
|
|
150
|
+
Run \`npx @cosmoops/connect-sdk doctor\` when you have done those three - it
|
|
151
|
+
checks all of them and says which are still outstanding.
|
|
148
152
|
`);
|
|
149
153
|
return 0;
|
|
150
154
|
}
|
|
151
155
|
/**
|
|
152
|
-
*
|
|
153
|
-
* `check` later does not change how the existing one is invoked.
|
|
156
|
+
* `init` writes the wiring; `doctor` checks the parts `init` cannot write.
|
|
154
157
|
*
|
|
155
158
|
* A bare path runs `init` against it, so both `connect-sdk init ./my-app` and
|
|
156
|
-
* `connect-sdk ./my-app` work. A first argument that is neither
|
|
159
|
+
* `connect-sdk ./my-app` work. A first argument that is neither a command nor
|
|
157
160
|
* an existing directory is a typo, and saying so beats reporting that
|
|
158
161
|
* `./innit` contains no App Router - which is true, unhelpful, and points at
|
|
159
162
|
* the wrong mistake.
|
|
@@ -161,13 +164,22 @@ session from any Server Component.
|
|
|
161
164
|
function main(argv) {
|
|
162
165
|
const [first, ...rest] = argv;
|
|
163
166
|
if (first === "--help" || first === "-h") {
|
|
164
|
-
console.log(
|
|
167
|
+
console.log([
|
|
168
|
+
"Usage: connect-sdk <command> [project-dir]",
|
|
169
|
+
"",
|
|
170
|
+
" init scaffold the bridge route, /plans page and .env.local",
|
|
171
|
+
" doctor check an existing setup and report what is missing",
|
|
172
|
+
"",
|
|
173
|
+
"With no command, runs init against the given directory (default: cwd).",
|
|
174
|
+
].join("\n"));
|
|
165
175
|
return 0;
|
|
166
176
|
}
|
|
167
177
|
if (first === "init")
|
|
168
178
|
return runInit(rest);
|
|
179
|
+
if (first === "doctor" || first === "check")
|
|
180
|
+
return (0, doctor_js_1.runDoctor)(rest);
|
|
169
181
|
if (!first || (0, node_fs_1.existsSync)(first))
|
|
170
182
|
return runInit(argv);
|
|
171
|
-
console.error(`[connect-sdk] Unknown command "${first}". Try: connect-sdk init`);
|
|
183
|
+
console.error(`[connect-sdk] Unknown command "${first}". Try: connect-sdk init, or connect-sdk doctor`);
|
|
172
184
|
return 1;
|
|
173
185
|
}
|
|
@@ -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 === "organization_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 service 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 service.", 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
|
+
}
|
|
@@ -7,6 +7,8 @@ const react_1 = require("react");
|
|
|
7
7
|
const react_2 = require("@heroui/react");
|
|
8
8
|
const lucide_react_1 = require("lucide-react");
|
|
9
9
|
const provider_js_1 = require("../provider.js");
|
|
10
|
+
const motion_js_1 = require("./motion.js");
|
|
11
|
+
const surfaces_js_1 = require("./surfaces.js");
|
|
10
12
|
const format_relative_time_js_1 = require("../../format-relative-time.js");
|
|
11
13
|
const DEVICE_ICONS = {
|
|
12
14
|
desktop: lucide_react_1.Monitor,
|
|
@@ -78,13 +80,13 @@ function ConnectAccountSessions({ className, maxVisible = 4 }) {
|
|
|
78
80
|
const [isRevokingOthers, setIsRevokingOthers] = (0, react_1.useState)(false);
|
|
79
81
|
const [showAll, setShowAll] = (0, react_1.useState)(false);
|
|
80
82
|
if (isLoading) {
|
|
81
|
-
return ((0, jsx_runtime_1.jsxs)("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsx)(react_2.Skeleton, { className: "h-4 w-28" }), (0, jsx_runtime_1.jsx)("div", { className:
|
|
83
|
+
return ((0, jsx_runtime_1.jsxs)("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsx)(react_2.Skeleton, { className: "h-4 w-28" }), (0, jsx_runtime_1.jsx)("div", { className: `flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border ${surfaces_js_1.PANEL_FILL}`, children: Array.from({ length: 2 }, (_, index) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3 px-3 py-3", children: [(0, jsx_runtime_1.jsx)(react_2.Skeleton, { className: "size-9 shrink-0 rounded-xl" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(react_2.Skeleton, { className: "h-3.5 w-32" }), (0, jsx_runtime_1.jsx)(react_2.Skeleton, { className: "h-3 w-24" })] })] }, index))) })] }));
|
|
82
84
|
}
|
|
83
85
|
// A failed read used to render as *nothing*, which is indistinguishable
|
|
84
86
|
// from "you have no other devices" - the one reading of this list that
|
|
85
87
|
// should never be guessed at.
|
|
86
88
|
if (error) {
|
|
87
|
-
return ((0, jsx_runtime_1.jsxs)("section", { className: `flex flex-col gap-2 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm font-medium text-foreground", children: "Active sessions" }), (0, jsx_runtime_1.jsx)("p", { className:
|
|
89
|
+
return ((0, jsx_runtime_1.jsxs)("section", { className: `flex flex-col gap-2 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm font-medium text-foreground", children: "Active sessions" }), (0, jsx_runtime_1.jsx)("p", { className: `rounded-xl border border-border ${surfaces_js_1.PANEL_FILL} px-3 py-4 text-xs text-muted`, children: "Could not load your sessions. Try again in a moment." })] }));
|
|
88
90
|
}
|
|
89
91
|
const sessions = ordered(data?.items ?? []);
|
|
90
92
|
if (sessions.length === 0)
|
|
@@ -92,21 +94,21 @@ function ConnectAccountSessions({ className, maxVisible = 4 }) {
|
|
|
92
94
|
const hasOtherSessions = sessions.some((session) => !session.isCurrent);
|
|
93
95
|
const isCollapsible = maxVisible > 0 && sessions.length > maxVisible;
|
|
94
96
|
const visible = isCollapsible && !showAll ? sessions.slice(0, maxVisible) : sessions;
|
|
95
|
-
return ((0, jsx_runtime_1.jsxs)("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start justify-between gap-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsxs)("p", { className: "flex items-center gap-2 text-sm font-medium text-foreground", children: ["Active sessions", (0, jsx_runtime_1.jsx)(react_2.Chip, { size: "sm", variant: "soft", children: sessions.length })] }), (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: "Devices signed in to your Connect account." })] }), hasOtherSessions && ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "tertiary", className: "shrink-0 text-xs text-danger", isPending: isRevokingOthers, isDisabled: isRevokingOthers, onPress: () => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
97
|
+
return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start justify-between gap-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsxs)("p", { className: "flex items-center gap-2 text-sm font-medium text-foreground", children: ["Active sessions", (0, jsx_runtime_1.jsx)(react_2.Chip, { size: "sm", variant: "soft", children: sessions.length })] }), (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: "Devices signed in to your Connect account." })] }), hasOtherSessions && ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "tertiary", className: "shrink-0 text-xs text-danger", isPending: isRevokingOthers, isDisabled: isRevokingOthers, onPress: () => {
|
|
98
|
+
setIsRevokingOthers(true);
|
|
99
|
+
void mutateResource("/sessions/revoke-others")
|
|
100
|
+
.then(() => {
|
|
101
|
+
invalidateResource("/sessions");
|
|
102
|
+
refetch();
|
|
103
|
+
})
|
|
104
|
+
.finally(() => setIsRevokingOthers(false));
|
|
105
|
+
}, children: "Sign out others" }))] }), (0, jsx_runtime_1.jsx)("div", { className: `flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border ${surfaces_js_1.PANEL_FILL}`, children: (0, jsx_runtime_1.jsx)(motion_js_1.AnimatePresence, { initial: false, children: visible.map((session) => {
|
|
106
|
+
const Icon = DEVICE_ICONS[session.device.deviceType];
|
|
107
|
+
return ((0, jsx_runtime_1.jsxs)(motion_js_1.m.div, { layout: true, exit: { opacity: 0, height: 0, overflow: "hidden" }, transition: { duration: 0.25, ease: motion_js_1.EASE_OUT_QUART }, className: "flex items-center gap-3 px-3 py-2.5", children: [(0, jsx_runtime_1.jsx)("span", { className: `flex size-9 shrink-0 items-center justify-center rounded-xl ${session.isCurrent ? "bg-accent-soft text-accent-soft-foreground" : "bg-surface text-muted"}`, children: (0, jsx_runtime_1.jsx)(Icon, { className: surfaces_js_1.ICON, "aria-hidden": "true" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)("span", { className: "truncate text-sm font-medium text-foreground", children: deviceLabel(session) }), session.isCurrent && ((0, jsx_runtime_1.jsx)(react_2.Chip, { size: "sm", variant: "soft", color: "accent", className: "shrink-0", children: "This device" }))] }), (0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: metaLine(session) })] }), !session.isCurrent && ((0, jsx_runtime_1.jsx)(RevokeRow, { session: session, onRevoke: () => {
|
|
108
|
+
void mutateResource("/sessions/revoke", { sessionId: session.id }).then(() => {
|
|
109
|
+
invalidateResource("/sessions");
|
|
110
|
+
refetch();
|
|
111
|
+
});
|
|
112
|
+
} }))] }, session.id));
|
|
113
|
+
}) }) }), isCollapsible && ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "tertiary", className: "self-start text-xs", onPress: () => setShowAll((open) => !open), children: showAll ? "Show fewer" : `Show all ${sessions.length} devices` }))] }) }));
|
|
112
114
|
}
|