@cosmoops/connect-sdk 0.20.0 → 0.23.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 +281 -0
- package/README.md +2 -0
- package/dist/cjs/client/components/identity.js +24 -0
- package/dist/cjs/client/components/one-tap-state.js +96 -0
- package/dist/cjs/client/components/one-tap.js +106 -0
- package/dist/cjs/client/components/plan-pricing.js +28 -0
- package/dist/cjs/client/components/plans.js +8 -3
- package/dist/cjs/client/components/user-menu.js +2 -6
- package/dist/cjs/client/index.js +11 -3
- package/dist/cjs/client/provider.js +102 -7
- package/dist/cjs/server/client.js +12 -1
- package/dist/cjs/server/handler.js +126 -2
- package/dist/cjs/server/resources/calls.js +57 -0
- package/dist/cjs/server/resources/channels.js +115 -0
- package/dist/cjs/server/resources/enrichment.js +56 -0
- package/dist/cjs/server/resources/identity.js +40 -0
- package/dist/cjs/server/resources/workspaces.js +73 -2
- package/dist/esm/client/components/identity.js +21 -0
- package/dist/esm/client/components/one-tap-state.js +90 -0
- package/dist/esm/client/components/one-tap.js +103 -0
- package/dist/esm/client/components/plan-pricing.js +27 -0
- package/dist/esm/client/components/plans.js +9 -4
- package/dist/esm/client/components/user-menu.js +1 -5
- package/dist/esm/client/index.js +8 -1
- package/dist/esm/client/provider.js +102 -7
- package/dist/esm/server/client.js +12 -1
- package/dist/esm/server/handler.js +127 -3
- package/dist/esm/server/resources/calls.js +53 -0
- package/dist/esm/server/resources/channels.js +111 -0
- package/dist/esm/server/resources/enrichment.js +52 -0
- package/dist/esm/server/resources/identity.js +39 -0
- package/dist/esm/server/resources/workspaces.js +73 -2
- package/dist/types/client/components/identity.d.ts +17 -0
- package/dist/types/client/components/one-tap-state.d.ts +39 -0
- package/dist/types/client/components/one-tap.d.ts +50 -0
- package/dist/types/client/components/plan-pricing.d.ts +23 -0
- package/dist/types/client/index.d.ts +4 -2
- package/dist/types/client/provider.d.ts +101 -3
- package/dist/types/client/use-checkout.d.ts +9 -0
- package/dist/types/server/client.d.ts +9 -1
- package/dist/types/server/index.d.ts +3 -0
- package/dist/types/server/resources/calls.d.ts +45 -0
- package/dist/types/server/resources/channels.d.ts +91 -0
- package/dist/types/server/resources/enrichment.d.ts +44 -0
- package/dist/types/server/resources/identity.d.ts +35 -0
- package/dist/types/server/resources/workspaces.d.ts +63 -3
- package/dist/types/types.d.ts +344 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.IdentityResource = void 0;
|
|
4
4
|
exports.createState = createState;
|
|
5
|
+
exports.createPkcePair = createPkcePair;
|
|
5
6
|
const errors_js_1 = require("../../errors.js");
|
|
6
7
|
const http_js_1 = require("../http.js");
|
|
7
8
|
/** A URL-safe random string, suitable for `state` or a PKCE verifier. */
|
|
@@ -16,6 +17,23 @@ function base64UrlEncode(bytes) {
|
|
|
16
17
|
binary += String.fromCharCode(byte);
|
|
17
18
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Mints a PKCE pair for the One Tap flow.
|
|
22
|
+
*
|
|
23
|
+
* The redirect flow can take PKCE or leave it, because the code is delivered
|
|
24
|
+
* to the application's server on a redirect the browser only forwards. One Tap
|
|
25
|
+
* hands the code *to the page*, so the verifier is the thing that makes a
|
|
26
|
+
* stolen code worthless - it stays in an `HttpOnly` cookie on the application's
|
|
27
|
+
* own origin and is never sent to the browser.
|
|
28
|
+
*
|
|
29
|
+
* `crypto.subtle` rather than a Node hash, so this stays isomorphic and the
|
|
30
|
+
* `./next` entry keeps loading on the edge runtime as well as Node.
|
|
31
|
+
*/
|
|
32
|
+
async function createPkcePair() {
|
|
33
|
+
const verifier = createState(32);
|
|
34
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
35
|
+
return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) };
|
|
36
|
+
}
|
|
19
37
|
function base64UrlDecode(value) {
|
|
20
38
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
21
39
|
const binary = atob(padded);
|
|
@@ -54,6 +72,28 @@ class IdentityResource {
|
|
|
54
72
|
}
|
|
55
73
|
return url.toString();
|
|
56
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The two Connect endpoints One Tap talks to **from the browser**, or `null`
|
|
77
|
+
* when this client has no `clientId` configured and the flow cannot run.
|
|
78
|
+
*
|
|
79
|
+
* Handed out by the bridge rather than built in the page, so One Tap needs no
|
|
80
|
+
* client-side configuration at all - a `./next` application sets
|
|
81
|
+
* `CONNECT_CLIENT_ID` server-side and nothing else. Both URLs carry only the
|
|
82
|
+
* public `client_id`; neither is a credential.
|
|
83
|
+
*
|
|
84
|
+
* Returning `null` rather than throwing (as `authorizeUrl` does) because this
|
|
85
|
+
* is read on the `/session` route, which must keep answering for an
|
|
86
|
+
* application that has no OAuth client configured at all.
|
|
87
|
+
*/
|
|
88
|
+
oneTapEndpoints() {
|
|
89
|
+
if (!this.clientId)
|
|
90
|
+
return null;
|
|
91
|
+
const hint = new URL("/api/sso/session-hint", this.appBaseUrl);
|
|
92
|
+
hint.searchParams.set("client_id", this.clientId);
|
|
93
|
+
const authorize = new URL("/api/sso/authorize/one-tap", this.appBaseUrl);
|
|
94
|
+
authorize.searchParams.set("client_id", this.clientId);
|
|
95
|
+
return { hintUrl: hint.toString(), authorizeUrl: authorize.toString() };
|
|
96
|
+
}
|
|
57
97
|
/** Step 2 - trade the one-time `code` for a session. Server-side only. */
|
|
58
98
|
async exchangeCode(input) {
|
|
59
99
|
const wire = await this.post("/sso/token", {
|
|
@@ -4,8 +4,8 @@ exports.WorkspacesResource = void 0;
|
|
|
4
4
|
const acting_as_js_1 = require("./acting-as.js");
|
|
5
5
|
const http_js_1 = require("../http.js");
|
|
6
6
|
/**
|
|
7
|
-
* Which workspaces the signed-in user belongs to, moving between them,
|
|
8
|
-
* else is on the team.
|
|
7
|
+
* Which workspaces the signed-in user belongs to, moving between them, who
|
|
8
|
+
* else is on the team, and inviting or granting access to your application.
|
|
9
9
|
*
|
|
10
10
|
* **Every call here needs `asUser`** - the signed-in user's opaque
|
|
11
11
|
* `sessionToken`. There is no application-wide form of any of these: they take
|
|
@@ -70,5 +70,76 @@ class WorkspacesResource {
|
|
|
70
70
|
});
|
|
71
71
|
return result.members;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Seats, what the caller may do with them, and the invitations already
|
|
75
|
+
* holding one - everything a team screen needs to decide what to draw.
|
|
76
|
+
*
|
|
77
|
+
* Behind `members:manage` rather than `workspaces:read`, and gated on the
|
|
78
|
+
* caller's own `member:invite`: it names people who are not members yet.
|
|
79
|
+
*/
|
|
80
|
+
access(options) {
|
|
81
|
+
return (0, http_js_1.request)(this.transport, {
|
|
82
|
+
method: "GET",
|
|
83
|
+
path: "/workspaces/members/access",
|
|
84
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Invite someone into the workspace, with access to your application.
|
|
89
|
+
*
|
|
90
|
+
* `roleKey` names a role from your own catalog (or the reserved `"admin"`);
|
|
91
|
+
* omit it to add someone to the workspace with no access to your
|
|
92
|
+
* application. You cannot send a Connect role - the grant is built server
|
|
93
|
+
* side as `[member, app:<yourKey>:<roleKey>]`, so nothing this method can
|
|
94
|
+
* express reaches outside your application.
|
|
95
|
+
*
|
|
96
|
+
* Spends a member seat when the invitation is created, not when it is
|
|
97
|
+
* accepted. Throws `ConnectError` with status 409 and a message written to
|
|
98
|
+
* be shown when there is no seat, and 403 when the caller is inviting
|
|
99
|
+
* someone with access they do not hold themselves.
|
|
100
|
+
*/
|
|
101
|
+
invite(input, options) {
|
|
102
|
+
return (0, http_js_1.request)(this.transport, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
path: "/workspaces/members/invite",
|
|
105
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
106
|
+
json: input,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Grant or revoke **your application's** role for an existing member.
|
|
111
|
+
* `roleKey: null` revokes it.
|
|
112
|
+
*
|
|
113
|
+
* Moves only your own grant: Connect recomputes the member's roles as what
|
|
114
|
+
* they hold now, minus any grant naming your application, plus the one you
|
|
115
|
+
* asked for. Their workspace role and every other application they use are
|
|
116
|
+
* untouched, and there is no form of this that removes them from the
|
|
117
|
+
* workspace.
|
|
118
|
+
*
|
|
119
|
+
* Re-sending the role they already have is a no-op rather than an error, so
|
|
120
|
+
* a form that submits whatever is on screen is safe.
|
|
121
|
+
*/
|
|
122
|
+
setAccess(input, options) {
|
|
123
|
+
return (0, http_js_1.request)(this.transport, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
path: "/workspaces/members/access",
|
|
126
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
127
|
+
json: input,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Withdraw a pending invitation your application sent, freeing its seat.
|
|
132
|
+
*
|
|
133
|
+
* Only yours - an invitation carrying another application's grant is a 404,
|
|
134
|
+
* deliberately indistinguishable from one that does not exist.
|
|
135
|
+
*/
|
|
136
|
+
async revokeInvitation(input, options) {
|
|
137
|
+
await (0, http_js_1.request)(this.transport, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
path: "/workspaces/invitations/revoke",
|
|
140
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
141
|
+
json: input,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
73
144
|
}
|
|
74
145
|
exports.WorkspacesResource = WorkspacesResource;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a person is rendered when there is no photo to render.
|
|
3
|
+
*
|
|
4
|
+
* Shared rather than copied because two surfaces showing *different* initials
|
|
5
|
+
* for the same account reads as a bug in the account, not in the component -
|
|
6
|
+
* and the One Tap prompt and the user menu are frequently on screen within
|
|
7
|
+
* seconds of each other.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Up to two letters standing for a person.
|
|
11
|
+
*
|
|
12
|
+
* Splits on the punctuation an email address uses as well as whitespace, so
|
|
13
|
+
* `priya.nair@acme.test` yields `PN` rather than `PR` - an account with no
|
|
14
|
+
* display name is exactly the case the fallback exists for, and taking the
|
|
15
|
+
* first two characters of a local part would make it useless there.
|
|
16
|
+
*/
|
|
17
|
+
export function initials(name, email = null) {
|
|
18
|
+
const source = name?.trim() || email?.trim() || "";
|
|
19
|
+
const parts = source.split(/[\s@._-]+/).filter(Boolean);
|
|
20
|
+
return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
|
|
21
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long One Tap stays quiet, and why.
|
|
3
|
+
*
|
|
4
|
+
* A prompt that reappears on every page load after being closed is not a
|
|
5
|
+
* convenience, it is an interstitial. Two things silence it, both stored in the
|
|
6
|
+
* application's own `localStorage` - nothing here leaves the browser, and
|
|
7
|
+
* nothing here is a security control:
|
|
8
|
+
*
|
|
9
|
+
* - **Dismissal, escalating.** Closing it once is "not now"; closing it three
|
|
10
|
+
* times is "stop asking". The backoff says that back.
|
|
11
|
+
* - **Signing out.** Someone who has just signed out of this application does
|
|
12
|
+
* not want to be offered the same account back on the next render. That is a
|
|
13
|
+
* different fact from a dismissal, so it does not consume a dismissal, but it
|
|
14
|
+
* buys a day of quiet.
|
|
15
|
+
*
|
|
16
|
+
* Every access is wrapped: `localStorage` throws outright in some embedded and
|
|
17
|
+
* privacy-restricted contexts, and a prompt is never worth breaking a page for.
|
|
18
|
+
* A read that fails reads as "not suppressed", which is the same state a fresh
|
|
19
|
+
* browser is in.
|
|
20
|
+
*/
|
|
21
|
+
const STORAGE_KEY = "connect_one_tap";
|
|
22
|
+
/** Escalating quiet after 1, 2, and 3-or-more dismissals. */
|
|
23
|
+
const DISMISS_BACKOFF_MS = [2 * 60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000];
|
|
24
|
+
const SIGN_OUT_QUIET_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
function read() {
|
|
26
|
+
const empty = { dismissals: 0, until: 0 };
|
|
27
|
+
if (typeof window === "undefined")
|
|
28
|
+
return empty;
|
|
29
|
+
try {
|
|
30
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
31
|
+
if (!raw)
|
|
32
|
+
return empty;
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
return {
|
|
35
|
+
dismissals: typeof parsed.dismissals === "number" ? parsed.dismissals : 0,
|
|
36
|
+
until: typeof parsed.until === "number" ? parsed.until : 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return empty;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function write(state) {
|
|
44
|
+
if (typeof window === "undefined")
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Storage is full, blocked, or unavailable. The prompt simply keeps its
|
|
51
|
+
// default behaviour rather than the page failing.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether the prompt may be shown at all.
|
|
56
|
+
*
|
|
57
|
+
* Read once when the component mounts, never during render: `localStorage` is
|
|
58
|
+
* external mutable state, and reading it in a render body is impure - with the
|
|
59
|
+
* React Compiler on, exactly the kind of thing that can be hoisted into the
|
|
60
|
+
* wrong answer. Same reasoning as `ConnectProvider`'s `silentAttemptSpent`.
|
|
61
|
+
*/
|
|
62
|
+
export function oneTapSuppressed() {
|
|
63
|
+
return Date.now() < read().until;
|
|
64
|
+
}
|
|
65
|
+
/** Records a close, and escalates how long the next quiet period lasts. */
|
|
66
|
+
export function dismissOneTap() {
|
|
67
|
+
const { dismissals } = read();
|
|
68
|
+
const next = dismissals + 1;
|
|
69
|
+
const backoff = DISMISS_BACKOFF_MS[Math.min(next, DISMISS_BACKOFF_MS.length) - 1];
|
|
70
|
+
write({ dismissals: next, until: Date.now() + backoff });
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Silences the prompt after an explicit sign-out, without spending a dismissal.
|
|
74
|
+
* Signing out is not a complaint about the prompt, so it must not push someone
|
|
75
|
+
* closer to the seven-day backoff.
|
|
76
|
+
*/
|
|
77
|
+
export function suppressOneTapAfterSignOut() {
|
|
78
|
+
write({ dismissals: read().dismissals, until: Date.now() + SIGN_OUT_QUIET_MS });
|
|
79
|
+
}
|
|
80
|
+
/** Clears the record once a sign-in actually happens - the prompt did its job. */
|
|
81
|
+
export function clearOneTapSuppression() {
|
|
82
|
+
if (typeof window === "undefined")
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// See `write`.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { Avatar, Button, Card } from "@heroui/react";
|
|
6
|
+
import { useConnect } from "../provider.js";
|
|
7
|
+
import { ConnectMark } from "../mark.js";
|
|
8
|
+
import { initials } from "./identity.js";
|
|
9
|
+
import { ENTER, m, MotionProvider } from "./motion.js";
|
|
10
|
+
import { dismissOneTap, oneTapSuppressed } from "./one-tap-state.js";
|
|
11
|
+
export function ConnectOneTap({ onInteractiveSignIn, className = "" }) {
|
|
12
|
+
const { session, isLoading, signIn, readOneTapHint, oneTapSignIn } = useConnect();
|
|
13
|
+
const [hint, setHint] = useState(null);
|
|
14
|
+
const [phase, setPhase] = useState("hidden");
|
|
15
|
+
/**
|
|
16
|
+
* Sampled once at mount rather than read during render, for the same reason
|
|
17
|
+
* `ConnectProvider` samples its silent-attempt marker that way: `localStorage`
|
|
18
|
+
* is external mutable state and reading it in a render body is impure.
|
|
19
|
+
* `false` on the server, which is correct - nothing renders until the hint
|
|
20
|
+
* has resolved on the client anyway.
|
|
21
|
+
*/
|
|
22
|
+
const [suppressed] = useState(() => typeof window !== "undefined" && oneTapSuppressed());
|
|
23
|
+
const signedOut = !isLoading && !session?.user;
|
|
24
|
+
const askable = signedOut && !suppressed && Boolean(session?.oneTap);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!askable)
|
|
27
|
+
return;
|
|
28
|
+
// Nothing here is a synchronous `setState` in an effect: every write
|
|
29
|
+
// happens after awaiting the network. The flag is what stops a late answer
|
|
30
|
+
// landing on a component that has already unmounted.
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
void readOneTapHint().then((answer) => {
|
|
33
|
+
if (cancelled)
|
|
34
|
+
return;
|
|
35
|
+
// A visitor Connect does not recognise is the common case and gets
|
|
36
|
+
// nothing - no prompt, no flicker, no trace that a question was asked.
|
|
37
|
+
if (!answer || answer.status !== "signed_in")
|
|
38
|
+
return;
|
|
39
|
+
setHint(answer);
|
|
40
|
+
setPhase("offered");
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
cancelled = true;
|
|
44
|
+
};
|
|
45
|
+
}, [askable, readOneTapHint]);
|
|
46
|
+
const handoff = useCallback(() => {
|
|
47
|
+
setPhase("hidden");
|
|
48
|
+
if (onInteractiveSignIn)
|
|
49
|
+
onInteractiveSignIn();
|
|
50
|
+
else
|
|
51
|
+
signIn();
|
|
52
|
+
}, [onInteractiveSignIn, signIn]);
|
|
53
|
+
const accept = useCallback(async () => {
|
|
54
|
+
// A visitor who has never authorised this application cannot be signed in
|
|
55
|
+
// silently and Connect would refuse anyway, so the tap goes straight to the
|
|
56
|
+
// screen that can ask them properly.
|
|
57
|
+
if (!hint?.consented) {
|
|
58
|
+
handoff();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setPhase("working");
|
|
62
|
+
const result = await oneTapSignIn();
|
|
63
|
+
if (result.ok) {
|
|
64
|
+
// The provider has already refreshed the session; this component
|
|
65
|
+
// disappears because `signedOut` is now false.
|
|
66
|
+
setPhase("hidden");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// `consent_required` and `interaction_required` both mean a screen is
|
|
70
|
+
// genuinely needed. `login_required` means the Connect session went away
|
|
71
|
+
// between the hint and the tap. All three are handed to the interactive
|
|
72
|
+
// flow, which can explain itself; a rate limit or a transport failure just
|
|
73
|
+
// closes the prompt rather than blaming the visitor for tapping.
|
|
74
|
+
if (result.reason === "consent_required" ||
|
|
75
|
+
result.reason === "interaction_required" ||
|
|
76
|
+
result.reason === "login_required") {
|
|
77
|
+
handoff();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setPhase("hidden");
|
|
81
|
+
}, [handoff, hint, oneTapSignIn]);
|
|
82
|
+
const dismiss = useCallback(() => {
|
|
83
|
+
dismissOneTap();
|
|
84
|
+
setPhase("hidden");
|
|
85
|
+
}, []);
|
|
86
|
+
if (phase === "hidden" || !hint)
|
|
87
|
+
return null;
|
|
88
|
+
const displayName = hint.user?.displayName ?? hint.user?.email ?? null;
|
|
89
|
+
const applicationName = hint.application?.name;
|
|
90
|
+
const busy = phase === "working";
|
|
91
|
+
return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0, y: 12 }, animate: { opacity: 1, y: 0 }, transition: ENTER, role: "dialog", "aria-label": applicationName ? `Continue to ${applicationName} with CosmoOps` : "Continue with CosmoOps",
|
|
92
|
+
// Fixed, above ordinary content but below a modal: this is an offer,
|
|
93
|
+
// and it must never sit on top of something the visitor opened
|
|
94
|
+
// themselves.
|
|
95
|
+
className: `fixed inset-x-3 bottom-3 z-40 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:w-80 ${className}`, onKeyDown: (event) => {
|
|
96
|
+
if (event.key === "Escape")
|
|
97
|
+
dismiss();
|
|
98
|
+
}, children: _jsxs(Card, { className: "p-4 shadow-lg", children: [_jsxs("div", { className: "flex items-start gap-3", children: [hint.consented ? (_jsxs(Avatar, { size: "md", color: "accent", variant: "soft", className: "shrink-0 rounded-full", children: [hint.user?.photoUrl && (_jsx(Avatar.Image, { alt: displayName ?? "Your account", src: hint.user.photoUrl })), _jsx(Avatar.Fallback, { children: initials(hint.user?.displayName ?? null, hint.user?.email ?? null) })] })) : (_jsx(ConnectMark, { className: "mt-0.5 size-8 shrink-0" })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium text-foreground", children: hint.consented ? `Continue as ${displayName ?? "your account"}` : "You're signed in to CosmoOps" }), _jsx("p", { className: "truncate text-xs text-muted", children: hint.consented
|
|
99
|
+
? [hint.user?.email, hint.workspace?.name].filter(Boolean).join(" · ")
|
|
100
|
+
: applicationName
|
|
101
|
+
? `Continue to ${applicationName}`
|
|
102
|
+
: "Continue with your account" })] }), _jsx(Button, { variant: "tertiary", size: "sm", isIconOnly: true, "aria-label": "Not now", onPress: dismiss, isDisabled: busy, children: _jsx(X, { className: "size-4" }) })] }), _jsxs(Button, { variant: "secondary", className: "mt-3 w-full", onPress: () => void accept(), isPending: busy, children: [_jsx(ConnectMark, { className: "size-4" }), busy ? "Signing you in…" : "Continue"] })] }) }) }));
|
|
103
|
+
}
|
|
@@ -194,3 +194,30 @@ export const ACTION_LABEL = {
|
|
|
194
194
|
"switch-to-monthly": "Switch to monthly",
|
|
195
195
|
subscribe: "Subscribe",
|
|
196
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* The subscription id a checkout hand-off should carry for `action` - or
|
|
199
|
+
* `null`, meaning "send them to Connect's *subscribe* screen".
|
|
200
|
+
*
|
|
201
|
+
* Paired with `resolveAction` deliberately: the button's label and the URL it
|
|
202
|
+
* opens are one decision, and deriving the URL from the mere *presence* of a
|
|
203
|
+
* subscription row instead is what sent a card reading "Subscribe" to
|
|
204
|
+
* Connect's change-plan screen, which answered `notFound()`.
|
|
205
|
+
*
|
|
206
|
+
* The gap is real rather than hypothetical. `resolveAction` needs the
|
|
207
|
+
* **current plan** to classify a change and answers `"subscribe"` without
|
|
208
|
+
* one - and `SubscriptionResponse` carries `plan: null` beside a perfectly
|
|
209
|
+
* live `subscription` whenever that plan has been deleted from the catalogue
|
|
210
|
+
* out from under it, which a re-seeded application does. Connect's change-plan
|
|
211
|
+
* route is stuck in the same place for the same reason: it cannot prorate
|
|
212
|
+
* against a price it cannot read.
|
|
213
|
+
*
|
|
214
|
+
* Sending those to `/apps/.../subscribe/...` is a real hand-off, not a guess:
|
|
215
|
+
* that route re-reads the workspace's live subscriptions server-side and
|
|
216
|
+
* redirects to the change-plan screen itself when there genuinely is one to
|
|
217
|
+
* change. Connect decides, so the grid does not have to be sure.
|
|
218
|
+
*/
|
|
219
|
+
export function checkoutSubscriptionId(action, current) {
|
|
220
|
+
if (action === "subscribe")
|
|
221
|
+
return null;
|
|
222
|
+
return current?.subscription?.id ?? null;
|
|
223
|
+
}
|
|
@@ -7,7 +7,7 @@ import { useConnect, useConnectEntitlements, useConnectPlans } from "../provider
|
|
|
7
7
|
import { buildCancelUrl, buildCheckoutUrl, useConnectCheckout, } from "../use-checkout.js";
|
|
8
8
|
import { AnimatePresence, ENTER, m, MotionProvider } from "./motion.js";
|
|
9
9
|
import { ConnectSubscribeDialog } from "./subscribe-dialog.js";
|
|
10
|
-
import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, intervalLabel, isFreePlan, pricePerLabel, resolveAction, } from "./plan-pricing.js";
|
|
10
|
+
import { ACTION_LABEL, checkoutSubscriptionId, discountLabel, formatPrice, groupPlansByTier, intervalLabel, isFreePlan, pricePerLabel, resolveAction, } from "./plan-pricing.js";
|
|
11
11
|
/**
|
|
12
12
|
* The plan table: monthly/annual toggle, features, add-ons, the discount
|
|
13
13
|
* actually in effect, and the subscribe / upgrade / downgrade flows.
|
|
@@ -80,8 +80,13 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
80
80
|
* created in `"created"` and only flips to active when the payment webhook
|
|
81
81
|
* lands, which can be minutes later. Holding a customer on a spinner until
|
|
82
82
|
* then would misreport a completed checkout as an abandoned one.
|
|
83
|
+
*
|
|
84
|
+
* `action` is threaded through rather than re-derived: it is the same
|
|
85
|
+
* decision the card's label came from, and `checkoutSubscriptionId` is what
|
|
86
|
+
* keeps the two from disagreeing - see its doc comment for the 404 that
|
|
87
|
+
* disagreement produced.
|
|
83
88
|
*/
|
|
84
|
-
function startCheckout(plan) {
|
|
89
|
+
function startCheckout(plan, action) {
|
|
85
90
|
if (!applicationId)
|
|
86
91
|
return;
|
|
87
92
|
checkout.start({
|
|
@@ -89,7 +94,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
89
94
|
appUrl,
|
|
90
95
|
applicationId,
|
|
91
96
|
planId: plan.id,
|
|
92
|
-
subscriptionId: subscription
|
|
97
|
+
subscriptionId: checkoutSubscriptionId(action, subscription),
|
|
93
98
|
coupon,
|
|
94
99
|
}),
|
|
95
100
|
isSettled: (next) => next.subscription.plan?.id === plan.id,
|
|
@@ -139,7 +144,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
139
144
|
// plan would race the first and one of them would be
|
|
140
145
|
// rejected on arrival.
|
|
141
146
|
isDisabled: isCurrent || checkout.status === "waiting", onPress: () => setPending({ plan, action }), children: [ACTION_LABEL[action], !isCurrent && _jsx(ExternalLink, { className: "size-3.5" })] }) })] }) }, tier.planGroup));
|
|
142
|
-
}) }), subscription?.subscription && (_jsx(ConnectUnsubscribeRow, { subscription: subscription.subscription, appUrl: appUrl, checkout: checkout })), _jsx(ConnectSubscribeDialog, { plan: pending?.plan ?? null, action: pending?.action ?? "subscribe", current: subscription, coupon: hideCoupon ? undefined : coupon, onClose: () => setPending(null), onConfirm: () => pending && startCheckout(pending.plan) })] }) }));
|
|
147
|
+
}) }), subscription?.subscription && (_jsx(ConnectUnsubscribeRow, { subscription: subscription.subscription, appUrl: appUrl, checkout: checkout })), _jsx(ConnectSubscribeDialog, { plan: pending?.plan ?? null, action: pending?.action ?? "subscribe", current: subscription, coupon: hideCoupon ? undefined : coupon, onClose: () => setPending(null), onConfirm: () => pending && startCheckout(pending.plan, pending.action) })] }) }));
|
|
143
148
|
}
|
|
144
149
|
/**
|
|
145
150
|
* What the page says while the customer is off in Connect, and after.
|
|
@@ -4,6 +4,7 @@ import { Avatar, Button, Chip, Modal, Skeleton, ToggleButton, ToggleButtonGroup,
|
|
|
4
4
|
import { ChevronsUpDown, CreditCard, ExternalLink, Laptop, LogOut, Moon, ReceiptText, Sun, UserRound, } from "lucide-react";
|
|
5
5
|
import { useConnect } from "../provider.js";
|
|
6
6
|
import { ConnectMark } from "../mark.js";
|
|
7
|
+
import { initials } from "./identity.js";
|
|
7
8
|
import { ConnectAccountSessions } from "./account-sessions.js";
|
|
8
9
|
import { m } from "./motion.js";
|
|
9
10
|
import { SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
|
|
@@ -13,11 +14,6 @@ const THEME_OPTIONS = [
|
|
|
13
14
|
{ value: "dark", label: "Dark", icon: Moon },
|
|
14
15
|
{ value: "system", label: "System", icon: Laptop },
|
|
15
16
|
];
|
|
16
|
-
function initials(name, email) {
|
|
17
|
-
const source = name?.trim() || email?.trim() || "";
|
|
18
|
-
const parts = source.split(/[\s@._-]+/).filter(Boolean);
|
|
19
|
-
return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
|
|
20
|
-
}
|
|
21
17
|
function openExternal(url) {
|
|
22
18
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
23
19
|
}
|
package/dist/esm/client/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export { ConnectGrievanceNotice } from "./components/grievance-notice.js";
|
|
|
21
21
|
export { ConnectMark } from "./mark.js";
|
|
22
22
|
export { ConnectLogo } from "./components/logo.js";
|
|
23
23
|
export { ConnectLoginButton, ConnectRegisterButton } from "./components/auth-buttons.js";
|
|
24
|
+
export { ConnectOneTap } from "./components/one-tap.js";
|
|
24
25
|
export { ConnectUserButton, ConnectProfileModal,
|
|
25
26
|
/** @deprecated Renamed to `ConnectUserButton`. */
|
|
26
27
|
ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton, ConnectProfileButton, ConnectHomeButton, ConnectSignOutButton, } from "./components/user-menu.js";
|
|
@@ -104,7 +105,13 @@ export { ConnectConfirmDialog } from "./components/confirm-dialog.js";
|
|
|
104
105
|
export { ConnectCursorPagination } from "./components/cursor-pagination.js";
|
|
105
106
|
export { ConnectFilterTabs } from "./components/filter-tabs.js";
|
|
106
107
|
export { useConnectCheckout, buildCheckoutUrl } from "./use-checkout.js";
|
|
107
|
-
export { formatPrice, monthlyOf, resolveAction,
|
|
108
|
+
export { formatPrice, monthlyOf, resolveAction,
|
|
109
|
+
// The other half of `resolveAction`, and exported with it deliberately: a
|
|
110
|
+
// partner writing its own pricing grid has to turn that action into a
|
|
111
|
+
// checkout URL, and reading the subscription id straight off the
|
|
112
|
+
// entitlements response instead is what sent a "Subscribe" button to
|
|
113
|
+
// Connect's change-plan screen. See its doc comment.
|
|
114
|
+
checkoutSubscriptionId, annualSaving, discountLabel, ACTION_LABEL,
|
|
108
115
|
// Exported for the same reason `groupPlansByTier` is: a partner writing its
|
|
109
116
|
// own pricing grid has to draw a ₹0 plan as "Free" rather than as a price
|
|
110
117
|
// with a cadence and a GST rate attached, and re-deriving "is this free"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { clearOneTapSuppression, suppressOneTapAfterSignOut } from "./components/one-tap-state.js";
|
|
4
5
|
export const CONNECT_APP_URL = "https://connect.cosmoops.com";
|
|
5
6
|
const ConnectContext = createContext(null);
|
|
6
7
|
/** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
|
|
@@ -185,8 +186,91 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
185
186
|
credentials: "same-origin",
|
|
186
187
|
}).catch(() => { });
|
|
187
188
|
cache.current.clear();
|
|
189
|
+
// Someone who just signed out must not be offered the same account back on
|
|
190
|
+
// the next render - see `suppressOneTapAfterSignOut`.
|
|
191
|
+
suppressOneTapAfterSignOut();
|
|
188
192
|
setResult({ session: { user: null, subscription: null, role: null, permissions: [], staff: false, audience: null }, error: null });
|
|
189
193
|
}, [basePath, fetchImpl]);
|
|
194
|
+
/**
|
|
195
|
+
* Where Connect answers One Tap, taken from the session read rather than
|
|
196
|
+
* assembled here - the bridge knows the `client_id` and the browser need not.
|
|
197
|
+
*/
|
|
198
|
+
const oneTapEndpoints = result?.session.oneTap ?? null;
|
|
199
|
+
const readOneTapHint = useCallback(async () => {
|
|
200
|
+
if (!oneTapEndpoints)
|
|
201
|
+
return null;
|
|
202
|
+
try {
|
|
203
|
+
// `credentials: "include"` because this is the one request in the SDK
|
|
204
|
+
// that leaves the application's own origin. It carries the visitor's
|
|
205
|
+
// Connect cookie only when the two are same-site; everywhere else it is
|
|
206
|
+
// simply sent without one and comes back `signed_out`.
|
|
207
|
+
const response = await (fetchImpl ?? globalThis.fetch)(oneTapEndpoints.hintUrl, {
|
|
208
|
+
credentials: "include",
|
|
209
|
+
headers: { accept: "application/json" },
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok)
|
|
212
|
+
return null;
|
|
213
|
+
return (await response.json());
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Offline, blocked, or Connect is down. None of those is evidence about
|
|
217
|
+
// whether this visitor has a session, so the honest answer is silence.
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}, [fetchImpl, oneTapEndpoints]);
|
|
221
|
+
const oneTapSignIn = useCallback(async () => {
|
|
222
|
+
if (!oneTapEndpoints)
|
|
223
|
+
return { ok: false, reason: "unavailable" };
|
|
224
|
+
try {
|
|
225
|
+
// 1. The bridge mints the PKCE pair and keeps the verifier in an
|
|
226
|
+
// `HttpOnly` cookie on this origin.
|
|
227
|
+
const start = await mutateResource("/one-tap/start");
|
|
228
|
+
// 2. Connect mints a code against that challenge. The custom header is
|
|
229
|
+
// what forces the preflight, so an origin this client has not
|
|
230
|
+
// registered is refused before the request is ever sent.
|
|
231
|
+
const authorize = await (fetchImpl ?? globalThis.fetch)(oneTapEndpoints.authorizeUrl, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
credentials: "include",
|
|
234
|
+
headers: {
|
|
235
|
+
accept: "application/json",
|
|
236
|
+
"content-type": "application/json",
|
|
237
|
+
"x-connect-one-tap": "1",
|
|
238
|
+
},
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
redirect_uri: start.redirectUri,
|
|
241
|
+
code_challenge: start.codeChallenge,
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
const body = (await authorize.json().catch(() => null));
|
|
245
|
+
if (!authorize.ok || !body?.code) {
|
|
246
|
+
// Connect names the reason, and each one has a different next step -
|
|
247
|
+
// `consent_required` wants the interactive screen, `rate_limited` wants
|
|
248
|
+
// to be left alone. Anything unrecognised is reported as a plain
|
|
249
|
+
// failure rather than guessed at.
|
|
250
|
+
const reason = body?.error;
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
reason: reason === "login_required" ||
|
|
254
|
+
reason === "consent_required" ||
|
|
255
|
+
reason === "interaction_required" ||
|
|
256
|
+
reason === "rate_limited"
|
|
257
|
+
? reason
|
|
258
|
+
: "failed",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// 3. The bridge exchanges the code with the verifier the page never saw,
|
|
262
|
+
// and stores the session.
|
|
263
|
+
await mutateResource("/one-tap/complete", { code: body.code });
|
|
264
|
+
// The prompt was accepted, so whatever backoff earlier dismissals had
|
|
265
|
+
// accumulated is spent - it described reluctance that no longer holds.
|
|
266
|
+
clearOneTapSuppression();
|
|
267
|
+
await refresh();
|
|
268
|
+
return { ok: true };
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return { ok: false, reason: "failed" };
|
|
272
|
+
}
|
|
273
|
+
}, [fetchImpl, mutateResource, oneTapEndpoints, refresh]);
|
|
190
274
|
const value = useMemo(() => ({
|
|
191
275
|
clientId,
|
|
192
276
|
appUrl,
|
|
@@ -200,6 +284,8 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
200
284
|
mutateResource,
|
|
201
285
|
invalidateResource,
|
|
202
286
|
streamResource,
|
|
287
|
+
readOneTapHint,
|
|
288
|
+
oneTapSignIn,
|
|
203
289
|
}), [
|
|
204
290
|
clientId,
|
|
205
291
|
appUrl,
|
|
@@ -213,6 +299,8 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
213
299
|
mutateResource,
|
|
214
300
|
invalidateResource,
|
|
215
301
|
streamResource,
|
|
302
|
+
readOneTapHint,
|
|
303
|
+
oneTapSignIn,
|
|
216
304
|
]);
|
|
217
305
|
return _jsx(ConnectContext.Provider, { value: value, children: children });
|
|
218
306
|
}
|
|
@@ -301,7 +389,7 @@ export function useConnectPlans() {
|
|
|
301
389
|
* `CompanyProfile`. `<ConnectGrievanceNotice>` renders it for you.
|
|
302
390
|
*/
|
|
303
391
|
export function useConnectCompanyProfile() {
|
|
304
|
-
return useConnectResource("/company-profile");
|
|
392
|
+
return useConnectResource("/company-profile", { requiresSession: false });
|
|
305
393
|
}
|
|
306
394
|
/** The apps this workspace can open. */
|
|
307
395
|
export function useConnectApps() {
|
|
@@ -342,10 +430,15 @@ export function useConnectSessions() {
|
|
|
342
430
|
/**
|
|
343
431
|
* One bridge read, shared through the provider's cache.
|
|
344
432
|
*
|
|
345
|
-
*
|
|
346
|
-
* firing before one exists just buys a guaranteed 401.
|
|
433
|
+
* Waits for a session by default: every resource route but one needs it, so
|
|
434
|
+
* firing before one exists just buys a guaranteed 401. `requiresSession:
|
|
435
|
+
* false` opts out, and the reason it exists is the grievance notice - a
|
|
436
|
+
* statutory contact is at its most useful on a public page a signed-out
|
|
437
|
+
* visitor is reading, so a hook that answered `null` until someone logged in
|
|
438
|
+
* would render nothing at exactly the moment that matters.
|
|
347
439
|
*/
|
|
348
|
-
export function useConnectResource(path) {
|
|
440
|
+
export function useConnectResource(path, options) {
|
|
441
|
+
const requiresSession = options?.requiresSession ?? true;
|
|
349
442
|
const { fetchResource, session, isLoading: sessionLoading } = useConnect();
|
|
350
443
|
// Stamped with the `path` it belongs to, so switching paths reports loading
|
|
351
444
|
// without a synchronous `setIsLoading(true)` - and, more usefully, so a
|
|
@@ -356,7 +449,7 @@ export function useConnectResource(path) {
|
|
|
356
449
|
// Signed out is handled by derivation below rather than by writing state
|
|
357
450
|
// here - a synchronous setState inside an effect costs an extra render pass
|
|
358
451
|
// and is what `react-hooks/set-state-in-effect` exists to catch.
|
|
359
|
-
if (sessionLoading || !session?.user)
|
|
452
|
+
if (requiresSession && (sessionLoading || !session?.user))
|
|
360
453
|
return;
|
|
361
454
|
let active = true;
|
|
362
455
|
fetchResource(path)
|
|
@@ -375,8 +468,10 @@ export function useConnectResource(path) {
|
|
|
375
468
|
// `version` is otherwise unread - it exists purely to force this effect
|
|
376
469
|
// to re-run on `refetch()`, after the caller has already evicted this
|
|
377
470
|
// path's cache entry via `mutateResource`.
|
|
378
|
-
}, [fetchResource, path, session?.user, sessionLoading, version]);
|
|
379
|
-
|
|
471
|
+
}, [fetchResource, path, requiresSession, session?.user, sessionLoading, version]);
|
|
472
|
+
// A public read is never settled by the absence of a session - it is
|
|
473
|
+
// genuinely pending until the fetch lands.
|
|
474
|
+
const signedOut = requiresSession && !sessionLoading && !session?.user;
|
|
380
475
|
const loaded = entry?.path === path ? entry : null;
|
|
381
476
|
return {
|
|
382
477
|
data: loaded?.data ?? null,
|