@cosmoops/connect-sdk 0.34.0 → 0.35.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 +146 -0
- package/dist/cjs/cli/doctor.js +39 -5
- package/dist/cjs/client/components/shell-context.js +35 -6
- package/dist/cjs/client/components/sidebar.js +6 -1
- package/dist/cjs/client/components/subscription.js +6 -0
- package/dist/cjs/next/index.js +56 -1
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +162 -28
- package/dist/esm/cli/doctor.js +38 -5
- package/dist/esm/client/components/shell-context.js +35 -6
- package/dist/esm/client/components/sidebar.js +6 -1
- package/dist/esm/client/components/subscription.js +6 -0
- package/dist/esm/next/index.js +56 -2
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +162 -28
- package/dist/types/client/components/shell-context.d.ts +39 -7
- package/dist/types/client/components/sidebar.d.ts +2 -2
- package/dist/types/client/components/subscription.d.ts +6 -0
- package/dist/types/next/index.d.ts +49 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -58,6 +58,99 @@ function sameState(a, b) {
|
|
|
58
58
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
59
59
|
return diff === 0;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* How many sign-ins one browser may have in flight at once.
|
|
63
|
+
*
|
|
64
|
+
* The state cookie used to hold a *single* `state:next` pair, so a second
|
|
65
|
+
* `GET /signin` overwrote the first. Two tabs - or one abandoned attempt and
|
|
66
|
+
* one fresh one - and the older round trip came back to a cookie naming a
|
|
67
|
+
* state it had never heard of: `connect_error=state_mismatch`, which the host
|
|
68
|
+
* app's own session gate answers by silently firing another `/signin`, putting
|
|
69
|
+
* the visitor back on the sign-in screen with nothing on the page saying why.
|
|
70
|
+
*
|
|
71
|
+
* Five is far past what anyone opens by hand and still a cookie nobody
|
|
72
|
+
* notices - each entry is a 43-character state plus a short path. Entries are
|
|
73
|
+
* newest-first and the oldest fall off the end, so the tab a visitor is
|
|
74
|
+
* actually looking at is the last one to be forgotten.
|
|
75
|
+
*
|
|
76
|
+
* It does not weaken the CSRF check: a callback must still present one of
|
|
77
|
+
* these exact 256-bit values, and each still expires with the cookie.
|
|
78
|
+
*/
|
|
79
|
+
const MAX_PENDING_STATES = 5;
|
|
80
|
+
/** `-` and `r` are the only flag fields this format writes - see `decodePendingState`. */
|
|
81
|
+
const RETRIED_FLAG = "r";
|
|
82
|
+
const NO_FLAGS = "-";
|
|
83
|
+
/**
|
|
84
|
+
* The state cookie's wire format: one entry per in-flight sign-in, newest
|
|
85
|
+
* first, joined by `|`.
|
|
86
|
+
*
|
|
87
|
+
* An entry is `state:flags:next` with the destination **percent-encoded**
|
|
88
|
+
* rather than written raw, and that is what makes splitting on a delimiter
|
|
89
|
+
* safe: `state` is base64url so it can hold neither, and an encoded path
|
|
90
|
+
* cannot be read as a field boundary however many colons or pipes it
|
|
91
|
+
* contains. The raw single-pair format this replaces is exactly why `retried`
|
|
92
|
+
* and the silent marker had to live outside the cookie - the split was too
|
|
93
|
+
* fragile to carry a third field.
|
|
94
|
+
*/
|
|
95
|
+
function encodePendingStates(entries) {
|
|
96
|
+
return entries
|
|
97
|
+
.map((entry) => `${entry.state}:${entry.retried ? RETRIED_FLAG : NO_FLAGS}:${encodeURIComponent(entry.next)}`)
|
|
98
|
+
.join("|");
|
|
99
|
+
}
|
|
100
|
+
function decodePendingStates(raw) {
|
|
101
|
+
if (!raw)
|
|
102
|
+
return [];
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const part of raw.split("|")) {
|
|
105
|
+
const entry = decodePendingState(part);
|
|
106
|
+
if (entry)
|
|
107
|
+
entries.push(entry);
|
|
108
|
+
}
|
|
109
|
+
return entries;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* One entry, tolerating the format this replaced.
|
|
113
|
+
*
|
|
114
|
+
* A cookie minted by the previous version is a single raw `state:next` pair,
|
|
115
|
+
* and sign-ins in flight across a deploy carry one. The flag field is a known
|
|
116
|
+
* one-character set, so anything else in that position means the remainder is
|
|
117
|
+
* a legacy destination - including one that itself contains a colon. Dropping
|
|
118
|
+
* those instead would answer every mid-deploy callback with
|
|
119
|
+
* `state_mismatch`.
|
|
120
|
+
*/
|
|
121
|
+
function decodePendingState(part) {
|
|
122
|
+
const firstColon = part.indexOf(":");
|
|
123
|
+
if (firstColon <= 0)
|
|
124
|
+
return null;
|
|
125
|
+
const state = part.slice(0, firstColon);
|
|
126
|
+
const remainder = part.slice(firstColon + 1);
|
|
127
|
+
const secondColon = remainder.indexOf(":");
|
|
128
|
+
const flags = secondColon === -1 ? "" : remainder.slice(0, secondColon);
|
|
129
|
+
const hasFlags = flags === NO_FLAGS || flags === RETRIED_FLAG;
|
|
130
|
+
return {
|
|
131
|
+
state,
|
|
132
|
+
next: decodeDestination(hasFlags ? remainder.slice(secondColon + 1) : remainder),
|
|
133
|
+
retried: flags === RETRIED_FLAG,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* `decodeURIComponent` throws on a lone `%`, which a legacy raw destination
|
|
138
|
+
* may well hold. The undecoded string then goes through `safeRedirect` like
|
|
139
|
+
* any other candidate, so a malformed one costs the default destination
|
|
140
|
+
* rather than a 500.
|
|
141
|
+
*/
|
|
142
|
+
function decodeDestination(value) {
|
|
143
|
+
try {
|
|
144
|
+
return decodeURIComponent(value);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Appends `connect_error=<code>` to a destination that may already carry a query. */
|
|
151
|
+
function withError(next, code) {
|
|
152
|
+
return `${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(code)}`;
|
|
153
|
+
}
|
|
61
154
|
function readCookie(request, name) {
|
|
62
155
|
const header = request.headers.get("cookie");
|
|
63
156
|
if (!header)
|
|
@@ -130,6 +223,10 @@ function createConnectHandler(options) {
|
|
|
130
223
|
* the cross-site redirect back from Connect, which is the one request that
|
|
131
224
|
* has to read it. Scoped to `basePath` so it is never attached to ordinary
|
|
132
225
|
* page loads.
|
|
226
|
+
*
|
|
227
|
+
* Holds every in-flight round trip rather than the latest one - see
|
|
228
|
+
* `MAX_PENDING_STATES` and `encodePendingStates`. An empty value clears it,
|
|
229
|
+
* which is what an exchange that consumed the last pending entry wants.
|
|
133
230
|
*/
|
|
134
231
|
function stateCookie(value, secure) {
|
|
135
232
|
const cleared = value === "";
|
|
@@ -959,6 +1056,17 @@ function createConnectHandler(options) {
|
|
|
959
1056
|
* alongside the post-login destination, and bounces the browser to
|
|
960
1057
|
* Connect.
|
|
961
1058
|
*
|
|
1059
|
+
* The new entry is **prepended to whatever is already pending** rather
|
|
1060
|
+
* than replacing it, so opening the app in a second tab no longer
|
|
1061
|
+
* invalidates the first - see `MAX_PENDING_STATES`.
|
|
1062
|
+
*
|
|
1063
|
+
* `?connect_retry=1` is stamped onto the entry here, which is the only
|
|
1064
|
+
* place that marker can be read: it is a query parameter on *this* URL,
|
|
1065
|
+
* and the `redirect_uri` handed to Connect is a registered, exact-match
|
|
1066
|
+
* value that cannot carry it. The callback used to look for it on its own
|
|
1067
|
+
* URL, where it could never arrive - so both retry bounds below were
|
|
1068
|
+
* unreachable and `login_required` could loop indefinitely.
|
|
1069
|
+
*
|
|
962
1070
|
* Registered only when the session store can `write` - without that, the
|
|
963
1071
|
* callback would have nowhere to put the session it just earned.
|
|
964
1072
|
*/
|
|
@@ -974,9 +1082,13 @@ function createConnectHandler(options) {
|
|
|
974
1082
|
state,
|
|
975
1083
|
...(prompt ? { prompt } : {}),
|
|
976
1084
|
});
|
|
977
|
-
// State and destination travel together in one
|
|
1085
|
+
// State and destination travel together in one entry so the callback
|
|
978
1086
|
// cannot end up with one and not the other.
|
|
979
|
-
const
|
|
1087
|
+
const pending = [
|
|
1088
|
+
{ state, next, retried: url.searchParams.get("connect_retry") === "1" },
|
|
1089
|
+
...decodePendingStates(readCookie(request, stateCookieName)),
|
|
1090
|
+
].slice(0, MAX_PENDING_STATES);
|
|
1091
|
+
const cookies = [stateCookie(encodePendingStates(pending), secure)];
|
|
980
1092
|
if (prompt === "none")
|
|
981
1093
|
cookies.push(silentCookie("1", secure));
|
|
982
1094
|
return redirect(authorizeUrl, cookies);
|
|
@@ -989,20 +1101,49 @@ function createConnectHandler(options) {
|
|
|
989
1101
|
"GET /callback": async (request) => {
|
|
990
1102
|
const url = new URL(request.url);
|
|
991
1103
|
const secure = url.protocol === "https:";
|
|
992
|
-
const
|
|
993
|
-
const next = safeRedirect(destinationParts.join(":") || null, defaultRedirect);
|
|
994
|
-
const clearState = stateCookie("", secure);
|
|
1104
|
+
const pending = decodePendingStates(readCookie(request, stateCookieName));
|
|
995
1105
|
const returnedState = url.searchParams.get("state") ?? "";
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1106
|
+
const matched = pending.find((entry) => sameState(entry.state, returnedState)) ?? null;
|
|
1107
|
+
// Every *other* in-flight sign-in survives this one. Clearing the whole
|
|
1108
|
+
// cookie is what made a second tab collateral damage: finishing in one
|
|
1109
|
+
// tab left the other holding a state the server had just forgotten.
|
|
1110
|
+
const settleState = stateCookie(encodePendingStates(pending.filter((entry) => entry !== matched)), secure);
|
|
1111
|
+
// Only ever this flow's own destination. With no match there is no
|
|
1112
|
+
// destination to trust - a `next` belonging to a different round trip is
|
|
1113
|
+
// somebody else's business, so that case takes the default.
|
|
1114
|
+
const next = safeRedirect(matched?.next ?? null, defaultRedirect);
|
|
1115
|
+
// Read before the state check, not after. A `prompt=none` probe must
|
|
1116
|
+
// never escalate into a visible sign-in - including the retry below -
|
|
1117
|
+
// and the mismatch branch is reachable for a silent probe whose cookie
|
|
1118
|
+
// has expired.
|
|
1004
1119
|
const wasSilent = readCookie(request, silentCookieName) === "1";
|
|
1005
1120
|
const clearSilent = silentCookie("", secure);
|
|
1121
|
+
/** One more interactive attempt, marked so the next pass can tell. */
|
|
1122
|
+
const retrySignIn = () => {
|
|
1123
|
+
const retry = new URL(`${url.origin}${basePath}/signin`);
|
|
1124
|
+
retry.searchParams.set("next", next);
|
|
1125
|
+
retry.searchParams.set("connect_retry", "1");
|
|
1126
|
+
return redirect(retry.toString(), [settleState, clearSilent]);
|
|
1127
|
+
};
|
|
1128
|
+
if (!matched) {
|
|
1129
|
+
// A stale tab whose cookie has expired, more entries in flight than
|
|
1130
|
+
// `MAX_PENDING_STATES` holds, or a forged callback. None of them mints
|
|
1131
|
+
// a session and none is worth an error page.
|
|
1132
|
+
//
|
|
1133
|
+
// One retry, because the overwhelmingly common cause is a round trip
|
|
1134
|
+
// whose own entry is simply gone and whose visitor Connect has already
|
|
1135
|
+
// signed in - that completes in a single hop. The bound reads *any*
|
|
1136
|
+
// pending entry's marker rather than the matched one, there being
|
|
1137
|
+
// none: a browser that has already been bounced once gets the error
|
|
1138
|
+
// instead of another lap. Without it this is the silent infinite loop,
|
|
1139
|
+
// since the host app's session gate answers `/dashboard` with no
|
|
1140
|
+
// session by firing `/signin` again.
|
|
1141
|
+
if (wasSilent)
|
|
1142
|
+
return redirect(next, [settleState, clearSilent]);
|
|
1143
|
+
if (!pending.some((entry) => entry.retried))
|
|
1144
|
+
return retrySignIn();
|
|
1145
|
+
return redirect(withError(next, "state_mismatch"), [settleState, clearSilent]);
|
|
1146
|
+
}
|
|
1006
1147
|
const error = url.searchParams.get("error");
|
|
1007
1148
|
if (error) {
|
|
1008
1149
|
// A silent (`prompt=none`) probe that came back empty-handed. All
|
|
@@ -1017,27 +1158,20 @@ function createConnectHandler(options) {
|
|
|
1017
1158
|
// if they're already here" into "force everyone to log in", which is
|
|
1018
1159
|
// the precise opposite of the feature.
|
|
1019
1160
|
if (wasSilent) {
|
|
1020
|
-
return redirect(next, [
|
|
1161
|
+
return redirect(next, [settleState, clearSilent]);
|
|
1021
1162
|
}
|
|
1022
1163
|
// `login_required` is normal traffic, not a fault: a new account gets
|
|
1023
1164
|
// pulled out to their inbox to verify, and the ~15 minute request
|
|
1024
1165
|
// expires before they return. Connect has already signed them in, so
|
|
1025
|
-
// one silent retry completes in a single hop.
|
|
1026
|
-
// it - a genuine loop must surface, not spin.
|
|
1027
|
-
if (error === "login_required" &&
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
retry.searchParams.set("connect_retry", "1");
|
|
1031
|
-
return redirect(retry.toString(), [clearState, clearSilent]);
|
|
1032
|
-
}
|
|
1033
|
-
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(error)}`, [
|
|
1034
|
-
clearState,
|
|
1035
|
-
clearSilent,
|
|
1036
|
-
]);
|
|
1166
|
+
// one silent retry completes in a single hop. The marker on this
|
|
1167
|
+
// flow's own entry bounds it - a genuine loop must surface, not spin.
|
|
1168
|
+
if (error === "login_required" && !matched.retried)
|
|
1169
|
+
return retrySignIn();
|
|
1170
|
+
return redirect(withError(next, error), [settleState, clearSilent]);
|
|
1037
1171
|
}
|
|
1038
1172
|
const code = url.searchParams.get("code");
|
|
1039
1173
|
if (!code) {
|
|
1040
|
-
return redirect(
|
|
1174
|
+
return redirect(withError(next, "missing_code"), [settleState, clearSilent]);
|
|
1041
1175
|
}
|
|
1042
1176
|
const result = await client.identity.exchangeCode({
|
|
1043
1177
|
code,
|
|
@@ -1046,7 +1180,7 @@ function createConnectHandler(options) {
|
|
|
1046
1180
|
// Verified before the session is stored, not after - an unverifiable
|
|
1047
1181
|
// token must never become a signed-in user.
|
|
1048
1182
|
await client.identity.verifyIdToken(result.idToken);
|
|
1049
|
-
const response = redirect(next, [
|
|
1183
|
+
const response = redirect(next, [settleState, clearSilent]);
|
|
1050
1184
|
await session.write(request, response, result.sessionToken);
|
|
1051
1185
|
return response;
|
|
1052
1186
|
},
|
package/dist/esm/cli/doctor.js
CHANGED
|
@@ -81,14 +81,14 @@ function checkBridge(appDir, root) {
|
|
|
81
81
|
}
|
|
82
82
|
return { status: "pass", title: `Bridge route handler (${relative(root, bridge)})` };
|
|
83
83
|
}
|
|
84
|
-
function checkProvider(appDir, root) {
|
|
84
|
+
function checkProvider(appDir, root, env) {
|
|
85
85
|
const layouts = walk(appDir, ["layout.tsx", "layout.jsx"]);
|
|
86
86
|
const hosting = layouts.filter((path) => /<ConnectProvider/.test(read(path)));
|
|
87
87
|
if (hosting.length === 0) {
|
|
88
88
|
return {
|
|
89
89
|
status: "fail",
|
|
90
90
|
title: "ConnectProvider in a layout",
|
|
91
|
-
detail: 'Nothing renders <ConnectProvider>. Every client hook and component below it throws without one. In app/layout.tsx:\n import { ConnectProvider } from "@cosmoops/connect-sdk/client";\n <ConnectProvider>{children}</ConnectProvider>',
|
|
91
|
+
detail: 'Nothing renders <ConnectProvider>. Every client hook and component below it throws without one. In app/layout.tsx:\n import { connectProviderProps } from "@cosmoops/connect-sdk/next";\n import { ConnectProvider } from "@cosmoops/connect-sdk/client";\n <ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>',
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
// A nested provider re-fetches the session and keeps a second cache that the
|
|
@@ -101,8 +101,42 @@ function checkProvider(appDir, root) {
|
|
|
101
101
|
detail: `Found in ${hosting.length} layouts (${hosting.map((path) => relative(root, path)).join(", ")}). Nested providers keep separate caches, so a mutation in one is invisible to the other. Keep the outermost one only.`,
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
/*
|
|
105
|
+
* The split integration, and the reason this check exists at all.
|
|
106
|
+
*
|
|
107
|
+
* `CONNECT_APP_URL` points the *server* at a non-production Connect. It is
|
|
108
|
+
* not `NEXT_PUBLIC_`, so `ConnectProvider` cannot read it and defaults
|
|
109
|
+
* `appUrl` to production independently - and nothing fails. The API reads go
|
|
110
|
+
* to the configured instance while every destination the browser builds
|
|
111
|
+
* opens production: the user menu's Profile and Billing rows, the launcher's
|
|
112
|
+
* link for an unsubscribed application, the access gate's subscribe target,
|
|
113
|
+
* and checkout. Both applications built on this SDK shipped exactly that.
|
|
114
|
+
*
|
|
115
|
+
* `warn` rather than `fail`: the prop may be threaded from a parent Server
|
|
116
|
+
* Component into a `"use client"` shell, which is the documented shape for a
|
|
117
|
+
* shell needing `usePathname()` - and that assignment is not visible in the
|
|
118
|
+
* layout this reads. So this cannot tell from the outside, which is what
|
|
119
|
+
* `warn` means here.
|
|
120
|
+
*/
|
|
121
|
+
const pointedElsewhere = /^[ \t]*CONNECT_APP_URL[ \t]*=[ \t]*\S/m.test(env);
|
|
122
|
+
const carriesOrigin = /appUrl|connectProviderProps/.test(read(hosting[0]));
|
|
123
|
+
if (pointedElsewhere && !carriesOrigin) {
|
|
124
|
+
return {
|
|
125
|
+
status: "warn",
|
|
126
|
+
title: "ConnectProvider in a layout",
|
|
127
|
+
detail: `CONNECT_APP_URL is set, but ${relative(root, hosting[0])} passes no appUrl - so your server talks to that instance while every link the browser builds opens production Connect. Spread it in:\n <ConnectProvider {...connectProviderProps()}> // from "@cosmoops/connect-sdk/next"\n Already threading it into a "use client" shell? Then this is fine and you can ignore it.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
104
130
|
return { status: "pass", title: `ConnectProvider in a layout (${relative(root, hosting[0])})` };
|
|
105
131
|
}
|
|
132
|
+
/** The env files `checkEnv` reads, as one string, shared with `checkProvider`. */
|
|
133
|
+
function envText(root) {
|
|
134
|
+
return [".env.local", ".env.development.local", ".env"]
|
|
135
|
+
.map((name) => join(root, name))
|
|
136
|
+
.filter(existsSync)
|
|
137
|
+
.map(read)
|
|
138
|
+
.join("\n");
|
|
139
|
+
}
|
|
106
140
|
/**
|
|
107
141
|
* Order is the whole check.
|
|
108
142
|
*
|
|
@@ -143,8 +177,7 @@ function checkTheme(root) {
|
|
|
143
177
|
* loaded it - exactly the shell `next dev` will inherit.
|
|
144
178
|
*/
|
|
145
179
|
function checkEnv(root) {
|
|
146
|
-
const
|
|
147
|
-
const combined = files.filter(existsSync).map(read).join("\n");
|
|
180
|
+
const combined = envText(root);
|
|
148
181
|
return ["CONNECT_API_KEY", "CONNECT_CLIENT_ID"].map((name) => {
|
|
149
182
|
// Horizontal whitespace only. `\s*` also matches the newline, so a bare
|
|
150
183
|
// `CONNECT_API_KEY=` was satisfied by the *next* line's value and an empty
|
|
@@ -188,7 +221,7 @@ export function runDoctor(argv = []) {
|
|
|
188
221
|
console.log(`[connect-sdk] Checking ${relative(process.cwd(), root) || root}\n`);
|
|
189
222
|
const checks = [
|
|
190
223
|
checkBridge(appDir, root),
|
|
191
|
-
checkProvider(appDir, root),
|
|
224
|
+
checkProvider(appDir, root, envText(root)),
|
|
192
225
|
checkTheme(root),
|
|
193
226
|
...checkEnv(root),
|
|
194
227
|
checkRedirectUri(),
|
|
@@ -28,22 +28,51 @@ export const RAIL_GUTTER = 12;
|
|
|
28
28
|
* The row shape every sidebar control shares - nav items, the workspace tile,
|
|
29
29
|
* the launcher, the user button.
|
|
30
30
|
*
|
|
31
|
+
* **The height is not here.** It rides on `SIDEBAR_ROW_ICON` /
|
|
32
|
+
* `SIDEBAR_ROW_TILE` instead, because the two glyph sizes want different
|
|
33
|
+
* heights and only one of them is in the region that overflows - see those
|
|
34
|
+
* two constants.
|
|
35
|
+
*
|
|
31
36
|
* `overflow-hidden` + `whitespace-nowrap` is what makes the collapse smooth:
|
|
32
37
|
* the label is *clipped* by the shrinking rail rather than re-wrapped or
|
|
33
38
|
* re-measured, so a width animation costs no reflow. Fading the label with
|
|
34
39
|
* `SidebarLabel` on top of that hides the clip itself.
|
|
35
40
|
*/
|
|
36
|
-
export const SIDEBAR_ROW = "group/row relative flex
|
|
41
|
+
export const SIDEBAR_ROW = "group/row relative flex w-full shrink-0 items-center justify-start gap-3 overflow-hidden whitespace-nowrap rounded-lg pe-2 text-sm font-normal transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus";
|
|
37
42
|
/**
|
|
38
|
-
* Leading inset for a row whose glyph is 20px wide (a nav icon,
|
|
43
|
+
* Leading inset *and height* for a row whose glyph is 20px wide (a nav icon,
|
|
44
|
+
* the launcher, a secondary-nav row).
|
|
45
|
+
*
|
|
39
46
|
* `10 + 10 = 20`, and `RAIL_GUTTER + 20 = 32`. See `RAIL_COLLAPSED`.
|
|
47
|
+
*
|
|
48
|
+
* **36px, not 40px, and the height belongs on this axis rather than on
|
|
49
|
+
* `SIDEBAR_ROW`.** The nav is the only region of the rail that grows with the
|
|
50
|
+
* host's route count, and `ConnectSidebarBody` is the only region that
|
|
51
|
+
* scrolls - so a rail that overflows overflows here. Ten destinations at 40px
|
|
52
|
+
* came to 436px of nav against 273px of fixed chrome (header, switcher, two
|
|
53
|
+
* footers), which needs a 709px viewport to sit still; a 1366x768 screen has
|
|
54
|
+
* about 678px of it. Four pixels a row is 48px back across the twelve rows a
|
|
55
|
+
* real integration draws, and 36px is still comfortably past the 24px
|
|
56
|
+
* WCAG 2.5.8 target minimum with the row's full width as the hit area.
|
|
57
|
+
*
|
|
58
|
+
* It is deliberately *not* a smaller glyph. The 20px box is a flex centring
|
|
59
|
+
* container for an icon the **host** supplies at its own size, so shrinking
|
|
60
|
+
* the box buys no vertical space and only pushes the icon off the 32px centre
|
|
61
|
+
* the collapsed rail is built on.
|
|
40
62
|
*/
|
|
41
|
-
export const SIDEBAR_ROW_ICON = "ps-[10px]";
|
|
63
|
+
export const SIDEBAR_ROW_ICON = "h-9 ps-[10px]";
|
|
42
64
|
/**
|
|
43
|
-
* Leading inset for a row whose glyph is 32px wide (the avatar, the
|
|
44
|
-
* tile). `4 + 16 = 20`, landing on the same 32px centre as
|
|
65
|
+
* Leading inset and height for a row whose glyph is 32px wide (the avatar, the
|
|
66
|
+
* workspace tile). `4 + 16 = 20`, landing on the same 32px centre as
|
|
67
|
+
* `SIDEBAR_ROW_ICON`.
|
|
68
|
+
*
|
|
69
|
+
* **These stay at 40px.** There are exactly two of them and neither scales
|
|
70
|
+
* with the host's nav, so they are not what overflows - and both need the
|
|
71
|
+
* height: a 32px tile in a 36px row has 2px of air, and the account row's
|
|
72
|
+
* label is two lines (name over email, `leading-tight`) which measures ~32px
|
|
73
|
+
* on its own.
|
|
45
74
|
*/
|
|
46
|
-
export const SIDEBAR_ROW_TILE = "ps-[4px]";
|
|
75
|
+
export const SIDEBAR_ROW_TILE = "h-10 ps-[4px]";
|
|
47
76
|
/**
|
|
48
77
|
* The class list for one sidebar row, in one place.
|
|
49
78
|
*
|
|
@@ -109,7 +109,12 @@ export function ConnectSidebarBody({ children }) {
|
|
|
109
109
|
// zoom away over a region of text is a WCAG 1.4.4 failure, and nothing
|
|
110
110
|
// here needs it suppressed. On the desktop rail, where no ancestor
|
|
111
111
|
// restricts anything, the pair is equivalent to the default.
|
|
112
|
-
|
|
112
|
+
// `py-1` rather than the `py-2` every other region carries: this is the
|
|
113
|
+
// one region that scrolls, so it is the first place to give up air when
|
|
114
|
+
// the nav is a row or two from overflowing. The rows paint their own
|
|
115
|
+
// rounded background, so the hairline above the footer is still clear of
|
|
116
|
+
// them. See `SIDEBAR_ROW_ICON` for the arithmetic this is part of.
|
|
117
|
+
_jsx("div", { className: "flex min-h-0 flex-1 touch-pan-y touch-pinch-zoom flex-col gap-1 overflow-y-auto overflow-x-hidden px-3 py-1", children: children }));
|
|
113
118
|
}
|
|
114
119
|
/**
|
|
115
120
|
* A pinned region at the foot of the rail - the secondary nav, the launcher,
|
|
@@ -119,6 +119,12 @@ function NextEvent({ detail, state, }) {
|
|
|
119
119
|
* count against, so it renders nothing rather than a meter that is always
|
|
120
120
|
* empty. Under `"per-seat"` going over is billable rather than blocked, so it
|
|
121
121
|
* says what the extra costs instead of warning.
|
|
122
|
+
*
|
|
123
|
+
* `detail.seatCount` is a live count of the members holding a role for this
|
|
124
|
+
* application. It used to be the subscription's last-billed snapshot, which
|
|
125
|
+
* only ever moved on a `"per-seat"` plan - so a `"fixed"` single-seat plan
|
|
126
|
+
* read "0 of 1 seat used" permanently, including when its one seat was taken
|
|
127
|
+
* and Connect was already refusing the next assignment.
|
|
122
128
|
*/
|
|
123
129
|
export function ConnectSeatUsage({ plan, detail, className, }) {
|
|
124
130
|
if (plan.seats.model === "unlimited")
|
package/dist/esm/next/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cache } from "react";
|
|
2
|
-
import { ConnectClient } from "../server/client.js";
|
|
2
|
+
import { ConnectClient, DEFAULT_BASE_URL } from "../server/client.js";
|
|
3
3
|
import { createConnectHandler } from "../server/handler.js";
|
|
4
4
|
import { ConnectError } from "../errors.js";
|
|
5
5
|
/**
|
|
@@ -24,8 +24,9 @@ import { ConnectError } from "../errors.js";
|
|
|
24
24
|
*
|
|
25
25
|
* ```tsx
|
|
26
26
|
* // app/layout.tsx
|
|
27
|
+
* import { connectProviderProps } from "@cosmoops/connect-sdk/next";
|
|
27
28
|
* import { ConnectProvider } from "@cosmoops/connect-sdk/client";
|
|
28
|
-
* <ConnectProvider>{children}</ConnectProvider>
|
|
29
|
+
* <ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>
|
|
29
30
|
* ```
|
|
30
31
|
*
|
|
31
32
|
* No `next.config.ts` changes needed - configuration comes from the
|
|
@@ -42,6 +43,10 @@ import { ConnectError } from "../errors.js";
|
|
|
42
43
|
* `ConnectProvider` defaults `appUrl` to the production origin independently,
|
|
43
44
|
* so an app pointed at another instance must pass that prop as well or its
|
|
44
45
|
* portal links and its browser-built authorize URL still go to production.
|
|
46
|
+
* That is what the `connectProviderProps()` spread above is for, and why the
|
|
47
|
+
* layout is two imports rather than one - leave it off and the server half and
|
|
48
|
+
* the browser half of the same integration address different instances, with
|
|
49
|
+
* nothing failing to say so. See that function's own docs.
|
|
45
50
|
*
|
|
46
51
|
* Register `{your origin}/api/connect/callback` as a redirect URI in the
|
|
47
52
|
* application's Connect client section. That is the only step this cannot do
|
|
@@ -82,6 +87,55 @@ export function connectClient(overrides = {}) {
|
|
|
82
87
|
cached = client;
|
|
83
88
|
return client;
|
|
84
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* The `ConnectProvider` props that come from the environment.
|
|
92
|
+
*
|
|
93
|
+
* ```tsx
|
|
94
|
+
* // app/layout.tsx - a Server Component
|
|
95
|
+
* import { connectProviderProps } from "@cosmoops/connect-sdk/next";
|
|
96
|
+
* import { ConnectProvider } from "@cosmoops/connect-sdk/client";
|
|
97
|
+
*
|
|
98
|
+
* <ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* ## What it is for
|
|
102
|
+
*
|
|
103
|
+
* `CONNECT_APP_URL` points the *server* at a non-production Connect. It is not
|
|
104
|
+
* `NEXT_PUBLIC_`, so it never reaches the browser, and `ConnectProvider`
|
|
105
|
+
* defaults `appUrl` to the production origin independently - so pointing the
|
|
106
|
+
* server somewhere else and leaving the prop off splits the integration in
|
|
107
|
+
* half. The server talks to dev and every destination the browser builds opens
|
|
108
|
+
* production: the user menu's Profile and Billing rows, the app launcher's link
|
|
109
|
+
* for an unsubscribed application, `ConnectAccessGate`'s subscribe target, and
|
|
110
|
+
* checkout. Nothing errors. The customer is simply asked to buy a second
|
|
111
|
+
* subscription in an instance where they have none.
|
|
112
|
+
*
|
|
113
|
+
* Both apps built on this SDK shipped that bug before anyone noticed, from
|
|
114
|
+
* reading "the provider takes no props" as a rule rather than as a remark about
|
|
115
|
+
* `clientId`. A root layout is a Server Component, so one spread fixes it with
|
|
116
|
+
* no browser-visible copy of the variable.
|
|
117
|
+
*
|
|
118
|
+
* ## What it deliberately leaves out
|
|
119
|
+
*
|
|
120
|
+
* `clientId` and `basePath`. The first is only needed alongside an explicit
|
|
121
|
+
* `redirectUri`, when you build the authorize URL in the browser rather than
|
|
122
|
+
* letting `signIn()` bounce through your own `/signin` route - and handing the
|
|
123
|
+
* browser a value it does not need is surface for nothing. The second is where
|
|
124
|
+
* *you* mounted the handler, which the environment does not know. Pass either
|
|
125
|
+
* yourself; a prop you pass always wins over this spread, whichever order you
|
|
126
|
+
* write them in, as long as the spread comes first.
|
|
127
|
+
*
|
|
128
|
+
* ## If you already have an origin helper
|
|
129
|
+
*
|
|
130
|
+
* Use it instead. An app that builds its own links into Connect (a "Manage
|
|
131
|
+
* team" button, a pricing page's subscribe target) needs that origin on the
|
|
132
|
+
* server too, and one function owning the read is better than this helper
|
|
133
|
+
* beside it - two readers of one variable is the shape that caused this. This
|
|
134
|
+
* exists for the integration that has no such helper, which is most of them.
|
|
135
|
+
*/
|
|
136
|
+
export function connectProviderProps() {
|
|
137
|
+
return { appUrl: (process.env.CONNECT_APP_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "") };
|
|
138
|
+
}
|
|
85
139
|
/**
|
|
86
140
|
* An httpOnly cookie holding the opaque `sessionToken`.
|
|
87
141
|
*
|