@cosmoops/connect-sdk 0.1.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.
Files changed (126) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +446 -0
  3. package/dist/cjs/cli/bin.js +16 -0
  4. package/dist/cjs/cli/init.js +179 -0
  5. package/dist/cjs/client/components/account-sessions.js +112 -0
  6. package/dist/cjs/client/components/apps-grid.js +46 -0
  7. package/dist/cjs/client/components/auth-buttons.js +42 -0
  8. package/dist/cjs/client/components/feature-gate.js +61 -0
  9. package/dist/cjs/client/components/link-button.js +24 -0
  10. package/dist/cjs/client/components/logo.js +27 -0
  11. package/dist/cjs/client/components/plan-pricing.js +166 -0
  12. package/dist/cjs/client/components/plans-page.js +39 -0
  13. package/dist/cjs/client/components/plans.js +212 -0
  14. package/dist/cjs/client/components/readiness.js +117 -0
  15. package/dist/cjs/client/components/subscribe-dialog.js +60 -0
  16. package/dist/cjs/client/components/subscription.js +189 -0
  17. package/dist/cjs/client/components/usage-meter.js +68 -0
  18. package/dist/cjs/client/components/user-menu.js +172 -0
  19. package/dist/cjs/client/index.js +99 -0
  20. package/dist/cjs/client/mark.js +16 -0
  21. package/dist/cjs/client/provider.js +259 -0
  22. package/dist/cjs/client/use-checkout.js +173 -0
  23. package/dist/cjs/errors.js +74 -0
  24. package/dist/cjs/format-relative-time.js +18 -0
  25. package/dist/cjs/helpers.js +30 -0
  26. package/dist/cjs/index.js +22 -0
  27. package/dist/cjs/next/index.js +311 -0
  28. package/dist/cjs/package.json +3 -0
  29. package/dist/cjs/server/client.js +114 -0
  30. package/dist/cjs/server/handler.js +333 -0
  31. package/dist/cjs/server/http.js +160 -0
  32. package/dist/cjs/server/index.js +18 -0
  33. package/dist/cjs/server/resources/access.js +28 -0
  34. package/dist/cjs/server/resources/ai.js +36 -0
  35. package/dist/cjs/server/resources/apps.js +30 -0
  36. package/dist/cjs/server/resources/billing.js +71 -0
  37. package/dist/cjs/server/resources/drive.js +39 -0
  38. package/dist/cjs/server/resources/entitlements.js +49 -0
  39. package/dist/cjs/server/resources/identity.js +194 -0
  40. package/dist/cjs/server/resources/logs.js +41 -0
  41. package/dist/cjs/server/resources/otp.js +29 -0
  42. package/dist/cjs/server/resources/sessions.js +82 -0
  43. package/dist/cjs/types.js +15 -0
  44. package/dist/esm/cli/bin.js +13 -0
  45. package/dist/esm/cli/init.js +174 -0
  46. package/dist/esm/client/components/account-sessions.js +109 -0
  47. package/dist/esm/client/components/apps-grid.js +43 -0
  48. package/dist/esm/client/components/auth-buttons.js +38 -0
  49. package/dist/esm/client/components/feature-gate.js +57 -0
  50. package/dist/esm/client/components/link-button.js +21 -0
  51. package/dist/esm/client/components/logo.js +24 -0
  52. package/dist/esm/client/components/plan-pricing.js +156 -0
  53. package/dist/esm/client/components/plans-page.js +36 -0
  54. package/dist/esm/client/components/plans.js +209 -0
  55. package/dist/esm/client/components/readiness.js +113 -0
  56. package/dist/esm/client/components/subscribe-dialog.js +57 -0
  57. package/dist/esm/client/components/subscription.js +181 -0
  58. package/dist/esm/client/components/usage-meter.js +65 -0
  59. package/dist/esm/client/components/user-menu.js +164 -0
  60. package/dist/esm/client/index.js +47 -0
  61. package/dist/esm/client/mark.js +13 -0
  62. package/dist/esm/client/provider.js +247 -0
  63. package/dist/esm/client/use-checkout.js +168 -0
  64. package/dist/esm/errors.js +69 -0
  65. package/dist/esm/format-relative-time.js +15 -0
  66. package/dist/esm/helpers.js +26 -0
  67. package/dist/esm/index.js +15 -0
  68. package/dist/esm/next/index.js +279 -0
  69. package/dist/esm/package.json +3 -0
  70. package/dist/esm/server/client.js +110 -0
  71. package/dist/esm/server/handler.js +330 -0
  72. package/dist/esm/server/http.js +156 -0
  73. package/dist/esm/server/index.js +10 -0
  74. package/dist/esm/server/resources/access.js +24 -0
  75. package/dist/esm/server/resources/ai.js +32 -0
  76. package/dist/esm/server/resources/apps.js +26 -0
  77. package/dist/esm/server/resources/billing.js +67 -0
  78. package/dist/esm/server/resources/drive.js +35 -0
  79. package/dist/esm/server/resources/entitlements.js +45 -0
  80. package/dist/esm/server/resources/identity.js +189 -0
  81. package/dist/esm/server/resources/logs.js +37 -0
  82. package/dist/esm/server/resources/otp.js +25 -0
  83. package/dist/esm/server/resources/sessions.js +78 -0
  84. package/dist/esm/types.js +14 -0
  85. package/dist/theme.css +84 -0
  86. package/dist/types/cli/bin.d.ts +1 -0
  87. package/dist/types/cli/init.d.ts +34 -0
  88. package/dist/types/client/components/account-sessions.d.ts +25 -0
  89. package/dist/types/client/components/apps-grid.d.ts +20 -0
  90. package/dist/types/client/components/auth-buttons.d.ts +31 -0
  91. package/dist/types/client/components/feature-gate.d.ts +57 -0
  92. package/dist/types/client/components/link-button.d.ts +20 -0
  93. package/dist/types/client/components/logo.d.ts +35 -0
  94. package/dist/types/client/components/plan-pricing.d.ts +96 -0
  95. package/dist/types/client/components/plans-page.d.ts +36 -0
  96. package/dist/types/client/components/plans.d.ts +34 -0
  97. package/dist/types/client/components/readiness.d.ts +52 -0
  98. package/dist/types/client/components/subscribe-dialog.d.ts +34 -0
  99. package/dist/types/client/components/subscription.d.ts +123 -0
  100. package/dist/types/client/components/usage-meter.d.ts +24 -0
  101. package/dist/types/client/components/user-menu.d.ts +86 -0
  102. package/dist/types/client/index.d.ts +47 -0
  103. package/dist/types/client/mark.d.ts +11 -0
  104. package/dist/types/client/provider.d.ts +209 -0
  105. package/dist/types/client/use-checkout.d.ts +93 -0
  106. package/dist/types/errors.d.ts +62 -0
  107. package/dist/types/format-relative-time.d.ts +2 -0
  108. package/dist/types/helpers.d.ts +33 -0
  109. package/dist/types/index.d.ts +17 -0
  110. package/dist/types/next/index.d.ts +136 -0
  111. package/dist/types/server/client.d.ts +83 -0
  112. package/dist/types/server/handler.d.ts +67 -0
  113. package/dist/types/server/http.d.ts +52 -0
  114. package/dist/types/server/index.d.ts +15 -0
  115. package/dist/types/server/resources/access.d.ts +17 -0
  116. package/dist/types/server/resources/ai.d.ts +24 -0
  117. package/dist/types/server/resources/apps.d.ts +12 -0
  118. package/dist/types/server/resources/billing.d.ts +41 -0
  119. package/dist/types/server/resources/drive.d.ts +26 -0
  120. package/dist/types/server/resources/entitlements.d.ts +31 -0
  121. package/dist/types/server/resources/identity.d.ts +76 -0
  122. package/dist/types/server/resources/logs.d.ts +20 -0
  123. package/dist/types/server/resources/otp.d.ts +17 -0
  124. package/dist/types/server/resources/sessions.d.ts +37 -0
  125. package/dist/types/types.d.ts +434 -0
  126. package/package.json +102 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `npx @cosmoops/connect-sdk init` - writes the Connect wiring into a Next.js
3
+ * App Router project.
4
+ *
5
+ * ## Why this is a CLI and not a plugin
6
+ *
7
+ * Next has no plugin API that injects routes. `next.config` can rewrite and
8
+ * redirect, but both need a real route to point at, and there is no hook that
9
+ * contributes a `page.tsx` from a node_module - the App Router discovers routes
10
+ * by walking the project's own `app/` directory and nothing else. So a package
11
+ * cannot add `/plans` to an app by being installed, however much one would like
12
+ * it to.
13
+ *
14
+ * What it *can* do is make the file that must exist a single line, and then
15
+ * write that file for you. `ConnectPlansPage` is the first half; this is the
16
+ * second. The result is the same as a plugin from the developer's side - one
17
+ * command and the page is there - without pretending an extension point exists.
18
+ *
19
+ * Nothing here overwrites. Every file that already exists is reported as
20
+ * skipped, so re-running after a partial setup is safe and running it on an
21
+ * app that already has Connect wired up does nothing at all.
22
+ */
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { dirname, join, relative, resolve } from "node:path";
25
+ const BRIDGE_ROUTE = `// The Connect bridge - the only place your API key is used.
26
+ //
27
+ // It answers your own origin, so the browser half of the SDK never holds a
28
+ // credential. GET and POST are the same function; the bridge routes on method
29
+ // and path itself.
30
+ export { GET, POST } from "@cosmoops/connect-sdk/next";
31
+ `;
32
+ const PLANS_PAGE = `"use client";
33
+
34
+ // The full pricing page: readiness banner, current subscription, plan grid, and
35
+ // the subscribe / upgrade / downgrade flow.
36
+ //
37
+ // Swap this for \`<ConnectPlansPage title="..." />\` (or compose \`ConnectPlans\`
38
+ // with the other exports) whenever you want your own copy or layout.
39
+ export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
40
+ `;
41
+ const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's Setup tab.
42
+ CONNECT_API_KEY=
43
+ CONNECT_CLIENT_ID=
44
+ # Same value as CONNECT_CLIENT_ID. Inlined at build time for <ConnectProvider>.
45
+ NEXT_PUBLIC_CONNECT_CLIENT_ID=
46
+ `;
47
+ const SCAFFOLDS = [
48
+ {
49
+ path: join("api", "connect", "[...connect]", "route.ts"),
50
+ contents: BRIDGE_ROUTE,
51
+ description: "Connect bridge route handler",
52
+ },
53
+ {
54
+ path: join("plans", "page.tsx"),
55
+ contents: PLANS_PAGE,
56
+ description: "/plans pricing page",
57
+ },
58
+ ];
59
+ /**
60
+ * `app/` or `src/app/`, whichever this project uses.
61
+ *
62
+ * Both are official layouts and neither is more correct, so guessing wrong
63
+ * would scatter routes into a directory Next never reads - a failure that looks
64
+ * like "the page just 404s" and takes a while to trace. Refusing to guess is
65
+ * better than a default.
66
+ */
67
+ function findAppDir(root) {
68
+ for (const candidate of [join(root, "app"), join(root, "src", "app")]) {
69
+ if (existsSync(candidate))
70
+ return candidate;
71
+ }
72
+ return null;
73
+ }
74
+ function write(file, contents) {
75
+ if (existsSync(file))
76
+ return "skipped";
77
+ mkdirSync(dirname(file), { recursive: true });
78
+ writeFileSync(file, contents);
79
+ return "written";
80
+ }
81
+ /** Appends the Connect block to `.env.local`, or creates it - never rewrites an existing value. */
82
+ function ensureEnv(root) {
83
+ const file = join(root, ".env.local");
84
+ if (!existsSync(file)) {
85
+ writeFileSync(file, ENV_TEMPLATE);
86
+ return "written";
87
+ }
88
+ const current = readFileSync(file, "utf8");
89
+ if (current.includes("CONNECT_CLIENT_ID"))
90
+ return "skipped";
91
+ writeFileSync(file, `${current.trimEnd()}\n\n${ENV_TEMPLATE}`);
92
+ return "appended";
93
+ }
94
+ export function runInit(argv = []) {
95
+ const root = resolve(argv[0] ?? process.cwd());
96
+ const appDir = findAppDir(root);
97
+ if (!appDir) {
98
+ console.error(`[connect-sdk] No App Router directory found in ${root} - looked for "app/" and "src/app/".\n` +
99
+ "Run this from your Next.js project root, or pass the path: npx @cosmoops/connect-sdk init ./my-app");
100
+ return 1;
101
+ }
102
+ console.log(`[connect-sdk] Wiring Connect into ${relative(process.cwd(), appDir) || appDir}\n`);
103
+ for (const scaffold of SCAFFOLDS) {
104
+ const target = join(appDir, scaffold.path);
105
+ const result = write(target, scaffold.contents);
106
+ const marker = result === "written" ? "created" : "exists ";
107
+ console.log(` ${marker} ${relative(root, target)} - ${scaffold.description}`);
108
+ }
109
+ const env = ensureEnv(root);
110
+ console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the three values from your service's Setup tab`);
111
+ // The two steps a scaffolder must not do for you: editing an existing layout
112
+ // means parsing and rewriting JSX someone else wrote, and the redirect URI
113
+ // lives in Connect, not in this repo.
114
+ console.log(`
115
+ Three things left, all one-time:
116
+
117
+ 1. Wrap your app in the provider - app/layout.tsx:
118
+
119
+ import { ConnectProvider } from "@cosmoops/connect-sdk/client";
120
+
121
+ <ConnectProvider>{children}</ConnectProvider>
122
+
123
+ No props: it reads NEXT_PUBLIC_CONNECT_CLIENT_ID.
124
+
125
+ 2. Add the theme to your global stylesheet, last of the three - Tailwind
126
+ first, HeroUI's token layer next, so these values win:
127
+
128
+ @import "tailwindcss";
129
+ @import "@heroui/styles";
130
+ @import "@cosmoops/connect-sdk/theme.css";
131
+
132
+ It has to be imported from your *own* CSS, not from node_modules, or
133
+ Tailwind roots its content scan inside node_modules and skips your app.
134
+
135
+ 3. Register {your origin}/api/connect/callback as a redirect URI in your
136
+ service's Setup tab in Connect.
137
+
138
+ Optionally, in next.config.ts:
139
+
140
+ import { withConnect } from "@cosmoops/connect-sdk/next";
141
+ export default withConnect({ });
142
+
143
+ It checks NEXT_PUBLIC_CONNECT_CLIENT_ID at config load - the one variable whose
144
+ absence fails silently in the browser rather than loudly on the server.
145
+
146
+ Then <ConnectPlans />, <ConnectSubscriptionGate>, <ConnectFeatureGate> and the
147
+ rest work anywhere below the provider, and getConnectSession() reads the same
148
+ session from any Server Component.
149
+ `);
150
+ return 0;
151
+ }
152
+ /**
153
+ * Only `init` today. Parsed as a subcommand anyway so adding `doctor` or
154
+ * `check` later does not change how the existing one is invoked.
155
+ *
156
+ * A bare path runs `init` against it, so both `connect-sdk init ./my-app` and
157
+ * `connect-sdk ./my-app` work. A first argument that is neither the command nor
158
+ * an existing directory is a typo, and saying so beats reporting that
159
+ * `./innit` contains no App Router - which is true, unhelpful, and points at
160
+ * the wrong mistake.
161
+ */
162
+ export function main(argv) {
163
+ const [first, ...rest] = argv;
164
+ if (first === "--help" || first === "-h") {
165
+ console.log("Usage: connect-sdk init [project-dir]");
166
+ return 0;
167
+ }
168
+ if (first === "init")
169
+ return runInit(rest);
170
+ if (!first || existsSync(first))
171
+ return runInit(argv);
172
+ console.error(`[connect-sdk] Unknown command "${first}". Try: connect-sdk init`);
173
+ return 1;
174
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { AlertDialog, Button, Chip, Skeleton } from "@heroui/react";
5
+ import { LogOut, Monitor, MonitorSmartphone, Smartphone, Tablet } from "lucide-react";
6
+ import { useConnect, useConnectSessions } from "../provider.js";
7
+ import { formatRelativeTime } from "../../format-relative-time.js";
8
+ const DEVICE_ICONS = {
9
+ desktop: Monitor,
10
+ mobile: Smartphone,
11
+ tablet: Tablet,
12
+ unknown: MonitorSmartphone,
13
+ };
14
+ function deviceLabel(session) {
15
+ const { device } = session;
16
+ if (device.browser)
17
+ return device.os ? `${device.browser} on ${device.os}` : device.browser;
18
+ if (device.os)
19
+ return device.os;
20
+ return device.deviceType === "unknown" ? "Unknown device" : device.deviceType;
21
+ }
22
+ function metaLine(session) {
23
+ const place = [session.ipCity, session.ipCountry].filter(Boolean).join(", ");
24
+ const active = session.isCurrent ? "active now" : `active ${formatRelativeTime(new Date(session.lastActiveAt))}`;
25
+ return place ? `${place} · ${active}` : active;
26
+ }
27
+ /**
28
+ * Current device first, then most recently active. The row a user is most
29
+ * likely to *recognise* has to be the one they see without scrolling, or the
30
+ * list reads as a page of strangers and the whole surface loses its point -
31
+ * which is letting someone spot a sign-in that isn't theirs.
32
+ */
33
+ function ordered(sessions) {
34
+ return [...sessions].sort((a, b) => {
35
+ if (a.isCurrent !== b.isCurrent)
36
+ return a.isCurrent ? -1 : 1;
37
+ return new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime();
38
+ });
39
+ }
40
+ /**
41
+ * One row's revoke action, confirmed before it fires - revoking a session
42
+ * ends every partner-service sign-in from that same browser, not just this
43
+ * one. Closes on confirm, not on response - the same convention every
44
+ * destructive confirmation in this codebase follows - so the dialog itself
45
+ * needs no async/pending state of its own; the row simply disappears once
46
+ * `onRevoke`'s `refetch()` resolves.
47
+ *
48
+ * Icon-only, because the row already carries three pieces of text and a
49
+ * fourth ("Sign out", repeated per row) turns a scannable list into a wall.
50
+ * The accessible name still spells out *which* device, so a screen reader
51
+ * never hears three identically-named buttons - and that name is what the
52
+ * confirmation restates before anything is destroyed.
53
+ */
54
+ function RevokeRow({ session, onRevoke }) {
55
+ const label = deviceLabel(session);
56
+ return (_jsxs(AlertDialog, { children: [_jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", className: "shrink-0 text-muted hover:text-danger", "aria-label": `Sign out ${label}`, children: _jsx(LogOut, { className: "size-4" }) }), _jsx(AlertDialog.Backdrop, { children: _jsx(AlertDialog.Container, { children: _jsxs(AlertDialog.Dialog, { className: "sm:max-w-100", children: [_jsx(AlertDialog.CloseTrigger, {}), _jsxs(AlertDialog.Header, { children: [_jsx(AlertDialog.Icon, { status: "danger" }), _jsx(AlertDialog.Heading, { children: "Sign out this device?" })] }), _jsx(AlertDialog.Body, { children: _jsxs("p", { className: "text-sm text-muted", children: ["This ends every sign-in from ", label, ", on every service, not just this one."] }) }), _jsxs(AlertDialog.Footer, { children: [_jsx(Button, { slot: "close", variant: "secondary", className: "text-xs", children: "Cancel" }), _jsx(Button, { slot: "close", variant: "danger", className: "text-xs", onPress: onRevoke, children: "Sign out" })] })] }) }) })] }));
57
+ }
58
+ /**
59
+ * Every device signed into this Connect account - not scoped to this
60
+ * service, see `ConnectUserSession`'s doc comment. Owns the one
61
+ * `useConnectSessions()` call for this modal; every revoke action here
62
+ * mutates through it and re-reads the list itself, since nothing else
63
+ * invalidates it automatically - see `mutateResource`'s doc comment.
64
+ *
65
+ * That re-read is `invalidateResource("/sessions")` **then** `refetch()`,
66
+ * and both halves are load-bearing. `mutateResource` evicts only the path it
67
+ * posted to - `/sessions/revoke` - so `/sessions` keeps its cached promise,
68
+ * and `refetch()` alone just re-runs the effect and is handed that same
69
+ * resolved promise back. The row stayed on screen and the revoke looked like
70
+ * it had silently failed, when in fact the server had done exactly as asked.
71
+ */
72
+ export function ConnectAccountSessions({ className, maxVisible = 4 }) {
73
+ const { mutateResource, invalidateResource } = useConnect();
74
+ const { data, isLoading, error, refetch } = useConnectSessions();
75
+ const [isRevokingOthers, setIsRevokingOthers] = useState(false);
76
+ const [showAll, setShowAll] = useState(false);
77
+ if (isLoading) {
78
+ return (_jsxs("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [_jsx(Skeleton, { className: "h-4 w-28" }), _jsx("div", { className: "flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface", children: Array.from({ length: 2 }, (_, index) => (_jsxs("div", { className: "flex items-center gap-3 px-3 py-3", children: [_jsx(Skeleton, { className: "size-9 shrink-0 rounded-lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-32" }), _jsx(Skeleton, { className: "h-3 w-24" })] })] }, index))) })] }));
79
+ }
80
+ // A failed read used to render as *nothing*, which is indistinguishable
81
+ // from "you have no other devices" - the one reading of this list that
82
+ // should never be guessed at.
83
+ if (error) {
84
+ return (_jsxs("section", { className: `flex flex-col gap-2 ${className ?? ""}`, children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Active sessions" }), _jsx("p", { className: "rounded-xl border border-border bg-surface px-3 py-4 text-xs text-muted", children: "Could not load your sessions. Try again in a moment." })] }));
85
+ }
86
+ const sessions = ordered(data?.items ?? []);
87
+ if (sessions.length === 0)
88
+ return null;
89
+ const hasOtherSessions = sessions.some((session) => !session.isCurrent);
90
+ const isCollapsible = maxVisible > 0 && sessions.length > maxVisible;
91
+ const visible = isCollapsible && !showAll ? sessions.slice(0, maxVisible) : sessions;
92
+ return (_jsxs("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "flex items-center gap-2 text-sm font-medium text-foreground", children: ["Active sessions", _jsx(Chip, { size: "sm", variant: "soft", children: sessions.length })] }), _jsx("p", { className: "text-xs text-muted", children: "Devices signed in to your Connect account." })] }), hasOtherSessions && (_jsx(Button, { size: "sm", variant: "tertiary", className: "shrink-0 text-xs text-danger", isPending: isRevokingOthers, isDisabled: isRevokingOthers, onPress: () => {
93
+ setIsRevokingOthers(true);
94
+ void mutateResource("/sessions/revoke-others")
95
+ .then(() => {
96
+ invalidateResource("/sessions");
97
+ refetch();
98
+ })
99
+ .finally(() => setIsRevokingOthers(false));
100
+ }, children: "Sign out others" }))] }), _jsx("div", { className: "flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface", children: visible.map((session) => {
101
+ const Icon = DEVICE_ICONS[session.device.deviceType];
102
+ return (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2.5", children: [_jsx("span", { className: `flex size-9 shrink-0 items-center justify-center rounded-lg ${session.isCurrent ? "bg-accent-soft text-accent-soft-foreground" : "bg-surface-secondary text-muted"}`, children: _jsx(Icon, { className: "size-4", "aria-hidden": "true" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx("span", { className: "truncate text-sm font-medium text-foreground", children: deviceLabel(session) }), session.isCurrent && (_jsx(Chip, { color: "accent", size: "sm", variant: "soft", className: "shrink-0", children: "This device" }))] }), _jsx("p", { className: "truncate text-xs text-muted", children: metaLine(session) })] }), !session.isCurrent && (_jsx(RevokeRow, { session: session, onRevoke: () => {
103
+ void mutateResource("/sessions/revoke", { sessionId: session.id }).then(() => {
104
+ invalidateResource("/sessions");
105
+ refetch();
106
+ });
107
+ } }))] }, session.id));
108
+ }) }), isCollapsible && (_jsx(Button, { size: "sm", variant: "tertiary", className: "self-start text-xs", onPress: () => setShowAll((open) => !open), children: showAll ? "Show fewer" : `Show all ${sessions.length} devices` }))] }));
109
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, Modal, Skeleton } from "@heroui/react";
4
+ import { AlertCircle, ExternalLink, LayoutGrid } from "lucide-react";
5
+ import { useConnectApps, useConnect } from "../provider.js";
6
+ function AppTile({ app }) {
7
+ const disabled = app.launchUrl === null;
8
+ const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative flex size-12 items-center justify-center overflow-hidden rounded-xl bg-surface-secondary", children: [app.iconUrl ? (
9
+ // Deliberately a plain `<img>`: this package ships into any React app
10
+ // and cannot assume `next/image` or any other framework loader.
11
+ // eslint-disable-next-line @next/next/no-img-element
12
+ _jsx("img", { src: app.iconUrl, alt: "", className: "size-full object-cover" })) : (_jsx(LayoutGrid, { className: "size-6 text-muted", "aria-hidden": "true" })), app.needsAttention && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-warning text-warning-foreground", children: _jsx(AlertCircle, { className: "size-3", "aria-hidden": "true" }) }))] }), _jsx("span", { className: "line-clamp-2 text-center text-xs font-medium text-foreground", children: app.name }), app.isCurrent && _jsx("span", { className: "text-[0.625rem] uppercase tracking-wide text-accent", children: "Current" })] }));
13
+ const shared = "flex flex-col items-center gap-2 rounded-xl p-3 transition-colors focus-visible:outline-2 focus-visible:outline-focus";
14
+ if (disabled) {
15
+ return (_jsx("div", { className: `${shared} cursor-default opacity-60`, title: `${app.name} has no launch URL configured`, children: body }));
16
+ }
17
+ return (_jsx("a", { href: app.launchUrl, target: "_blank", rel: "noopener noreferrer", className: `${shared} hover:bg-surface-secondary`, title: app.description || app.name, children: body }));
18
+ }
19
+ /**
20
+ * The Google-style app launcher: a grid icon in your top bar that opens a
21
+ * modal of every Connect service this organization can actually open.
22
+ *
23
+ * Only launchable apps appear, so no tile is a dead end - a paused
24
+ * subscription still shows, badged, because a dunning cycle should not make a
25
+ * product look deleted. The current app is pinned first and marked, rather
26
+ * than filtered out, so the grid never has a hole where "you are here" should
27
+ * be.
28
+ *
29
+ * Renders nothing when signed out: a launcher with nothing to launch is just a
30
+ * button that opens an empty box.
31
+ */
32
+ export function ConnectAppsGrid({ className, label = "Switch app" }) {
33
+ const { session, isLoading: sessionLoading } = useConnect();
34
+ const { data, isLoading, error } = useConnectApps();
35
+ if (sessionLoading || !session?.user)
36
+ return null;
37
+ const apps = data?.apps ?? [];
38
+ return (_jsxs(Modal, { children: [_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: _jsx(LayoutGrid, { className: "size-5" }) }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-md", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Your apps" }) }), _jsxs(Modal.Body, { children: [isLoading && (_jsx("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 p-3", children: [_jsx(Skeleton, { className: "size-12 rounded-xl" }), _jsx(Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && (_jsx("p", { className: "py-6 text-center text-sm text-muted", children: "No other apps yet. Anything this workspace subscribes to shows up here." })), !isLoading && !error && apps.length > 0 && (_jsx("div", { className: "grid grid-cols-3 gap-2", children: apps.map((app) => (_jsx(AppTile, { app: app }, app.serviceId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }));
39
+ }
40
+ function ConnectBrowseAllLink() {
41
+ const { appUrl } = useConnect();
42
+ return (_jsxs(Button, { variant: "tertiary", className: "w-full", onPress: () => window.open(`${appUrl}/services`, "_blank", "noopener,noreferrer"), children: ["Browse all services", _jsx(ExternalLink, { className: "size-3.5 text-muted" })] }));
43
+ }
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button } from "@heroui/react";
4
+ import { useConnect } from "../provider.js";
5
+ import { ConnectMark } from "../mark.js";
6
+ /**
7
+ * "Continue with Connect" - the federated sign-in button, in the shape people
8
+ * already know from Google and GitHub: neutral surface, brand mark on the
9
+ * left, one line of text.
10
+ *
11
+ * `variant="secondary"` by default rather than `primary`, on purpose. A
12
+ * federated button that outshouts the host app's own primary action reads as
13
+ * an ad; every mature identity button is quiet. Pass `variant` to override if
14
+ * Connect *is* the primary path in your flow.
15
+ *
16
+ * Hidden while a session is still loading, so a signed-in user never sees a
17
+ * Login button flash before their avatar resolves.
18
+ */
19
+ export function ConnectLoginButton({ children, ...props }) {
20
+ const { signIn, isLoading, session } = useConnect();
21
+ if (isLoading || session?.user)
22
+ return null;
23
+ return (_jsxs(Button, { variant: "secondary", onPress: () => signIn(), ...props, children: [_jsx(ConnectMark, {}), children ?? "Continue with Connect"] }));
24
+ }
25
+ /**
26
+ * The same button pointed at registration (`prompt=create`).
27
+ *
28
+ * Only the starting screen differs - each screen links to the other carrying
29
+ * the same in-flight request, and anyone who already has a Connect session
30
+ * skips straight to consent either way. So shipping both buttons costs nothing
31
+ * and matches what users expect from a "Sign up" / "Log in" pair.
32
+ */
33
+ export function ConnectRegisterButton({ children, ...props }) {
34
+ const { signIn, isLoading, session } = useConnect();
35
+ if (isLoading || session?.user)
36
+ return null;
37
+ return (_jsxs(Button, { variant: "primary", onPress: () => signIn({ prompt: "create" }), ...props, children: [_jsx(ConnectMark, {}), children ?? "Sign up with Connect"] }));
38
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
+ import { useConnect, useConnectEntitlements } from "../provider.js";
4
+ import { ConnectNoSubscriptionCard } from "./subscription.js";
5
+ /**
6
+ * Whether the live subscription unlocks a plan feature.
7
+ *
8
+ * The browser twin of `client.entitlements.hasFeature` on the server, and the
9
+ * same posture: `false` rather than a throw when there is no subscription at
10
+ * all, because "not entitled" is the same answer either way and a feature check
11
+ * should not need a try/catch around it.
12
+ *
13
+ * `isLoading` is returned separately and matters: `false` while the first read
14
+ * is in flight means "we don't know yet", not "no". Gate on `isEntitled` for
15
+ * what to render and on `isLoading` for whether to render anything at all -
16
+ * `ConnectFeatureGate` does exactly that.
17
+ */
18
+ export function useConnectFeature(feature) {
19
+ const { data, isLoading } = useConnectEntitlements();
20
+ const features = data?.subscription.plan?.features ?? [];
21
+ return {
22
+ // `hasActiveSubscription` as well as the key: a cancelled or paused
23
+ // subscription still carries its plan's feature list, and reading that
24
+ // alone would keep unlocking a module nobody is paying for.
25
+ isEntitled: (data?.subscription.hasActiveSubscription ?? false) && features.includes(feature),
26
+ isLoading,
27
+ features,
28
+ };
29
+ }
30
+ /**
31
+ * Renders `children` only when the workspace's plan includes `feature`.
32
+ *
33
+ * ```tsx
34
+ * <ConnectFeatureGate feature="advanced_reports" title="Reports are on Growth">
35
+ * <Reports />
36
+ * </ConnectFeatureGate>
37
+ * ```
38
+ *
39
+ * Like every gate in this package, it is a **UX gate, not a security
40
+ * boundary**: the entitlement is read through your own origin and the browser
41
+ * can ignore it. Check `client.entitlements.hasFeature(orgId, feature)` on the
42
+ * server before doing the thing the feature unlocks.
43
+ *
44
+ * Renders `loading` - not `fallback` - while the answer is unknown, so a paying
45
+ * customer never sees an upsell for something they already have.
46
+ */
47
+ export function ConnectFeatureGate({ feature, children, fallback, loading = null, plansHref, title, description, className, }) {
48
+ const { session, isLoading: sessionLoading } = useConnect();
49
+ const { isEntitled, isLoading } = useConnectFeature(feature);
50
+ if (sessionLoading || (session?.user && isLoading))
51
+ return _jsx(_Fragment, { children: loading });
52
+ if (isEntitled)
53
+ return _jsx(_Fragment, { children: children });
54
+ if (fallback !== undefined)
55
+ return _jsx(_Fragment, { children: fallback });
56
+ return (_jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, title: title ?? "Not included in your plan", description: description ?? "This part of the service is on a higher plan. Compare plans to see what unlocks it.", className: className }));
57
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Button } from "@heroui/react";
4
+ /**
5
+ * A HeroUI `Button` that renders a real `<a>`.
6
+ *
7
+ * A plain anchor, deliberately not a router link: this package imports nothing
8
+ * from `next` (see the `./next` entry's doc comment for why that is a hard
9
+ * rule), so it cannot know which router - if any - the host app uses. An
10
+ * `<a href>` navigates under all of them, and a host that wants client-side
11
+ * routing can pass its own element via `render` on the underlying `Button`.
12
+ *
13
+ * The cast is the same one HeroUI's own "Render Function" example needs:
14
+ * `render` types its argument as the props of the element `Button` would have
15
+ * emitted, which is a `<button>` - including a `ref` typed to
16
+ * `HTMLButtonElement`. Spreading that onto an anchor is sound at runtime and
17
+ * unrepresentable in the type, which is exactly what a cast is for.
18
+ */
19
+ export function ConnectLinkButton({ href, ...props }) {
20
+ return (_jsx(Button, { ...props, render: (renderProps) => _jsx("a", { ...renderProps, href: href }) }));
21
+ }
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ConnectMark } from "../mark.js";
4
+ /**
5
+ * The shared platform lockup: a service's own glyph on the CosmoOps badge,
6
+ * `cosmo·ops` beside it, and the service name underneath.
7
+ *
8
+ * This is what makes a family of service platforms look like one product. Each
9
+ * platform passes only what differs - its `name` and its `icon` - and inherits
10
+ * the mark geometry, the wordmark treatment, the separator, and the accent from
11
+ * one place. Change the lockup here and every platform follows.
12
+ *
13
+ * Sizes are `em`-based throughout, so the whole lockup scales from the
14
+ * wrapper's font size: `className="text-base"` renders it half again as large
15
+ * as `text-sm`, with the badge, gap and tracking all in proportion. That is
16
+ * why there is no `size` prop.
17
+ */
18
+ export function ConnectLogo({ name, icon, collapsed = false, className }) {
19
+ const badge = (_jsxs("span", { className: "relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-[0.28em] bg-accent text-accent-foreground", style: { width: "2em", height: "2em" }, "aria-hidden": "true", children: [icon ? _jsx("span", { className: "inline-flex items-center justify-center [&>*]:size-[1.25em]", children: icon }) : null, !icon && _jsx(ConnectMark, { className: "absolute inset-0 size-full" })] }));
20
+ if (collapsed) {
21
+ return (_jsx("span", { className: `inline-flex text-sm leading-none ${className ?? ""}`, role: "img", "aria-label": name, children: badge }));
22
+ }
23
+ return (_jsxs("span", { className: `inline-flex min-w-0 items-center gap-[0.6em] text-sm leading-none ${className ?? ""}`, role: "img", "aria-label": `cosmoops ${name}`, children: [badge, _jsxs("span", { className: "flex min-w-0 flex-col leading-none", children: [_jsxs("span", { className: "inline-flex items-center font-extrabold tracking-tight text-foreground", "aria-hidden": "true", children: [_jsx("span", { children: "cosmo" }), _jsx("span", { className: "mx-[0.12em] inline-block size-[0.19em] shrink-0 rounded-[0.04em] bg-accent" }), _jsx("span", { children: "ops" })] }), _jsx("span", { className: "mt-[0.42em] truncate text-[0.6em] font-semibold uppercase tracking-[0.26em] text-accent", "aria-hidden": "true", children: name })] })] }));
24
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The pricing arithmetic the plan grid and its confirmation dialog have to
3
+ * agree on. Extracted so they cannot drift: a dialog that called an upgrade
4
+ * an upgrade while the card behind it said downgrade would be worse than
5
+ * either label being wrong on its own.
6
+ *
7
+ * Mirrors `packages/data/src/services/catalog.ts` and
8
+ * `packages/data/src/payments/plan-change.ts`, which are the copies Connect's
9
+ * own UI uses and which decide which use-case actually runs. The duplication
10
+ * is deliberate and load-bearing: this package carries no dependency on
11
+ * `@connect/data` so a partner's bundle never pulls Connect's internals, and
12
+ * `conformance.test.ts` type-checks the wire shapes against the server DTOs so
13
+ * the two can't drift silently.
14
+ *
15
+ * No React here - it is imported by both a client component and its dialog,
16
+ * and nothing in it needs to be.
17
+ */
18
+ /** Paise are the wire unit everywhere in Connect; rupees are the display unit. */
19
+ export function formatPrice(paise) {
20
+ return new Intl.NumberFormat("en-IN", {
21
+ style: "currency",
22
+ currency: "INR",
23
+ maximumFractionDigits: paise % 100 === 0 ? 0 : 2,
24
+ }).format(paise / 100);
25
+ }
26
+ /**
27
+ * An annual plan's price is 12 cycles of the same product, so any comparison
28
+ * across billing intervals has to normalise first - otherwise every annual
29
+ * plan reads as an upgrade over every monthly one.
30
+ */
31
+ export function monthlyOf(price, billingInterval) {
32
+ return billingInterval === "annual" ? price / 12 : price;
33
+ }
34
+ export function discountLabel(discount) {
35
+ const amount = discount.discountType === "percentage" ? `${discount.value}% off` : `${formatPrice(discount.value)} off`;
36
+ if (discount.duration === "forever")
37
+ return `${amount}, forever`;
38
+ if (discount.cyclesRemaining === null)
39
+ return amount;
40
+ return `${amount}, ${discount.cyclesRemaining} ${discount.cyclesRemaining === 1 ? "cycle" : "cycles"} left`;
41
+ }
42
+ /**
43
+ * Falls back to a slug of the name for a Connect deployment predating
44
+ * `planGroup` on the wire, mirroring `resolvePlanGroup` server-side. Kept so
45
+ * an SDK upgrade doesn't require the partner's Connect instance to be
46
+ * upgraded in lockstep - an older server simply omits the field and the grid
47
+ * groups the way it always did.
48
+ */
49
+ export function resolvePlanGroup(plan) {
50
+ const stored = plan.planGroup?.trim();
51
+ if (stored)
52
+ return stored;
53
+ const tokens = plan.name
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9]+/g, "-")
56
+ .split("-")
57
+ .filter(Boolean);
58
+ const cadence = new Set(["monthly", "month", "annual", "annually", "yearly", "year", "mo", "yr"]);
59
+ const kept = tokens.filter((token, index) => {
60
+ if (cadence.has(token))
61
+ return false;
62
+ return !(token === "per" && cadence.has(tokens[index + 1] ?? ""));
63
+ });
64
+ return (kept.length > 0 ? kept : tokens).join("-") || "plan";
65
+ }
66
+ /**
67
+ * Collapses a flat plan list into one card per tier.
68
+ *
69
+ * This is the shape the grid wants and the old one didn't have. It used to
70
+ * filter the whole list down to the toggled interval, which meant a tier sold
71
+ * monthly-only simply vanished when a customer looked at annual pricing, and a
72
+ * catalog with no monthly plans at all rendered an empty grid on first paint.
73
+ * Grouping first means the toggle picks a *price inside a card*, and a card
74
+ * with only one cadence still shows.
75
+ *
76
+ * Ordered by the tier's cheapest monthly-equivalent price, so the grid reads
77
+ * left-to-right cheapest-first regardless of what order the API returned.
78
+ */
79
+ export function groupPlansByTier(plans) {
80
+ const tiers = new Map();
81
+ for (const plan of plans) {
82
+ const planGroup = resolvePlanGroup(plan);
83
+ const tier = tiers.get(planGroup) ?? {
84
+ planGroup,
85
+ name: plan.name,
86
+ description: plan.description,
87
+ monthly: null,
88
+ annual: null,
89
+ annualSavingPercent: null,
90
+ };
91
+ if (plan.billingInterval === "monthly") {
92
+ if (!tier.monthly || plan.price < tier.monthly.price)
93
+ tier.monthly = plan;
94
+ }
95
+ else if (!tier.annual || plan.price < tier.annual.price) {
96
+ tier.annual = plan;
97
+ }
98
+ tiers.set(planGroup, tier);
99
+ }
100
+ return [...tiers.values()]
101
+ .map((tier) => {
102
+ const identity = tier.monthly ?? tier.annual;
103
+ return {
104
+ ...tier,
105
+ name: identity?.name ?? tier.name,
106
+ description: identity?.description ?? tier.description,
107
+ annualSavingPercent: annualSaving(tier.monthly, tier.annual),
108
+ };
109
+ })
110
+ .sort((a, b) => tierFloorPrice(a) - tierFloorPrice(b));
111
+ }
112
+ function tierFloorPrice(tier) {
113
+ const prices = [tier.monthly, tier.annual]
114
+ .filter((plan) => plan !== null)
115
+ .map((plan) => monthlyOf(plan.price, plan.billingInterval));
116
+ return prices.length > 0 ? Math.min(...prices) : Number.MAX_SAFE_INTEGER;
117
+ }
118
+ /**
119
+ * Annual is quoted per year on the wire (`billingInterval: "annual"`), so a
120
+ * side-by-side grid has to normalise before it can compare. Showing the saving
121
+ * against 12x the monthly plan is the comparison a customer is actually making.
122
+ *
123
+ * **Call this with a matched pair from one tier.** It used to be handed the
124
+ * first monthly and the first annual plan in the whole response - two plans
125
+ * that were the same tier only by the accident of document order, and that
126
+ * produced one badge for a catalog where each tier discounts differently.
127
+ * `groupPlansByTier` now does the pairing.
128
+ */
129
+ export function annualSaving(monthly, annual) {
130
+ if (!monthly || !annual)
131
+ return null;
132
+ const saving = monthly.price * 12 - annual.price;
133
+ return saving > 0 ? Math.round((saving / (monthly.price * 12)) * 100) : null;
134
+ }
135
+ export function resolveAction(plan, current) {
136
+ if (!current?.subscription || !current.plan)
137
+ return "subscribe";
138
+ if (current.plan.id === plan.id)
139
+ return "current";
140
+ const currentPlan = current.plan;
141
+ if (currentPlan.billingInterval !== plan.billingInterval) {
142
+ return plan.billingInterval === "annual" ? "switch-to-annual" : "switch-to-monthly";
143
+ }
144
+ return monthlyOf(plan.price, plan.billingInterval) >
145
+ monthlyOf(currentPlan.price, currentPlan.billingInterval)
146
+ ? "upgrade"
147
+ : "downgrade";
148
+ }
149
+ export const ACTION_LABEL = {
150
+ current: "Current plan",
151
+ upgrade: "Upgrade",
152
+ downgrade: "Downgrade",
153
+ "switch-to-annual": "Switch to annual",
154
+ "switch-to-monthly": "Switch to monthly",
155
+ subscribe: "Subscribe",
156
+ };