@cosmoops/connect-sdk 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/README.md +215 -6
- package/dist/cjs/cli/doctor.js +3 -3
- package/dist/cjs/cli/init.js +3 -3
- package/dist/cjs/client/components/access-gate.js +162 -0
- package/dist/cjs/client/components/account-sessions.js +21 -19
- package/dist/cjs/client/components/app-shell.js +157 -0
- package/dist/cjs/client/components/apps-grid.js +2 -1
- package/dist/cjs/client/components/integrations-modal.js +95 -0
- package/dist/cjs/client/components/motion.js +27 -0
- package/dist/cjs/client/components/nav.js +97 -0
- package/dist/cjs/client/components/plans.js +39 -37
- package/dist/cjs/client/components/readiness.js +3 -1
- package/dist/cjs/client/components/side-nav.js +58 -0
- package/dist/cjs/client/components/subscription.js +2 -1
- package/dist/cjs/client/components/surfaces.js +54 -0
- package/dist/cjs/client/components/usage-meter.js +3 -2
- package/dist/cjs/client/components/user-menu.js +30 -3
- package/dist/cjs/client/index.js +29 -1
- package/dist/cjs/client/provider.js +54 -2
- package/dist/cjs/client/use-active-path.js +77 -0
- package/dist/cjs/errors.js +1 -1
- package/dist/cjs/next/index.js +96 -15
- package/dist/cjs/server/client.js +10 -2
- package/dist/cjs/server/handler.js +89 -6
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/server/resources/analytics.js +66 -0
- package/dist/cjs/server/resources/identity.js +1 -1
- package/dist/cjs/server/resources/integrations.js +66 -0
- package/dist/cjs/server/resources/sessions.js +1 -1
- package/dist/cjs/server/webhooks.js +127 -0
- package/dist/esm/cli/doctor.js +3 -3
- package/dist/esm/cli/init.js +3 -3
- package/dist/esm/client/components/access-gate.js +158 -0
- package/dist/esm/client/components/account-sessions.js +21 -19
- package/dist/esm/client/components/app-shell.js +154 -0
- package/dist/esm/client/components/apps-grid.js +2 -1
- package/dist/esm/client/components/integrations-modal.js +92 -0
- package/dist/esm/client/components/motion.js +21 -0
- package/dist/esm/client/components/nav.js +89 -0
- package/dist/esm/client/components/plans.js +39 -37
- package/dist/esm/client/components/readiness.js +3 -1
- package/dist/esm/client/components/side-nav.js +53 -0
- package/dist/esm/client/components/subscription.js +2 -1
- package/dist/esm/client/components/surfaces.js +51 -0
- package/dist/esm/client/components/usage-meter.js +3 -2
- package/dist/esm/client/components/user-menu.js +31 -4
- package/dist/esm/client/index.js +15 -1
- package/dist/esm/client/provider.js +53 -2
- package/dist/esm/client/use-active-path.js +74 -0
- package/dist/esm/errors.js +1 -1
- package/dist/esm/next/index.js +95 -15
- package/dist/esm/server/client.js +10 -2
- package/dist/esm/server/handler.js +89 -6
- package/dist/esm/server/index.js +5 -0
- package/dist/esm/server/resources/analytics.js +62 -0
- package/dist/esm/server/resources/identity.js +1 -1
- package/dist/esm/server/resources/integrations.js +62 -0
- package/dist/esm/server/resources/sessions.js +1 -1
- package/dist/esm/server/webhooks.js +123 -0
- package/dist/theme.css +28 -0
- package/dist/types/client/components/access-gate.d.ts +79 -0
- package/dist/types/client/components/app-shell.d.ts +115 -0
- package/dist/types/client/components/integrations-modal.d.ts +21 -0
- package/dist/types/client/components/motion.d.ts +22 -0
- package/dist/types/client/components/nav.d.ts +110 -0
- package/dist/types/client/components/side-nav.d.ts +31 -0
- package/dist/types/client/components/surfaces.d.ts +51 -0
- package/dist/types/client/index.d.ts +12 -1
- package/dist/types/client/provider.d.ts +38 -3
- package/dist/types/client/use-active-path.d.ts +8 -0
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/next/index.d.ts +40 -0
- package/dist/types/server/client.d.ts +8 -2
- package/dist/types/server/index.d.ts +7 -0
- package/dist/types/server/resources/analytics.d.ts +31 -0
- package/dist/types/server/resources/identity.d.ts +12 -2
- package/dist/types/server/resources/integrations.d.ts +40 -0
- package/dist/types/server/webhooks.d.ts +67 -0
- package/dist/types/types.d.ts +182 -1
- package/package.json +10 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyConnectWebhook = verifyConnectWebhook;
|
|
4
|
+
exports.createConnectWebhookHandler = createConnectWebhookHandler;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
/**
|
|
7
|
+
* How far out of date a timestamp may be. Five minutes, the same value
|
|
8
|
+
* Connect signs against and the same one Stripe uses - so a backend that
|
|
9
|
+
* already verifies Stripe webhooks needs no new assumptions.
|
|
10
|
+
*/
|
|
11
|
+
const TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
12
|
+
const SIGNATURE_HEADER = "connect-signature";
|
|
13
|
+
/**
|
|
14
|
+
* Verifies a `Connect-Signature` header against the raw request body.
|
|
15
|
+
*
|
|
16
|
+
* Exported on its own for anyone wiring this into a framework
|
|
17
|
+
* `createConnectWebhookHandler` does not cover. The **raw** body matters:
|
|
18
|
+
* verify before parsing, and never re-serialise - `JSON.stringify(await
|
|
19
|
+
* req.json())` produces a different string whenever key order or number
|
|
20
|
+
* formatting differs, and the signature will not match.
|
|
21
|
+
*
|
|
22
|
+
* Returns a boolean and never explains which check failed. Reporting "bad
|
|
23
|
+
* timestamp" separately from "bad signature" tells whoever is probing which
|
|
24
|
+
* half of a forgery to keep working on.
|
|
25
|
+
*/
|
|
26
|
+
function verifyConnectWebhook(rawBody, signatureHeader, secret, now = new Date()) {
|
|
27
|
+
if (!signatureHeader)
|
|
28
|
+
return false;
|
|
29
|
+
const parts = new Map(signatureHeader
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((part) => part.trim().split("="))
|
|
32
|
+
.filter((pair) => pair.length === 2)
|
|
33
|
+
.map(([key, value]) => [key, value]));
|
|
34
|
+
const timestamp = Number(parts.get("t"));
|
|
35
|
+
const presented = parts.get("v1");
|
|
36
|
+
if (!presented || !Number.isFinite(timestamp))
|
|
37
|
+
return false;
|
|
38
|
+
// Absolute difference: a timestamp from the future is as much a sign of a
|
|
39
|
+
// forgery as a stale one, and accepting it would let somebody mint a
|
|
40
|
+
// signature that stays valid indefinitely.
|
|
41
|
+
const nowSeconds = Math.floor(now.getTime() / 1000);
|
|
42
|
+
if (Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS)
|
|
43
|
+
return false;
|
|
44
|
+
const expected = (0, node_crypto_1.createHmac)("sha256", secret)
|
|
45
|
+
.update(`${timestamp}.${rawBody}`)
|
|
46
|
+
.digest("hex");
|
|
47
|
+
// `timingSafeEqual` throws on a length mismatch, and a wrong-length hex
|
|
48
|
+
// string is exactly what a probe looks like - so lengths are compared
|
|
49
|
+
// first and only equal-length buffers reach it.
|
|
50
|
+
if (presented.length !== expected.length)
|
|
51
|
+
return false;
|
|
52
|
+
return (0, node_crypto_1.timingSafeEqual)(Buffer.from(presented, "utf8"), Buffer.from(expected, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A ready-made `POST` route for Connect's webhooks.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* export const POST = createConnectWebhookHandler({
|
|
59
|
+
* secret: process.env.CONNECT_WEBHOOK_SECRET!,
|
|
60
|
+
* on: {
|
|
61
|
+
* "organization.deletion_scheduled": ({ organizationId, purgeAfter }) =>
|
|
62
|
+
* freeze(organizationId, purgeAfter),
|
|
63
|
+
* "organization.deletion_cancelled": ({ organizationId }) => unfreeze(organizationId),
|
|
64
|
+
* "organization.deleted": ({ organizationId }) => purge(organizationId),
|
|
65
|
+
* },
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* Three behaviours worth knowing, because each of them is a decision rather
|
|
70
|
+
* than an accident:
|
|
71
|
+
*
|
|
72
|
+
* **An unverified request gets a 401 and nothing else happens.** No parsing,
|
|
73
|
+
* no logging of the body, no handler. The endpoint is a public URL, so
|
|
74
|
+
* anything reachable before the signature check is reachable by anyone.
|
|
75
|
+
*
|
|
76
|
+
* **An unhandled event type is a 200.** Connect adds event types over time,
|
|
77
|
+
* and answering 4xx to one you have not written code for would dead-letter
|
|
78
|
+
* it immediately and, after enough of them, disable your endpoint. Silence
|
|
79
|
+
* is the correct answer to "we do not care about this one".
|
|
80
|
+
*
|
|
81
|
+
* **A throwing handler produces a 500, on purpose.** That is what makes
|
|
82
|
+
* Connect retry. Swallowing the error would acknowledge an event you failed
|
|
83
|
+
* to process, and the retry ladder is the only thing standing between a
|
|
84
|
+
* transient failure and a permanently missed deletion.
|
|
85
|
+
*
|
|
86
|
+
* Delivery is at-least-once and unordered. Dedupe on `envelope.id`, and
|
|
87
|
+
* reconcile against the readiness endpoint rather than trusting arrival
|
|
88
|
+
* order - the pull channel is authoritative, this one is fast.
|
|
89
|
+
*/
|
|
90
|
+
function createConnectWebhookHandler(options) {
|
|
91
|
+
return async function POST(request) {
|
|
92
|
+
// Read as text and verify before parsing. `JSON.stringify(await
|
|
93
|
+
// request.json())` is not the same string that was signed - key order and
|
|
94
|
+
// number formatting both differ - so a handler that parses first can
|
|
95
|
+
// never verify.
|
|
96
|
+
const rawBody = await request.text();
|
|
97
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
98
|
+
if (!verifyConnectWebhook(rawBody, signature, options.secret)) {
|
|
99
|
+
return new Response("Invalid signature", { status: 401 });
|
|
100
|
+
}
|
|
101
|
+
let envelope;
|
|
102
|
+
try {
|
|
103
|
+
envelope = JSON.parse(rawBody);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Signed by us and still unparseable is not something a retry fixes.
|
|
107
|
+
return new Response("Malformed payload", { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
const handler = options.on[envelope.type];
|
|
110
|
+
if (!handler)
|
|
111
|
+
return new Response(null, { status: 200 });
|
|
112
|
+
try {
|
|
113
|
+
await handler(envelope.data, envelope);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (options.onError) {
|
|
117
|
+
await options.onError(error, envelope);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
console.error(`Connect webhook ${envelope.type} (${envelope.id}) failed:`, error);
|
|
121
|
+
}
|
|
122
|
+
// Deliberately a 500: it is what makes Connect retry. See the doc above.
|
|
123
|
+
return new Response("Handler failed", { status: 500 });
|
|
124
|
+
}
|
|
125
|
+
return new Response(null, { status: 200 });
|
|
126
|
+
};
|
|
127
|
+
}
|
package/dist/esm/cli/doctor.js
CHANGED
|
@@ -159,8 +159,8 @@ function checkEnv(root) {
|
|
|
159
159
|
status: "fail",
|
|
160
160
|
title: `${name} set`,
|
|
161
161
|
detail: declaredEmpty
|
|
162
|
-
? `Declared but empty in .env.local. The value is in your service's
|
|
163
|
-
: `Not found in .env.local or the environment. Both values are in your service's
|
|
162
|
+
? `Declared but empty in .env.local. The value is in your service's Integration tab in Connect.`
|
|
163
|
+
: `Not found in .env.local or the environment. Both values are in your service's Integration tab in Connect.`,
|
|
164
164
|
};
|
|
165
165
|
});
|
|
166
166
|
}
|
|
@@ -173,7 +173,7 @@ function checkRedirectUri() {
|
|
|
173
173
|
return {
|
|
174
174
|
status: "warn",
|
|
175
175
|
title: "Redirect URI registered in Connect",
|
|
176
|
-
detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your service's
|
|
176
|
+
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.",
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
|
package/dist/esm/cli/init.js
CHANGED
|
@@ -39,7 +39,7 @@ const PLANS_PAGE = `"use client";
|
|
|
39
39
|
// with the other exports) whenever you want your own copy or layout.
|
|
40
40
|
export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
|
|
41
41
|
`;
|
|
42
|
-
const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's
|
|
42
|
+
const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's Integration tab.
|
|
43
43
|
CONNECT_API_KEY=
|
|
44
44
|
CONNECT_CLIENT_ID=
|
|
45
45
|
`;
|
|
@@ -106,7 +106,7 @@ export function runInit(argv = []) {
|
|
|
106
106
|
console.log(` ${marker} ${relative(root, target)} - ${scaffold.description}`);
|
|
107
107
|
}
|
|
108
108
|
const env = ensureEnv(root);
|
|
109
|
-
console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your service's
|
|
109
|
+
console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your service's Integration tab`);
|
|
110
110
|
// The two steps a scaffolder must not do for you: editing an existing layout
|
|
111
111
|
// means parsing and rewriting JSX someone else wrote, and the redirect URI
|
|
112
112
|
// lives in Connect, not in this repo.
|
|
@@ -132,7 +132,7 @@ Three things left, all one-time:
|
|
|
132
132
|
Tailwind roots its content scan inside node_modules and skips your app.
|
|
133
133
|
|
|
134
134
|
3. Register {your origin}/api/connect/callback as a redirect URI in your
|
|
135
|
-
service's
|
|
135
|
+
service's Integration tab in Connect.
|
|
136
136
|
|
|
137
137
|
No next.config.ts changes needed - not even transpilePackages: dist/ is
|
|
138
138
|
already ES2022 with "use client" hoisted, so Turbopack and webpack consume it
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Ban, AlertTriangle, Clock, CreditCard, ExternalLink, X } from "lucide-react";
|
|
5
|
+
import { Button, Card, Skeleton } from "@heroui/react";
|
|
6
|
+
import { blockingBlockers } from "../../helpers.js";
|
|
7
|
+
import { useConnect, useConnectEntitlements } from "../provider.js";
|
|
8
|
+
import { ENTER, m, MotionProvider } from "./motion.js";
|
|
9
|
+
import { ConnectReadinessBanner } from "./readiness.js";
|
|
10
|
+
function formatDate(iso) {
|
|
11
|
+
return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The single composed answer to "can this workspace use my app right now,
|
|
15
|
+
* and if not, which of the four things is it": choose a plan, wait on
|
|
16
|
+
* approval, resolve an overdue payment, or - a suspended workspace - none of
|
|
17
|
+
* the above, because nothing short of a platform admin fixes that one.
|
|
18
|
+
*
|
|
19
|
+
* Reuses the existing single sources of truth rather than re-deriving them:
|
|
20
|
+
* `blockingBlockers()` (`helpers.ts`) for severity, the same readiness read
|
|
21
|
+
* `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
|
|
22
|
+
* is a priority ordering over the same data, not a second opinion about it.
|
|
23
|
+
*
|
|
24
|
+
* Priority, first match wins: `suspended` > `no_subscription` >
|
|
25
|
+
* `onboarding_pending` > any other blocking code (`blocked`) >
|
|
26
|
+
* `payment_retry` (advisory - `children` still render) > `clear`.
|
|
27
|
+
*/
|
|
28
|
+
export function useConnectAccessGate() {
|
|
29
|
+
const { session, isLoading: sessionLoading } = useConnect();
|
|
30
|
+
const { data, isLoading: entitlementsLoading } = useConnectEntitlements();
|
|
31
|
+
if (sessionLoading || (session?.user && entitlementsLoading)) {
|
|
32
|
+
return { state: "loading", blocker: null, paymentRetry: null };
|
|
33
|
+
}
|
|
34
|
+
if (!session?.user) {
|
|
35
|
+
return { state: "signed_out", blocker: null, paymentRetry: null };
|
|
36
|
+
}
|
|
37
|
+
if (!data) {
|
|
38
|
+
// Signed in, not loading, yet nothing came back - a failed read (see
|
|
39
|
+
// `error` on `useConnectEntitlements`). Fail open rather than hard-block
|
|
40
|
+
// the whole app on a network hiccup; `ConnectReadinessBanner` makes the
|
|
41
|
+
// same call for its own "no data" branch.
|
|
42
|
+
return { state: "clear", blocker: null, paymentRetry: null };
|
|
43
|
+
}
|
|
44
|
+
const readiness = data.readiness;
|
|
45
|
+
const blocking = blockingBlockers(readiness);
|
|
46
|
+
const suspended = blocking.find((b) => b.code === "organization_suspended");
|
|
47
|
+
if (suspended)
|
|
48
|
+
return { state: "suspended", blocker: suspended, paymentRetry: null };
|
|
49
|
+
const noSubscription = blocking.find((b) => b.code === "subscription_missing");
|
|
50
|
+
if (noSubscription)
|
|
51
|
+
return { state: "no_subscription", blocker: noSubscription, paymentRetry: null };
|
|
52
|
+
const onboardingPending = blocking.find((b) => b.code === "onboarding_approval_pending");
|
|
53
|
+
if (onboardingPending) {
|
|
54
|
+
return { state: "onboarding_pending", blocker: onboardingPending, paymentRetry: null };
|
|
55
|
+
}
|
|
56
|
+
if (blocking.length > 0) {
|
|
57
|
+
return { state: "blocked", blocker: blocking[0] ?? null, paymentRetry: null };
|
|
58
|
+
}
|
|
59
|
+
const paymentRetry = readiness.readiness.blockers.find((b) => b.code === "payment_retry_pending");
|
|
60
|
+
if (paymentRetry)
|
|
61
|
+
return { state: "payment_retry", blocker: null, paymentRetry };
|
|
62
|
+
return { state: "clear", blocker: null, paymentRetry: null };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Wrap your app's content once - typically in your root layout, alongside
|
|
66
|
+
* (not instead of) your own header/nav - and it renders exactly one of: the
|
|
67
|
+
* hard-block card for a suspended workspace, one with no plan, or one
|
|
68
|
+
* waiting on approval; your `children` plus a dismissible payment-due banner
|
|
69
|
+
* during an automatic dunning retry; or just `children` once everything is
|
|
70
|
+
* clear.
|
|
71
|
+
*
|
|
72
|
+
* **Do not wrap your own plans/pricing page with this.** The `no_subscription`
|
|
73
|
+
* card's button sends someone to Connect's subscribe page directly (see
|
|
74
|
+
* `GateCard`'s doc comment on why - it used to link to your in-app `/plans`
|
|
75
|
+
* instead, which is a dead end once this gate hides that very page too), but
|
|
76
|
+
* a customer can still navigate to your plans page on their own (a nav link,
|
|
77
|
+
* a bookmark) and must actually see it there. Since the gate is mounted once
|
|
78
|
+
* around every route, carving out that exception is your router's job, not
|
|
79
|
+
* a prop on this component: check the current path in your layout/shell and
|
|
80
|
+
* skip wrapping `children` in `<ConnectAccessGate>` for that one route.
|
|
81
|
+
*
|
|
82
|
+
* **Signed-out renders `children`, not a fallback** - deliberately different
|
|
83
|
+
* from `ConnectReadinessGate`/`ConnectSubscriptionGate`, which show nothing
|
|
84
|
+
* until signed in. None of the four states this component composes is a
|
|
85
|
+
* property of a signed-out visitor, and a component mounted around your
|
|
86
|
+
* entire app must not hide a public landing page behind an empty gate. Your
|
|
87
|
+
* own sign-in flow decides what a signed-out visitor sees.
|
|
88
|
+
*
|
|
89
|
+
* **This is a UX gate, not a security boundary** - same caveat as every
|
|
90
|
+
* other gate in this package. The authoritative check is
|
|
91
|
+
* `client.entitlements.getReadiness` on your server, before you do the
|
|
92
|
+
* thing.
|
|
93
|
+
*/
|
|
94
|
+
export function ConnectAccessGate({ children, fallback, loading, className }) {
|
|
95
|
+
const gate = useConnectAccessGate();
|
|
96
|
+
if (gate.state === "loading") {
|
|
97
|
+
return _jsx(_Fragment, { children: loading ?? _jsx(Skeleton, { className: `h-32 rounded-xl ${className ?? ""}` }) });
|
|
98
|
+
}
|
|
99
|
+
if (gate.state === "signed_out" || gate.state === "clear") {
|
|
100
|
+
return _jsx(_Fragment, { children: children });
|
|
101
|
+
}
|
|
102
|
+
if (gate.state === "payment_retry") {
|
|
103
|
+
return (_jsxs(_Fragment, { children: [gate.paymentRetry && _jsx(PaymentRetryBanner, { blocker: gate.paymentRetry, className: className }), children] }));
|
|
104
|
+
}
|
|
105
|
+
return (_jsx(_Fragment, { children: fallback ?? _jsx(AccessGateFallback, { state: gate.state, blocker: gate.blocker, className: className }) }));
|
|
106
|
+
}
|
|
107
|
+
function AccessGateFallback({ state, blocker, className, }) {
|
|
108
|
+
if (state === "no_subscription") {
|
|
109
|
+
return (_jsx(GateCard, { icon: 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 }));
|
|
110
|
+
}
|
|
111
|
+
if (state === "suspended") {
|
|
112
|
+
return (_jsx(GateCard, { icon: Ban, iconClassName: "text-danger", title: "Workspace suspended", message: blocker?.message ?? "This workspace has been suspended.", resolveUrl: null, className: className }));
|
|
113
|
+
}
|
|
114
|
+
if (state === "onboarding_pending") {
|
|
115
|
+
return (_jsx(GateCard, { icon: 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 }));
|
|
116
|
+
}
|
|
117
|
+
// "blocked" - any other blocking code (verification, audience, seats, ...).
|
|
118
|
+
// Full fidelity for a code this component doesn't special-case: the same
|
|
119
|
+
// banner-in-a-card `ConnectReadinessGate` falls back to, rather than
|
|
120
|
+
// `GateCard`'s single-blocker shape - there can be more than one of these
|
|
121
|
+
// at once, and each already carries its own action.
|
|
122
|
+
return (_jsx(Card, { className: className, children: _jsx(Card.Content, { className: "py-6", children: _jsx(ConnectReadinessBanner, {}) }) }));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* One card shape for every hard-block state: an icon, what's wrong, and -
|
|
126
|
+
* when the server sent a `resolveUrl` - the one button that fixes it,
|
|
127
|
+
* opening Connect directly in a new tab. The same pattern every other
|
|
128
|
+
* resolvable blocker in this package uses (`BlockerRow`, `PaymentRetryBanner`
|
|
129
|
+
* below) - `no_subscription` used to be the one exception, sending someone
|
|
130
|
+
* to the host app's own `/plans` page instead. That was a deliberate choice
|
|
131
|
+
* on `ConnectNoSubscriptionCard` (browsing plans belongs in your product),
|
|
132
|
+
* but inside a gate mounted around your *entire* app it is a dead end: the
|
|
133
|
+
* card replaces whatever page it is rendered on, including your own plans
|
|
134
|
+
* page if you did not carve it out (see `ConnectAccessGate`'s doc comment),
|
|
135
|
+
* so "see plans" landing back on the same blocked screen never gets anyone
|
|
136
|
+
* to a choice they can act on. Sending them to Connect's own subscribe page
|
|
137
|
+
* always resolves.
|
|
138
|
+
*/
|
|
139
|
+
function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
|
|
140
|
+
return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), _jsx(Card.Title, { children: title })] }), _jsx(Card.Description, { children: message })] }), resolveUrl && (_jsx(Card.Footer, { children: _jsxs(Button, { variant: "primary", onPress: () => window.open(resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel ?? "Resolve on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The skippable payment-due warning. Dismissal is scoped to this specific
|
|
144
|
+
* dunning cycle - keyed by `graceEndsAt`, so a dismissed banner reappears on
|
|
145
|
+
* its own if a *new* retry cycle starts (a different `graceEndsAt`) even
|
|
146
|
+
* though an old dismissal is still on record.
|
|
147
|
+
*/
|
|
148
|
+
function PaymentRetryBanner({ blocker, className, }) {
|
|
149
|
+
const dismissKey = blocker.graceEndsAt ? `connect_dismissed_payment_retry_pending_${blocker.graceEndsAt}` : null;
|
|
150
|
+
const [dismissed, setDismissed] = useState(() => dismissKey !== null && typeof window !== "undefined" && window.localStorage.getItem(dismissKey) === "1");
|
|
151
|
+
if (dismissed)
|
|
152
|
+
return null;
|
|
153
|
+
return (_jsx(MotionProvider, { children: _jsxs(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: ENTER, children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), _jsxs("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: ["Resolve payment", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
|
|
154
|
+
setDismissed(true);
|
|
155
|
+
if (dismissKey && typeof window !== "undefined")
|
|
156
|
+
window.localStorage.setItem(dismissKey, "1");
|
|
157
|
+
}, children: _jsx(X, { className: "size-4" }) })] })] }) }));
|
|
158
|
+
}
|
|
@@ -4,6 +4,8 @@ import { useState } from "react";
|
|
|
4
4
|
import { AlertDialog, Button, Chip, Skeleton } from "@heroui/react";
|
|
5
5
|
import { LogOut, Monitor, MonitorSmartphone, Smartphone, Tablet } from "lucide-react";
|
|
6
6
|
import { useConnect, useConnectSessions } from "../provider.js";
|
|
7
|
+
import { AnimatePresence, EASE_OUT_QUART, m, MotionProvider } from "./motion.js";
|
|
8
|
+
import { ICON, PANEL_FILL } from "./surfaces.js";
|
|
7
9
|
import { formatRelativeTime } from "../../format-relative-time.js";
|
|
8
10
|
const DEVICE_ICONS = {
|
|
9
11
|
desktop: Monitor,
|
|
@@ -75,13 +77,13 @@ export function ConnectAccountSessions({ className, maxVisible = 4 }) {
|
|
|
75
77
|
const [isRevokingOthers, setIsRevokingOthers] = useState(false);
|
|
76
78
|
const [showAll, setShowAll] = useState(false);
|
|
77
79
|
if (isLoading) {
|
|
78
|
-
return (_jsxs("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [_jsx(Skeleton, { className: "h-4 w-28" }), _jsx("div", { className:
|
|
80
|
+
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 ${PANEL_FILL}`, 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-xl" }), _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
81
|
}
|
|
80
82
|
// A failed read used to render as *nothing*, which is indistinguishable
|
|
81
83
|
// from "you have no other devices" - the one reading of this list that
|
|
82
84
|
// should never be guessed at.
|
|
83
85
|
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:
|
|
86
|
+
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 ${PANEL_FILL} px-3 py-4 text-xs text-muted`, children: "Could not load your sessions. Try again in a moment." })] }));
|
|
85
87
|
}
|
|
86
88
|
const sessions = ordered(data?.items ?? []);
|
|
87
89
|
if (sessions.length === 0)
|
|
@@ -89,21 +91,21 @@ export function ConnectAccountSessions({ className, maxVisible = 4 }) {
|
|
|
89
91
|
const hasOtherSessions = sessions.some((session) => !session.isCurrent);
|
|
90
92
|
const isCollapsible = maxVisible > 0 && sessions.length > maxVisible;
|
|
91
93
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
94
|
+
return (_jsx(MotionProvider, { children: _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: () => {
|
|
95
|
+
setIsRevokingOthers(true);
|
|
96
|
+
void mutateResource("/sessions/revoke-others")
|
|
97
|
+
.then(() => {
|
|
98
|
+
invalidateResource("/sessions");
|
|
99
|
+
refetch();
|
|
100
|
+
})
|
|
101
|
+
.finally(() => setIsRevokingOthers(false));
|
|
102
|
+
}, children: "Sign out others" }))] }), _jsx("div", { className: `flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border ${PANEL_FILL}`, children: _jsx(AnimatePresence, { initial: false, children: visible.map((session) => {
|
|
103
|
+
const Icon = DEVICE_ICONS[session.device.deviceType];
|
|
104
|
+
return (_jsxs(m.div, { layout: true, exit: { opacity: 0, height: 0, overflow: "hidden" }, transition: { duration: 0.25, ease: EASE_OUT_QUART }, 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-xl ${session.isCurrent ? "bg-accent-soft text-accent-soft-foreground" : "bg-surface text-muted"}`, children: _jsx(Icon, { className: ICON, "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, { size: "sm", variant: "soft", color: "accent", className: "shrink-0", children: "This device" }))] }), _jsx("p", { className: "truncate text-xs text-muted", children: metaLine(session) })] }), !session.isCurrent && (_jsx(RevokeRow, { session: session, onRevoke: () => {
|
|
105
|
+
void mutateResource("/sessions/revoke", { sessionId: session.id }).then(() => {
|
|
106
|
+
invalidateResource("/sessions");
|
|
107
|
+
refetch();
|
|
108
|
+
});
|
|
109
|
+
} }))] }, session.id));
|
|
110
|
+
}) }) }), 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
111
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { Button, Drawer, Tooltip } from "@heroui/react";
|
|
5
|
+
import { Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
6
|
+
import { useConnect } from "../provider.js";
|
|
7
|
+
import { ConnectAccessGate } from "./access-gate.js";
|
|
8
|
+
import { ConnectAppsGrid } from "./apps-grid.js";
|
|
9
|
+
import { ConnectLoginButton, ConnectRegisterButton } from "./auth-buttons.js";
|
|
10
|
+
import { ConnectLogo } from "./logo.js";
|
|
11
|
+
import { ConnectSideNav, defaultRenderLink } from "./side-nav.js";
|
|
12
|
+
import { ConnectUserMenu } from "./user-menu.js";
|
|
13
|
+
import { gateExemptPaths, normalizePath, } from "./nav.js";
|
|
14
|
+
import { useActivePath } from "../use-active-path.js";
|
|
15
|
+
const COLLAPSED_KEY = "connect_sidenav_collapsed";
|
|
16
|
+
const COLLAPSED_EVENT = "connect:sidenav-collapsed";
|
|
17
|
+
/**
|
|
18
|
+
* The stored rail preference, or `null` when there isn't one (or when reading
|
|
19
|
+
* it is not allowed).
|
|
20
|
+
*
|
|
21
|
+
* `localStorage` throws outright in a partitioned iframe or with site data
|
|
22
|
+
* blocked, and an application frame is not worth losing over a preference.
|
|
23
|
+
*/
|
|
24
|
+
function collapsedSnapshot() {
|
|
25
|
+
try {
|
|
26
|
+
const stored = window.localStorage.getItem(COLLAPSED_KEY);
|
|
27
|
+
return stored === null ? null : stored === "1";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* No preference on the server, which is the point: `useSyncExternalStore` uses
|
|
35
|
+
* this for the hydrating render and only then swaps in the real reading, so a
|
|
36
|
+
* returning user's collapsed rail can never make the server's HTML and the
|
|
37
|
+
* client's first pass disagree. Reading storage during render instead is a
|
|
38
|
+
* hydration mismatch waiting for its first user.
|
|
39
|
+
*/
|
|
40
|
+
function serverCollapsedSnapshot() {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function subscribeCollapsed(onStoreChange) {
|
|
44
|
+
// `storage` covers the app's other tabs; the custom event covers this one,
|
|
45
|
+
// which `storage` deliberately never fires in.
|
|
46
|
+
window.addEventListener("storage", onStoreChange);
|
|
47
|
+
window.addEventListener(COLLAPSED_EVENT, onStoreChange);
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener("storage", onStoreChange);
|
|
50
|
+
window.removeEventListener(COLLAPSED_EVENT, onStoreChange);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The sidebar's brand row: the lockup when expanded, the badge alone when not.
|
|
55
|
+
*/
|
|
56
|
+
function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
|
|
57
|
+
return renderLink({
|
|
58
|
+
href: homeHref,
|
|
59
|
+
className: `flex items-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ${collapsed ? "justify-center" : ""}`,
|
|
60
|
+
// `ConnectLogo` names itself, but it does it with `role="img"` on a
|
|
61
|
+
// `<span>`, which the `<a>` wrapping it does not inherit as its own name.
|
|
62
|
+
"aria-label": `${name} home`,
|
|
63
|
+
children: _jsx(ConnectLogo, { name: name, icon: icon, collapsed: collapsed }),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The application frame every CosmoOps service platform runs inside: a
|
|
68
|
+
* collapsible sidebar, a top bar carrying the app launcher and the account
|
|
69
|
+
* menu, and your pages in the middle.
|
|
70
|
+
*
|
|
71
|
+
* This is the piece each partner was otherwise writing by hand, and writing
|
|
72
|
+
* slightly differently - which is how a family of services stops looking like
|
|
73
|
+
* one product. Everything that should be identical across platforms (the
|
|
74
|
+
* lockup, the rail geometry, the active-route treatment, the launcher, the
|
|
75
|
+
* account menu, the access gate) lives here; everything that genuinely
|
|
76
|
+
* differs is a prop:
|
|
77
|
+
*
|
|
78
|
+
* ```tsx
|
|
79
|
+
* "use client";
|
|
80
|
+
* import Link from "next/link";
|
|
81
|
+
* import { usePathname } from "next/navigation";
|
|
82
|
+
* import { ConnectAppShell } from "@cosmoops/connect-sdk/client";
|
|
83
|
+
* import { LayoutDashboard, Truck } from "lucide-react";
|
|
84
|
+
*
|
|
85
|
+
* export function Shell({ children }: { children: React.ReactNode }) {
|
|
86
|
+
* return (
|
|
87
|
+
* <ConnectAppShell
|
|
88
|
+
* name="Dispatch"
|
|
89
|
+
* icon={<Truck />}
|
|
90
|
+
* activePath={usePathname()}
|
|
91
|
+
* renderLink={(props) => <Link {...props} />}
|
|
92
|
+
* nav={[
|
|
93
|
+
* { label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
|
|
94
|
+
* { label: "Plans", href: "/plans", icon: <Truck className="size-5" />, gateExempt: true },
|
|
95
|
+
* ]}
|
|
96
|
+
* >
|
|
97
|
+
* {children}
|
|
98
|
+
* </ConnectAppShell>
|
|
99
|
+
* );
|
|
100
|
+
* }
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* There is no `isSignedIn` branch anywhere in here, and there should be none
|
|
104
|
+
* in yours: the auth buttons render nothing once a session exists, the user
|
|
105
|
+
* menu renders nothing without one, and the launcher renders nothing when
|
|
106
|
+
* signed out. The swap happens inside the components.
|
|
107
|
+
*
|
|
108
|
+
* It renders no provider of its own - mount `ConnectProvider` above it, once,
|
|
109
|
+
* so a page with a shell, a plan table and a usage meter still makes one
|
|
110
|
+
* session read.
|
|
111
|
+
*/
|
|
112
|
+
export function ConnectAppShell({ name, icon, homeHref = "/", nav, activePath, renderLink = defaultRenderLink, title, headerActions, sidebarFooter, footer, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, collapsible = true, showAppsGrid = true, showAuthButtons = true, theme, onThemeChange, gate = true, gateExemptRoutes, className, contentClassName, children, }) {
|
|
113
|
+
const { session, isLoading } = useConnect();
|
|
114
|
+
const pathname = useActivePath(activePath);
|
|
115
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
116
|
+
// The rail's own state lives in `localStorage`, read as an external store
|
|
117
|
+
// rather than copied into React state: it is shared with this app's other
|
|
118
|
+
// tabs, and the reading is not available during the render that hydrates.
|
|
119
|
+
const stored = useSyncExternalStore(subscribeCollapsed, collapsedSnapshot, serverCollapsedSnapshot);
|
|
120
|
+
const isControlled = controlledCollapsed !== undefined;
|
|
121
|
+
const collapsed = collapsible ? (isControlled ? controlledCollapsed : (stored ?? defaultCollapsed)) : false;
|
|
122
|
+
const toggleCollapsed = useCallback(() => {
|
|
123
|
+
const next = !collapsed;
|
|
124
|
+
onCollapsedChange?.(next);
|
|
125
|
+
if (isControlled)
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
window.localStorage.setItem(COLLAPSED_KEY, next ? "1" : "0");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Not persisted - but the event below still moves the rail for this session.
|
|
132
|
+
}
|
|
133
|
+
window.dispatchEvent(new Event(COLLAPSED_EVENT));
|
|
134
|
+
}, [collapsed, isControlled, onCollapsedChange]);
|
|
135
|
+
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
|
136
|
+
const currentPath = normalizePath(pathname);
|
|
137
|
+
const exempt = [...gateExemptPaths(nav), ...(gateExemptRoutes ?? []).map(normalizePath)];
|
|
138
|
+
const isExempt = exempt.some((path) => currentPath === path || currentPath.startsWith(`${path}/`));
|
|
139
|
+
// A signed-out visitor has no readiness to check and nothing to be blocked
|
|
140
|
+
// by; `ConnectAccessGate` passes them straight through, but mounting it at
|
|
141
|
+
// all costs an entitlements read that cannot succeed.
|
|
142
|
+
const signedOut = !isLoading && !session?.user;
|
|
143
|
+
const gated = gate && !isExempt && !signedOut;
|
|
144
|
+
const sideNav = (mobile) => (_jsx(ConnectSideNav, { nav: nav, activePath: pathname, renderLink: renderLink, collapsed: mobile ? false : collapsed, onNavigate: mobile ? closeMobile : undefined }));
|
|
145
|
+
return (_jsxs("div", { className: `flex h-screen bg-background ${className ?? ""}`, children: [_jsxs("aside", { "aria-label": "Sidebar", className: `hidden shrink-0 flex-col border-r border-border bg-surface transition-[width] duration-200 ease-in-out will-change-[width] md:flex ${collapsed ? "w-16" : "w-64"}`, children: [_jsx("div", { className: `flex h-16 shrink-0 items-center border-b border-border ${collapsed ? "justify-center px-2" : "px-4"}`, children: _jsx(BrandLink, { name: name, icon: icon, homeHref: homeHref, collapsed: collapsed, renderLink: renderLink }) }), _jsx("div", { className: `flex-1 overflow-y-auto py-4 ${collapsed ? "px-2" : "px-3"}`, children: sideNav(false) }), _jsxs("div", { className: `shrink-0 border-t border-border ${collapsed ? "flex flex-col items-center gap-2 p-2" : "flex flex-col gap-2 p-3"}`, children: [!collapsed && sidebarFooter, collapsible && (
|
|
146
|
+
// The button goes in as a *direct* child, the way `Modal` and
|
|
147
|
+
// `Dropdown` take theirs. `Tooltip.Trigger` renders a wrapper
|
|
148
|
+
// `<div>` and puts the props it is given on that div - so an
|
|
149
|
+
// `aria-label` passed there names a div nobody can reach and
|
|
150
|
+
// leaves this icon-only button with no accessible name at all.
|
|
151
|
+
// The wrapper is for trigger elements React Aria cannot drive
|
|
152
|
+
// directly; a HeroUI `Button` is not one.
|
|
153
|
+
_jsxs(Tooltip, { delay: 200, children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar", className: collapsed ? "" : "self-start", onPress: toggleCollapsed, children: collapsed ? _jsx(PanelLeftOpen, { className: "size-4" }) : _jsx(PanelLeftClose, { className: "size-4" }) }), _jsx(Tooltip.Content, { placement: "right", children: collapsed ? "Expand sidebar" : "Collapse sidebar" })] }))] })] }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-72 max-w-[80vw]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", children: [_jsx(Drawer.CloseTrigger, {}), _jsx(Drawer.Header, { children: _jsx(Drawer.Heading, { "aria-label": name, children: _jsx(ConnectLogo, { name: name, icon: icon }) }) }), _jsx(Drawer.Body, { children: sideNav(true) }), sidebarFooter && _jsx(Drawer.Footer, { className: "flex flex-col items-stretch gap-3", children: sidebarFooter })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-16 shrink-0 items-center gap-3 border-b border-border bg-surface px-4 md:px-6", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", className: "md:hidden", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), typeof title === "string" ? (_jsx("h1", { className: "truncate text-lg font-semibold text-foreground", children: title })) : (title), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [headerActions, showAppsGrid && _jsx(ConnectAppsGrid, {}), _jsx(ConnectUserMenu, { compact: true, theme: theme, onThemeChange: onThemeChange }), showAuthButtons && (_jsxs(_Fragment, { children: [_jsx(ConnectLoginButton, { size: "sm" }), _jsx(ConnectRegisterButton, { size: "sm" })] }))] })] }), _jsx("main", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: contentClassName ?? "mx-auto w-full max-w-6xl px-4 py-6 md:px-6 md:py-8", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] })] }));
|
|
154
|
+
}
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { Button, Modal, Skeleton } from "@heroui/react";
|
|
4
4
|
import { AlertCircle, ExternalLink, LayoutGrid } from "lucide-react";
|
|
5
5
|
import { useConnectApps, useConnect } from "../provider.js";
|
|
6
|
+
import { ENTER, m, MotionProvider } from "./motion.js";
|
|
6
7
|
function AppTile({ app }) {
|
|
7
8
|
const disabled = app.launchUrl === null;
|
|
8
9
|
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 ? (
|
|
@@ -35,7 +36,7 @@ export function ConnectAppsGrid({ className, label = "Switch app" }) {
|
|
|
35
36
|
if (sessionLoading || !session?.user)
|
|
36
37
|
return null;
|
|
37
38
|
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(
|
|
39
|
+
return (_jsx(MotionProvider, { children: _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(m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsx(AppTile, { app: app }) }, app.serviceId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
|
|
39
40
|
}
|
|
40
41
|
function ConnectBrowseAllLink() {
|
|
41
42
|
const { appUrl } = useConnect();
|