@cosmoops/connect-sdk 0.33.0 → 0.34.1
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 +159 -0
- package/README.md +17 -5
- package/dist/cjs/client/components/access-gate.js +98 -2
- package/dist/cjs/client/components/subscription.js +6 -0
- package/dist/cjs/client/provider.js +5 -2
- package/dist/cjs/client/use-checkout.js +3 -1
- package/dist/cjs/helpers.js +46 -0
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/next/index.js +10 -3
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +162 -28
- package/dist/cjs/server/resources/billing.js +5 -0
- package/dist/cjs/server/resources/entitlements.js +47 -0
- package/dist/esm/client/components/access-gate.js +99 -3
- package/dist/esm/client/components/subscription.js +6 -0
- package/dist/esm/client/provider.js +5 -2
- package/dist/esm/client/use-checkout.js +3 -1
- package/dist/esm/helpers.js +43 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/next/index.js +10 -3
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +162 -28
- package/dist/esm/server/resources/billing.js +5 -0
- package/dist/esm/server/resources/entitlements.js +47 -0
- package/dist/types/client/components/access-gate.d.ts +9 -0
- package/dist/types/client/components/subscription.d.ts +6 -0
- package/dist/types/client/provider.d.ts +155 -51
- package/dist/types/client/use-checkout.d.ts +18 -1
- package/dist/types/helpers.d.ts +36 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/billing.d.ts +4 -0
- package/dist/types/server/resources/entitlements.d.ts +40 -1
- package/dist/types/types.d.ts +164 -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
|
},
|
|
@@ -17,6 +17,10 @@ class BillingResource {
|
|
|
17
17
|
* independently of the instance it talks to. Without the coercion a partner
|
|
18
18
|
* pointed at a Connect that has not upgraded yet renders a feature list of
|
|
19
19
|
* blanks - which is exactly what happened. See `normalizePlanFeatures`.
|
|
20
|
+
*
|
|
21
|
+
* `limits` is defaulted for the same reason, one version later: an instance
|
|
22
|
+
* that predates limits sends no such field, and a pricing grid mapping over
|
|
23
|
+
* `plan.limits` would throw rather than simply show no limit rows.
|
|
20
24
|
*/
|
|
21
25
|
async listPlans() {
|
|
22
26
|
const response = await (0, http_js_1.request)(this.transport, { method: "GET", path: "/plans" });
|
|
@@ -24,6 +28,7 @@ class BillingResource {
|
|
|
24
28
|
plans: (response.plans ?? []).map((plan) => ({
|
|
25
29
|
...plan,
|
|
26
30
|
features: (0, helpers_js_1.normalizePlanFeatures)(plan.features),
|
|
31
|
+
limits: plan.limits ?? [],
|
|
27
32
|
})),
|
|
28
33
|
};
|
|
29
34
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.EntitlementsResource = void 0;
|
|
4
4
|
const http_js_1 = require("../http.js");
|
|
5
|
+
const helpers_js_1 = require("../../helpers.js");
|
|
5
6
|
/**
|
|
6
7
|
* The two checks worth making on every request: may this workspace be here at
|
|
7
8
|
* all, and what is it paying for.
|
|
@@ -45,6 +46,52 @@ class EntitlementsResource {
|
|
|
45
46
|
const result = await this.getSubscription(workspaceId);
|
|
46
47
|
return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* How much of a metered resource this workspace may have - the numeric
|
|
51
|
+
* counterpart of `hasFeature`, for the resources your application counts
|
|
52
|
+
* itself (sites, projects, pipelines).
|
|
53
|
+
*
|
|
54
|
+
* **This is the effective ceiling, not the plan's own number.** Connect has
|
|
55
|
+
* already folded in whatever the add-ons on the subscription raise it by, so
|
|
56
|
+
* "Starter includes 1 site, they bought two 5-site packs" arrives here as
|
|
57
|
+
* `{ model: "limited", value: 11 }`. You never join the plan against the
|
|
58
|
+
* add-on list yourself, and a customer who buys another pack needs no change
|
|
59
|
+
* on your side.
|
|
60
|
+
*
|
|
61
|
+
* Returns `{ model: "none" }` rather than throwing when there is no active
|
|
62
|
+
* subscription, when the plan carries no answer for this key, and when the
|
|
63
|
+
* Connect instance predates limits entirely - the same reasoning
|
|
64
|
+
* `hasFeature` gives for answering `false`: "not entitled" is the same answer
|
|
65
|
+
* either way, and a limit check should not need a try/catch around it.
|
|
66
|
+
*
|
|
67
|
+
* Connect does not enforce this. It cannot see what you are counting, so it
|
|
68
|
+
* hands you the number and your code refuses the next row - `canAdd` is that
|
|
69
|
+
* comparison if you already have the count.
|
|
70
|
+
*/
|
|
71
|
+
async getLimit(workspaceId, key) {
|
|
72
|
+
const result = await this.getSubscription(workspaceId);
|
|
73
|
+
if (!result.hasActiveSubscription)
|
|
74
|
+
return helpers_js_1.NO_PLAN_LIMIT;
|
|
75
|
+
return (0, helpers_js_1.planLimitFor)(result.plan?.limits, key);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether this workspace may add `additional` more of a limited resource,
|
|
79
|
+
* given how many it already has.
|
|
80
|
+
*
|
|
81
|
+
* You supply the count, because only your application can produce it. This
|
|
82
|
+
* is `getLimit` plus `planLimitAllows`, which exists so the comparison is
|
|
83
|
+
* not rewritten per call site - see that helper for the three ways of
|
|
84
|
+
* getting it wrong.
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* if (!(await connect.entitlements.canAdd(workspaceId, "sites", sites.length))) {
|
|
88
|
+
* return refuse("Your plan's site limit is reached.");
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
async canAdd(workspaceId, key, currentCount, additional = 1) {
|
|
93
|
+
return (0, helpers_js_1.planLimitAllows)(await this.getLimit(workspaceId, key), currentCount, additional);
|
|
94
|
+
}
|
|
48
95
|
/**
|
|
49
96
|
* One page of the workspaces subscribed to your application, newest first.
|
|
50
97
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
4
|
import { Ban, AlertTriangle, Building2, Clock, CreditCard, ExternalLink, User, X } from "lucide-react";
|
|
5
5
|
import { Button, Card } from "@heroui/react";
|
|
6
6
|
import { blockingBlockers } from "../../helpers.js";
|
|
@@ -36,7 +36,19 @@ function formatDate(iso) {
|
|
|
36
36
|
*/
|
|
37
37
|
export function useConnectAccessGate() {
|
|
38
38
|
const { session, isLoading: sessionLoading } = useConnect();
|
|
39
|
-
const { data, isLoading: entitlementsLoading } = useConnectEntitlements();
|
|
39
|
+
const { data, isLoading: entitlementsLoading, refetch } = useConnectEntitlements();
|
|
40
|
+
return { ...decideAccess(session, sessionLoading, data, entitlementsLoading), refresh: refetch };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The priority ordering, with the reads lifted out.
|
|
44
|
+
*
|
|
45
|
+
* Split from the hook so the twelve verdicts stay one `return` each and none
|
|
46
|
+
* of them has to carry `refresh` - a field that is the same function in every
|
|
47
|
+
* branch and says nothing about which branch it is. Pure, like
|
|
48
|
+
* `rbac/policy.ts` and `evaluateSignInRisk`, and for the same reason: a
|
|
49
|
+
* decision spread across its inputs cannot be tested.
|
|
50
|
+
*/
|
|
51
|
+
function decideAccess(session, sessionLoading, data, entitlementsLoading) {
|
|
40
52
|
if (sessionLoading) {
|
|
41
53
|
return { state: "loading", blocker: null, paymentRetry: null };
|
|
42
54
|
}
|
|
@@ -147,6 +159,49 @@ export function useConnectAccessGate() {
|
|
|
147
159
|
*/
|
|
148
160
|
export function ConnectAccessGate({ children, fallback, loading, className }) {
|
|
149
161
|
const gate = useConnectAccessGate();
|
|
162
|
+
const isBlocked = gate.state !== "loading" &&
|
|
163
|
+
gate.state !== "signed_out" &&
|
|
164
|
+
gate.state !== "clear" &&
|
|
165
|
+
gate.state !== "payment_retry";
|
|
166
|
+
/**
|
|
167
|
+
* A blocked gate re-checks when the tab comes back.
|
|
168
|
+
*
|
|
169
|
+
* Every blocker this component draws is resolved **somewhere else** - on
|
|
170
|
+
* Connect, in another tab, by an admin, on a phone - and none of that
|
|
171
|
+
* reaches this tab. The resolve button used to open Connect in a new tab,
|
|
172
|
+
* so the overwhelmingly common path was: subscribe over there, switch back,
|
|
173
|
+
* and find the same "Choose a plan to continue" card, with a reload the
|
|
174
|
+
* only way out. It read as the purchase not having worked.
|
|
175
|
+
*
|
|
176
|
+
* The button is a same-tab navigation now (see `GateCard`), which handles
|
|
177
|
+
* the common case on its own. This covers the rest: a second tab, a
|
|
178
|
+
* colleague with the admin rights, a payment settled on a phone.
|
|
179
|
+
*
|
|
180
|
+
* Only while blocked, deliberately. A `clear` gate re-reading entitlements
|
|
181
|
+
* on every tab focus would put a request on the critical path of
|
|
182
|
+
* alt-tabbing back into an application that is working fine, for every
|
|
183
|
+
* workspace, all day. Nothing is gained by it: the gate has nothing to
|
|
184
|
+
* show, and losing access mid-session is what the server-side check before
|
|
185
|
+
* each action is for.
|
|
186
|
+
*/
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!isBlocked || typeof document === "undefined")
|
|
189
|
+
return;
|
|
190
|
+
const recheck = () => {
|
|
191
|
+
if (document.visibilityState === "visible")
|
|
192
|
+
gate.refresh();
|
|
193
|
+
};
|
|
194
|
+
document.addEventListener("visibilitychange", recheck);
|
|
195
|
+
window.addEventListener("focus", recheck);
|
|
196
|
+
return () => {
|
|
197
|
+
document.removeEventListener("visibilitychange", recheck);
|
|
198
|
+
window.removeEventListener("focus", recheck);
|
|
199
|
+
};
|
|
200
|
+
// `gate.refresh` is stable per render of the provider and re-subscribing
|
|
201
|
+
// on it would churn the listeners; `isBlocked` is the only thing that
|
|
202
|
+
// should add or remove them.
|
|
203
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
204
|
+
}, [isBlocked]);
|
|
150
205
|
// `children`, unless the host explicitly asked for a placeholder. See the
|
|
151
206
|
// doc comment - this is the branch that decides whether a partner's page
|
|
152
207
|
// can stream.
|
|
@@ -215,9 +270,27 @@ function AccessGateFallback({ state, blocker, className, }) {
|
|
|
215
270
|
* so "see plans" landing back on the same blocked screen never gets anyone
|
|
216
271
|
* to a choice they can act on. Sending them to Connect's own subscribe page
|
|
217
272
|
* always resolves.
|
|
273
|
+
*
|
|
274
|
+
* ### Same tab, and it carries the way back
|
|
275
|
+
*
|
|
276
|
+
* It used to `window.open(resolveUrl, "_blank")`. A new tab is the wrong
|
|
277
|
+
* shape for a step you have to finish before you can continue: the customer
|
|
278
|
+
* subscribes over there, is left on Connect's billing page with nothing
|
|
279
|
+
* pointing home, and the tab they started in is still showing this card. The
|
|
280
|
+
* most common ending was two open tabs and the belief that the payment had
|
|
281
|
+
* not worked.
|
|
282
|
+
*
|
|
283
|
+
* So: a same-tab navigation carrying `?next=` back to the page they are
|
|
284
|
+
* standing on, which Connect honours after a subscribe once the origin
|
|
285
|
+
* matches one registered for this application's client
|
|
286
|
+
* (`resolveApplicationReturnUrl`). An unregistered origin costs the return
|
|
287
|
+
* trip and nothing else - they still reach Connect, still resolve the
|
|
288
|
+
* blocker, and `ConnectAccessGate`'s focus re-check catches them on the way
|
|
289
|
+
* back. Partners should register their app's origin; the fallback is a
|
|
290
|
+
* degraded path, not a broken one.
|
|
218
291
|
*/
|
|
219
292
|
function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
|
|
220
|
-
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.
|
|
293
|
+
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.location.assign(withReturnTo(resolveUrl)), children: [actionLabel ?? "Resolve on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
|
|
221
294
|
}
|
|
222
295
|
/**
|
|
223
296
|
* The skippable payment-due warning. Dismissal is scoped to this specific
|
|
@@ -236,3 +309,26 @@ function PaymentRetryBanner({ blocker, className, }) {
|
|
|
236
309
|
window.localStorage.setItem(dismissKey, "1");
|
|
237
310
|
}, children: _jsx(X, { className: "size-4" }) })] })] }) }));
|
|
238
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* `resolveUrl` with `?next=` pointing at the page the customer is standing
|
|
314
|
+
* on, so Connect can hand them back once the blocker is gone.
|
|
315
|
+
*
|
|
316
|
+
* Returns the URL untouched if anything is off - no `window`, an unparseable
|
|
317
|
+
* `resolveUrl`, a `next` already set by the server. Getting back is a
|
|
318
|
+
* courtesy on top of resolving the blocker, and it must never be the reason
|
|
319
|
+
* somebody cannot reach the screen that resolves it.
|
|
320
|
+
*/
|
|
321
|
+
function withReturnTo(resolveUrl) {
|
|
322
|
+
if (typeof window === "undefined")
|
|
323
|
+
return resolveUrl;
|
|
324
|
+
try {
|
|
325
|
+
const url = new URL(resolveUrl);
|
|
326
|
+
if (url.searchParams.has("next"))
|
|
327
|
+
return resolveUrl;
|
|
328
|
+
url.searchParams.set("next", window.location.href);
|
|
329
|
+
return url.toString();
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return resolveUrl;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -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")
|
|
@@ -483,7 +483,7 @@ export function useConnectSessions() {
|
|
|
483
483
|
*/
|
|
484
484
|
export function useConnectResource(path, options) {
|
|
485
485
|
const requiresSession = options?.requiresSession ?? true;
|
|
486
|
-
const { fetchResource, session, isLoading: sessionLoading } = useConnect();
|
|
486
|
+
const { fetchResource, invalidateResource, session, isLoading: sessionLoading } = useConnect();
|
|
487
487
|
// Stamped with the `path` it belongs to, so switching paths reports loading
|
|
488
488
|
// without a synchronous `setIsLoading(true)` - and, more usefully, so a
|
|
489
489
|
// resolved response for a previous path can never be read as this one's.
|
|
@@ -523,6 +523,9 @@ export function useConnectResource(path, options) {
|
|
|
523
523
|
// should render its signed-out state, not skeletons forever.
|
|
524
524
|
isLoading: !signedOut && !loaded,
|
|
525
525
|
error: loaded?.error ?? null,
|
|
526
|
-
refetch: () =>
|
|
526
|
+
refetch: () => {
|
|
527
|
+
invalidateResource(path);
|
|
528
|
+
setVersion((v) => v + 1);
|
|
529
|
+
},
|
|
527
530
|
};
|
|
528
531
|
}
|
|
@@ -161,12 +161,14 @@ export function buildCancelUrl({ appUrl, subscriptionId }) {
|
|
|
161
161
|
* prefix that has never served this page. Building the other one is what
|
|
162
162
|
* sent every first-time subscriber from a partner's pricing page to a 404.
|
|
163
163
|
*/
|
|
164
|
-
export function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, }) {
|
|
164
|
+
export function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, next, }) {
|
|
165
165
|
const path = subscriptionId
|
|
166
166
|
? `/billing/subscriptions/${encodeURIComponent(subscriptionId)}/change-plan/${encodeURIComponent(planId)}`
|
|
167
167
|
: `/apps/${encodeURIComponent(applicationId)}/subscribe/${encodeURIComponent(planId)}`;
|
|
168
168
|
const url = new URL(path, appUrl);
|
|
169
169
|
if (coupon?.trim())
|
|
170
170
|
url.searchParams.set("coupon", coupon.trim());
|
|
171
|
+
if (next?.trim())
|
|
172
|
+
url.searchParams.set("next", next.trim());
|
|
171
173
|
return url.toString();
|
|
172
174
|
}
|
package/dist/esm/helpers.js
CHANGED
|
@@ -24,3 +24,46 @@ export function blockingBlockers(readiness) {
|
|
|
24
24
|
export function normalizePlanFeatures(features) {
|
|
25
25
|
return (features ?? []).map((feature) => typeof feature === "string" ? { key: feature, name: feature, description: "" } : feature);
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* What a key the plan carries no answer for reads as - see
|
|
29
|
+
* `SubscribedPlan.limits`, which is sparse.
|
|
30
|
+
*
|
|
31
|
+
* `"none"` rather than `"unlimited"`: a plan that was never asked about a
|
|
32
|
+
* limit has not sold it, and defaulting the other way would hand an unbounded
|
|
33
|
+
* allowance to every tier that predates the question.
|
|
34
|
+
*/
|
|
35
|
+
export const NO_PLAN_LIMIT = { model: "none", value: null };
|
|
36
|
+
/**
|
|
37
|
+
* One plan's answer for one limit key, with the sparse-map default applied.
|
|
38
|
+
*
|
|
39
|
+
* Indexing `plan.limits` directly is what makes a missing key `undefined`,
|
|
40
|
+
* which then reads as unlimited through the first `?.` somebody writes. This
|
|
41
|
+
* returns a `PlanLimit` either way.
|
|
42
|
+
*/
|
|
43
|
+
export function planLimitFor(limits, key) {
|
|
44
|
+
return limits?.[key] ?? NO_PLAN_LIMIT;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether `additional` more of the resource fit under this limit, given how
|
|
48
|
+
* many the subscriber already has. The comparison, written once.
|
|
49
|
+
*
|
|
50
|
+
* Every way of writing it at a call site is wrong in a different direction:
|
|
51
|
+
* `count < limit.value` is off by one as soon as you add more than one row,
|
|
52
|
+
* `if (!limit.value)` refuses the unlimited tier, and
|
|
53
|
+
* `limit.value ?? Infinity` grants unlimited to a plan that allows none. This
|
|
54
|
+
* is the same implementation Connect's own server uses to validate a plan.
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* const limit = await connect.entitlements.getLimit(workspaceId, "sites");
|
|
58
|
+
* if (!planLimitAllows(limit, await countSites(workspaceId))) {
|
|
59
|
+
* throw new Error("Your plan's site limit is reached.");
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function planLimitAllows(limit, currentCount, additional = 1) {
|
|
64
|
+
if (limit.model === "unlimited")
|
|
65
|
+
return true;
|
|
66
|
+
if (limit.model === "none")
|
|
67
|
+
return false;
|
|
68
|
+
return currentCount + additional <= (limit.value ?? 0);
|
|
69
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -15,5 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export { ConnectError, isConnectError } from "./errors.js";
|
|
17
17
|
export { blockingBlockers, normalizePlanFeatures } from "./helpers.js";
|
|
18
|
+
export { NO_PLAN_LIMIT, planLimitAllows, planLimitFor } from "./helpers.js";
|
|
18
19
|
export { arrayRemove, arrayUnion, deleteField, documentExists, increment, serverTimestamp, storeIndexFor, storeIndexSignature, unchangedSince, } from "./store.js";
|
|
19
20
|
export { describeQueryRejection, storeIndexCovers } from "./store-schema.js";
|
package/dist/esm/next/index.js
CHANGED
|
@@ -35,11 +35,18 @@ import { ConnectError } from "../errors.js";
|
|
|
35
35
|
* | --- | --- | --- |
|
|
36
36
|
* | `CONNECT_API_KEY` | yes | server-side credential |
|
|
37
37
|
* | `CONNECT_CLIENT_ID` | yes | OAuth client id |
|
|
38
|
-
* | `CONNECT_APP_URL` | no | non-production Connect instance |
|
|
39
|
-
* | `CONNECT_SESSION_COOKIE` | no | session cookie name |
|
|
38
|
+
* | `CONNECT_APP_URL` | no | non-production Connect instance; unset means production |
|
|
39
|
+
* | `CONNECT_SESSION_COOKIE` | no | session cookie name; unset means `connect_session` |
|
|
40
|
+
*
|
|
41
|
+
* `CONNECT_APP_URL` is server-side only and does not reach the browser:
|
|
42
|
+
* `ConnectProvider` defaults `appUrl` to the production origin independently,
|
|
43
|
+
* so an app pointed at another instance must pass that prop as well or its
|
|
44
|
+
* portal links and its browser-built authorize URL still go to production.
|
|
40
45
|
*
|
|
41
46
|
* Register `{your origin}/api/connect/callback` as a redirect URI in the
|
|
42
|
-
* application's Connect client section. That is the only step this cannot do
|
|
47
|
+
* application's Connect client section. That is the only step this cannot do
|
|
48
|
+
* for you, and Connect matches it exactly - every origin you run from needs
|
|
49
|
+
* its own entry, localhost included.
|
|
43
50
|
*
|
|
44
51
|
* No `next.config.ts` changes needed either, including `transpilePackages`:
|
|
45
52
|
* `dist/` is already ES2022 with `"use client"` hoisted to the top of every
|