@cosmoops/connect-sdk 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +2 -0
  3. package/dist/cjs/client/components/identity.js +24 -0
  4. package/dist/cjs/client/components/one-tap-state.js +96 -0
  5. package/dist/cjs/client/components/one-tap.js +106 -0
  6. package/dist/cjs/client/components/user-menu.js +2 -6
  7. package/dist/cjs/client/index.js +5 -3
  8. package/dist/cjs/client/provider.js +88 -0
  9. package/dist/cjs/server/client.js +9 -1
  10. package/dist/cjs/server/handler.js +126 -2
  11. package/dist/cjs/server/resources/calls.js +57 -0
  12. package/dist/cjs/server/resources/channels.js +115 -0
  13. package/dist/cjs/server/resources/identity.js +40 -0
  14. package/dist/esm/client/components/identity.js +21 -0
  15. package/dist/esm/client/components/one-tap-state.js +90 -0
  16. package/dist/esm/client/components/one-tap.js +103 -0
  17. package/dist/esm/client/components/user-menu.js +1 -5
  18. package/dist/esm/client/index.js +1 -0
  19. package/dist/esm/client/provider.js +88 -0
  20. package/dist/esm/server/client.js +9 -1
  21. package/dist/esm/server/handler.js +127 -3
  22. package/dist/esm/server/resources/calls.js +53 -0
  23. package/dist/esm/server/resources/channels.js +111 -0
  24. package/dist/esm/server/resources/identity.js +39 -0
  25. package/dist/types/client/components/identity.d.ts +17 -0
  26. package/dist/types/client/components/one-tap-state.d.ts +39 -0
  27. package/dist/types/client/components/one-tap.d.ts +50 -0
  28. package/dist/types/client/index.d.ts +3 -1
  29. package/dist/types/client/provider.d.ts +81 -0
  30. package/dist/types/server/client.d.ts +7 -1
  31. package/dist/types/server/index.d.ts +2 -0
  32. package/dist/types/server/resources/calls.d.ts +45 -0
  33. package/dist/types/server/resources/channels.d.ts +91 -0
  34. package/dist/types/server/resources/identity.d.ts +35 -0
  35. package/dist/types/types.d.ts +172 -0
  36. package/package.json +1 -1
@@ -105,6 +105,7 @@ function createConnectHandler(options) {
105
105
  const defaultRedirect = options.defaultRedirect ?? "/";
106
106
  const stateCookieName = options.stateCookieName ?? "connect_oauth_state";
107
107
  const silentCookieName = `${stateCookieName}_silent`;
108
+ const pkceCookieName = `${stateCookieName}_pkce`;
108
109
  const callbackPath = `${basePath}/callback`;
109
110
  /**
110
111
  * Short-lived, httpOnly, and `SameSite=Lax` - `Strict` would not be sent on
@@ -151,6 +152,35 @@ function createConnectHandler(options) {
151
152
  .filter(Boolean)
152
153
  .join("; ");
153
154
  }
155
+ /**
156
+ * Holds the One Tap PKCE **verifier** for the few seconds between the tap and
157
+ * the exchange.
158
+ *
159
+ * This is the cookie the whole no-navigation flow rests on. Connect hands the
160
+ * authorization code to the *page*, so the code alone must not be enough to
161
+ * mint a session - the verifier is what completes it, and it never leaves
162
+ * this origin's cookie jar. `SameSite=Lax` is sufficient and correct: unlike
163
+ * the state cookie there is no cross-site redirect that has to carry it, and
164
+ * `Lax` additionally means a cross-site `POST` to `/one-tap/complete` arrives
165
+ * without it and simply fails.
166
+ *
167
+ * Five minutes, not the state cookie's fifteen: it is minted on the tap and
168
+ * spent on the next request, so a long window is only a longer chance for a
169
+ * stale one to be lying around.
170
+ */
171
+ function pkceCookie(value, secure) {
172
+ const cleared = value === "";
173
+ return [
174
+ `${pkceCookieName}=${encodeURIComponent(value)}`,
175
+ `Path=${basePath}`,
176
+ "HttpOnly",
177
+ "SameSite=Lax",
178
+ secure ? "Secure" : "",
179
+ cleared ? "Max-Age=0" : "Max-Age=300",
180
+ ]
181
+ .filter(Boolean)
182
+ .join("; ");
183
+ }
154
184
  async function requireToken(request) {
155
185
  const token = await session.read(request);
156
186
  if (!token) {
@@ -258,6 +288,14 @@ function createConnectHandler(options) {
258
288
  },
259
289
  });
260
290
  }
291
+ /**
292
+ * Where the browser asks Connect about the visitor, and where it asks for a
293
+ * code once they tap - or `null` when this deployment cannot run One Tap at
294
+ * all (no `clientId`, or a session store that cannot `write`, in which case
295
+ * `/one-tap/complete` does not exist and offering the prompt would strand
296
+ * anyone who used it).
297
+ */
298
+ const oneTapEndpoints = session.write ? client.identity.oneTapEndpoints() : null;
261
299
  const routes = {
262
300
  "GET /session": async (request) => {
263
301
  const token = await session.read(request);
@@ -267,9 +305,21 @@ function createConnectHandler(options) {
267
305
  // the client never has to tell "signed out" from "signed in with no
268
306
  // role" by which keys happen to be present.
269
307
  if (!token) {
270
- return json({ user: null, subscription: null, role: null, permissions: [], staff: false });
308
+ return json({
309
+ user: null,
310
+ subscription: null,
311
+ role: null,
312
+ permissions: [],
313
+ staff: false,
314
+ // Carried on the read the provider already makes on mount, so One Tap
315
+ // costs no extra round trip to discover where to ask. Present on both
316
+ // branches even though only the signed-out one can use it: a response
317
+ // whose shape depends on the answer is a response consumers start
318
+ // testing for key presence instead of value.
319
+ oneTap: oneTapEndpoints,
320
+ });
271
321
  }
272
- return json(await client.identity.getUser(token));
322
+ return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints });
273
323
  },
274
324
  "GET /entitlements": async (request) => {
275
325
  const { workspaceId } = await requireWorkspace(request);
@@ -704,6 +754,74 @@ function createConnectHandler(options) {
704
754
  await session.write(request, response, result.sessionToken);
705
755
  return response;
706
756
  },
757
+ /**
758
+ * One Tap, step one: mint the PKCE pair, keep the verifier here, hand the
759
+ * challenge to the page.
760
+ *
761
+ * Called on the *tap*, not on mount, so the five-minute verifier is minted
762
+ * seconds before it is spent rather than sitting in a cookie for as long as
763
+ * someone leaves the prompt on screen.
764
+ *
765
+ * A `POST` despite reading nothing: it sets a cookie, and a `GET` that
766
+ * changes state is both wrong and prefetchable - a link prefetcher or a
767
+ * scanner would silently rotate the verifier out from under an open prompt.
768
+ */
769
+ "POST /one-tap/start": async (request) => {
770
+ const url = new URL(request.url);
771
+ const { verifier, challenge } = await (0, identity_js_1.createPkcePair)();
772
+ const response = json({
773
+ codeChallenge: challenge,
774
+ // The already-registered callback URI. One Tap never navigates to it -
775
+ // it is the value the code is *bound* to, and it must be byte-identical
776
+ // at `/one-tap/complete`, so both ends derive it the same way the
777
+ // redirect flow does rather than accepting it from the page.
778
+ redirectUri: `${url.origin}${callbackPath}`,
779
+ });
780
+ response.headers.append("set-cookie", pkceCookie(verifier, url.protocol === "https:"));
781
+ return response;
782
+ },
783
+ /**
784
+ * One Tap, step two: trade the page's code for a session, using the
785
+ * verifier the page never saw.
786
+ *
787
+ * This is `GET /callback`'s body without the navigation - same exchange,
788
+ * same verify-before-store ordering, same session store. The verifier is
789
+ * what makes the difference: a code lifted out of the browser cannot be
790
+ * redeemed anywhere else, because the exchange is refused without it.
791
+ */
792
+ "POST /one-tap/complete": async (request) => {
793
+ const url = new URL(request.url);
794
+ const secure = url.protocol === "https:";
795
+ const clearPkce = pkceCookie("", secure);
796
+ const verifier = readCookie(request, pkceCookieName);
797
+ if (!verifier) {
798
+ // The prompt sat unanswered past the cookie's life, or a cross-site
799
+ // `POST` arrived without it. Either way there is nothing to complete,
800
+ // and the client falls back to the interactive flow.
801
+ const expired = json({ error: "expired" }, 400);
802
+ expired.headers.append("set-cookie", clearPkce);
803
+ return expired;
804
+ }
805
+ const body = (await request.json().catch(() => null));
806
+ const code = typeof body?.code === "string" ? body.code : null;
807
+ if (!code) {
808
+ const invalid = json({ error: "invalid_request" }, 400);
809
+ invalid.headers.append("set-cookie", clearPkce);
810
+ return invalid;
811
+ }
812
+ const result = await client.identity.exchangeCode({
813
+ code,
814
+ redirectUri: `${url.origin}${callbackPath}`,
815
+ codeVerifier: verifier,
816
+ });
817
+ // Verified before the session is stored, exactly as the callback does -
818
+ // an unverifiable token must never become a signed-in user.
819
+ await client.identity.verifyIdToken(result.idToken);
820
+ const response = json({ ok: true });
821
+ response.headers.append("set-cookie", clearPkce);
822
+ await session.write(request, response, result.sessionToken);
823
+ return response;
824
+ },
707
825
  "POST /logout": async (request) => {
708
826
  const token = await session.read(request);
709
827
  const response = json({ ok: true });
@@ -722,6 +840,12 @@ function createConnectHandler(options) {
722
840
  if (!session.write) {
723
841
  delete routes["GET /signin"];
724
842
  delete routes["GET /callback"];
843
+ // Same reason, and `oneTapEndpoints` is already `null` above so the client
844
+ // never offers the prompt in the first place - this is the second half of
845
+ // that, so a hand-written call cannot reach a route that would throw on
846
+ // `session.write!`.
847
+ delete routes["POST /one-tap/start"];
848
+ delete routes["POST /one-tap/complete"];
725
849
  // A switch rotates the session token. Without somewhere to persist the new
726
850
  // one, the old one would be revoked and the user silently signed out - so
727
851
  // the route does not exist rather than working once and breaking.
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CallsResource = void 0;
4
+ const http_js_1 = require("../http.js");
5
+ /**
6
+ * Every call that arrived on a channel assigned to your application.
7
+ *
8
+ * The application is resolved from your API key and is never a parameter, so
9
+ * there is no request that reads another partner's calls.
10
+ *
11
+ * `channelId` is the only filter, and that is a budget decision rather than an
12
+ * oversight: each filter costs a Firestore composite index against a project
13
+ * already near its ceiling. Filter the rest client-side, or read the
14
+ * aggregates from `analytics`.
15
+ */
16
+ class CallsResource {
17
+ transport;
18
+ constructor(transport) {
19
+ this.transport = transport;
20
+ }
21
+ /**
22
+ * Your calls, newest first, across all of your subscribing workspaces.
23
+ *
24
+ * **Page until `hasMore` is `false`; do not stop on an empty `items`.** A
25
+ * page can come back empty with more behind it.
26
+ */
27
+ list(input = {}) {
28
+ return (0, http_js_1.request)(this.transport, {
29
+ method: "GET",
30
+ path: "/calls",
31
+ query: {
32
+ channelId: input.channelId,
33
+ cursor: input.cursor,
34
+ limit: input.limit === undefined ? undefined : String(input.limit),
35
+ },
36
+ });
37
+ }
38
+ /**
39
+ * One call and every transcript turn, ordered by `sequence` - which is the
40
+ * sort to trust, since two turns can share a millisecond.
41
+ *
42
+ * `recordingFileId` and `transcriptFileId` are Drive file ids, readable with
43
+ * a `drive:read` key. Both are `null` until the carrier posts the audio,
44
+ * which is the normal state for a call that has only just ended - branch on
45
+ * `recordingStatus` rather than treating `null` as a failure.
46
+ *
47
+ * Throws `ConnectError` with `code: "not_found"` for a call that does not
48
+ * exist *or* belongs to another application.
49
+ */
50
+ get(callId) {
51
+ return (0, http_js_1.request)(this.transport, {
52
+ method: "GET",
53
+ path: `/calls/${encodeURIComponent(callId)}`,
54
+ });
55
+ }
56
+ }
57
+ exports.CallsResource = CallsResource;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChannelsResource = void 0;
4
+ const http_js_1 = require("../http.js");
5
+ /**
6
+ * The phone numbers Connect has assigned to your application.
7
+ *
8
+ * **Inventory is platform-owned.** A number is a regulated asset bought under
9
+ * Connect's carrier account and reassignable between workspaces, so your
10
+ * application never provisions, prices or releases one - it claims from a pool
11
+ * it cannot see, and reads back what it was given. That is why `claim` takes a
12
+ * workspace and a kind rather than a number, and why nothing here exposes a
13
+ * carrier, a provider reference or a monthly rate.
14
+ *
15
+ * The application is resolved from your API key and is never a parameter, so
16
+ * no request shape here can reach another partner's channels. A channel
17
+ * belonging to someone else answers `404`, not `403` - confirming it exists
18
+ * would make these reads an oracle for enumerating their channel ids.
19
+ */
20
+ class ChannelsResource {
21
+ transport;
22
+ constructor(transport) {
23
+ this.transport = transport;
24
+ }
25
+ /**
26
+ * Every channel assigned to your application, across all of your subscribing
27
+ * workspaces.
28
+ *
29
+ * Page until `hasMore` is `false`.
30
+ */
31
+ list(input = {}) {
32
+ return (0, http_js_1.request)(this.transport, {
33
+ method: "GET",
34
+ path: "/channels",
35
+ query: {
36
+ workspaceId: input.workspaceId,
37
+ cursor: input.cursor,
38
+ limit: input.limit === undefined ? undefined : String(input.limit),
39
+ },
40
+ });
41
+ }
42
+ /**
43
+ * The channel behind an id you already hold - a webhook's `channelId`, or a
44
+ * call record's.
45
+ *
46
+ * Throws `ConnectError` with `code: "not_found"` for an id that does not
47
+ * exist *or* belongs to another application.
48
+ */
49
+ get(channelId) {
50
+ return (0, http_js_1.request)(this.transport, {
51
+ method: "GET",
52
+ path: `/channels/${encodeURIComponent(channelId)}`,
53
+ });
54
+ }
55
+ /**
56
+ * Takes a number out of Connect's inventory and binds it to one of your
57
+ * subscribing workspaces.
58
+ *
59
+ * **You do not pick the number.** A workspace may hold exactly one, across
60
+ * every application it uses, and it is then fixed for the life of the
61
+ * engagement - there is no re-claim and no swap. Only a Connect operator can
62
+ * unassign one.
63
+ *
64
+ * Two different `409`s, and they need different handling: the workspace
65
+ * already holds a channel of this kind (fix the call), or the pool is empty
66
+ * right now (retry - Connect is notified, and this is not permanent).
67
+ *
68
+ * The claimed channel comes back without routing, because it has none yet.
69
+ * Call `setRouting` next, or the number refuses every call that reaches it.
70
+ */
71
+ claim(input) {
72
+ return (0, http_js_1.request)(this.transport, {
73
+ method: "POST",
74
+ path: "/channels/claim",
75
+ json: { workspaceId: input.workspaceId, kind: input.kind ?? "voice" },
76
+ });
77
+ }
78
+ /**
79
+ * Points a channel at wherever inbound conversations should go.
80
+ *
81
+ * `destination` is yours: Connect stores it verbatim and never reads into
82
+ * it. It **replaces** the stored record rather than merging, so send the
83
+ * whole destination each time; it must serialize to 16 KB or fewer.
84
+ *
85
+ * Throws `code: "conflict"` when the channel is suspended or released - use
86
+ * `clearRouting` to stop a suspended number answering, which is deliberately
87
+ * still allowed.
88
+ */
89
+ setRouting(channelId, destination) {
90
+ return (0, http_js_1.request)(this.transport, {
91
+ method: "PUT",
92
+ path: `/channels/${encodeURIComponent(channelId)}/routing`,
93
+ json: { destination },
94
+ });
95
+ }
96
+ /**
97
+ * Takes a number out of service without giving it up - the channel stays
98
+ * assigned to you and stays billable, and inbound calls are refused until
99
+ * you write routing again.
100
+ *
101
+ * This is the only way to say "stop answering": overwriting `destination`
102
+ * can only ever point the number somewhere *else*, and unassigning it is a
103
+ * Console action that returns the number to Connect's inventory.
104
+ *
105
+ * Idempotent - clearing already-clear routing answers `200` with
106
+ * `routing: null`, so a retry after a lost response is safe.
107
+ */
108
+ clearRouting(channelId) {
109
+ return (0, http_js_1.request)(this.transport, {
110
+ method: "DELETE",
111
+ path: `/channels/${encodeURIComponent(channelId)}/routing`,
112
+ });
113
+ }
114
+ }
115
+ exports.ChannelsResource = ChannelsResource;
@@ -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", {
@@ -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
+ }
@@ -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
  }
@@ -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";