@growth-operations/ui-shared 1.9.0 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { useEffect, useState } from "react";
2
2
  import {
3
3
  Flex,
4
4
  Heading,
@@ -11,6 +11,46 @@ import {
11
11
  import { fmtDate, daysUntil } from "../lib/format";
12
12
  import { CreditMeter } from "../home/CreditMeter";
13
13
  import { PlanGrid } from "./PlanGrid";
14
+ import { refreshBillingActionTokens } from "../sdk/billing";
15
+
16
+ // Billing action tokens (see common.billing.action_token) are signed with a
17
+ // 5-minute TTL — short enough that a customer who opens the tab and comes
18
+ // back later hits an expired Stripe link. Refresh at a fixed interval well
19
+ // under that TTL so a normally-paced re-render always has a token with
20
+ // several minutes of life left; if a refresh call is slow or fails, the
21
+ // still-live token already in state keeps working until the next tick.
22
+ const TOKEN_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
23
+
24
+ // Re-mints state.billing_action_tokens on an interval so long-open tabs never
25
+ // hand the customer an expired Stripe link. Returns the latest known token
26
+ // set — the initial props value until the first successful refresh, then
27
+ // whatever the service last minted. Silently keeps the previous tokens on a
28
+ // failed refresh (e.g. transient network blip); the tokens simply expire on
29
+ // their own schedule as before this loop existed, which is a strict
30
+ // improvement (some refreshes succeeding beats none).
31
+ function useRefreshedBillingActionTokens(billingBaseUrl, initialTokens) {
32
+ const [tokens, setTokens] = useState(initialTokens ?? null);
33
+
34
+ useEffect(() => {
35
+ setTokens(initialTokens ?? null);
36
+ }, [initialTokens]);
37
+
38
+ useEffect(() => {
39
+ const portalToken = initialTokens?.portal;
40
+ if (!billingBaseUrl || !portalToken) return undefined;
41
+
42
+ const id = setInterval(() => {
43
+ refreshBillingActionTokens(billingBaseUrl, portalToken)
44
+ .then((fresh) => setTokens(fresh))
45
+ .catch(() => {});
46
+ }, TOKEN_REFRESH_INTERVAL_MS);
47
+
48
+ return () => clearInterval(id);
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ }, [billingBaseUrl, initialTokens?.portal]);
51
+
52
+ return tokens;
53
+ }
14
54
 
15
55
  // The shared Billing tab for all Growth Operations apps. Branches on the
16
56
  // entitlement union (CONTRACT.md):
@@ -412,6 +452,20 @@ export function BillingTab({ context, state, appKey, openIframe = null }) {
412
452
  const mode = state?.entitlement?.mode;
413
453
  const isSuperAdmin = state?.user?.is_super_admin === true;
414
454
 
455
+ // Keep the billing action tokens (portal/checkout/upgrade Stripe links)
456
+ // fresh for as long as this tab stays open. Overriding billing_action_tokens
457
+ // on a copy of `state` means every descendant (TrialSubscriptionBilling,
458
+ // CreditsBilling, PlanGrid) picks up refreshed tokens through the same
459
+ // `state?.billing_action_tokens` reads they already had — no prop threading.
460
+ const refreshedTokens = useRefreshedBillingActionTokens(
461
+ state?.billing_base_url,
462
+ state?.billing_action_tokens
463
+ );
464
+ const billingState =
465
+ refreshedTokens && refreshedTokens !== state?.billing_action_tokens
466
+ ? { ...state, billing_action_tokens: refreshedTokens }
467
+ : state;
468
+
415
469
  return (
416
470
  <Flex direction="column" gap="medium">
417
471
  <Heading>Billing</Heading>
@@ -420,13 +474,13 @@ export function BillingTab({ context, state, appKey, openIframe = null }) {
420
474
  <Text>Billing settings are only available to super admins.</Text>
421
475
  </Alert>
422
476
  ) : mode === "legacy" ? (
423
- <LegacyBilling state={state} />
477
+ <LegacyBilling state={billingState} />
424
478
  ) : mode === "credits" ? (
425
- <CreditsBilling context={context} state={state} appKey={appKey} openIframe={openIframe} />
479
+ <CreditsBilling context={context} state={billingState} appKey={appKey} openIframe={openIframe} />
426
480
  ) : mode === "comped" ? (
427
481
  <CompedBilling />
428
482
  ) : (
429
- <TrialSubscriptionBilling context={context} state={state} appKey={appKey} openIframe={openIframe} />
483
+ <TrialSubscriptionBilling context={context} state={billingState} appKey={appKey} openIframe={openIframe} />
430
484
  )}
431
485
  </Flex>
432
486
  );
package/src/index.js CHANGED
@@ -91,4 +91,6 @@ export {
91
91
  TokenProvider,
92
92
  useToken,
93
93
  TokenContext,
94
+ // Billing service
95
+ refreshBillingActionTokens,
94
96
  } from "./sdk/index";
@@ -0,0 +1,28 @@
1
+ // Billing-service SDK — direct calls to growth-ops-apps-app's billing
2
+ // endpoints (NOT the calling app's own backend, NOT api.hubapi.com). Every
3
+ // consuming app already allowlists ${BILLING_BASE_URL} under
4
+ // permittedUrls.fetch (it's also used for the Stripe-redirect <a href> links
5
+ // and the embedded-checkout iframe host), so hubspot.fetch can reach it
6
+ // directly with no new hsmeta changes.
7
+ //
8
+ // Auth here is the signed `token` query param (see
9
+ // common.billing.action_token) — NOT an Authorization header — so this is a
10
+ // bare hubspot.fetch, not callHubSpotApi (which always attaches a bearer
11
+ // token these endpoints don't expect).
12
+ import { hubspot, logger } from "@hubspot/ui-extensions";
13
+
14
+ // Re-mint a fresh { portal, checkout, upgrade } token set from a still-valid
15
+ // one. See billing.py's /v1/billing/refresh-tokens: it verifies `token`
16
+ // (whichever action it authorizes — BillingTab always holds/passes `portal`)
17
+ // and returns a brand-new set signed for the same app_key/portal_id. Throws
18
+ // on a 401 (token already expired) — the caller's next /v1/home load is the
19
+ // recovery path for that, same as before this refresh loop existed.
20
+ export async function refreshBillingActionTokens(billingBaseUrl, token) {
21
+ const url = `${billingBaseUrl}/v1/billing/refresh-tokens?token=${encodeURIComponent(token)}`;
22
+ const response = await hubspot.fetch(url, { timeout: 10000, method: "GET" });
23
+ if (!response.ok) {
24
+ logger.warn(`[Billing] refresh-tokens failed: ${response.status}`);
25
+ throw new Error(`refresh-tokens failed with status ${response.status}`);
26
+ }
27
+ return response.json();
28
+ }
package/src/sdk/index.js CHANGED
@@ -32,3 +32,6 @@ export {
32
32
  // Token lifecycle.
33
33
  export { getShortLivedToken, isTokenExpired } from "./token/getShortLivedToken";
34
34
  export { TokenProvider, useToken, TokenContext } from "./token/TokenContext";
35
+
36
+ // Billing service (growth-ops-apps-app), NOT the calling app's own backend.
37
+ export { refreshBillingActionTokens } from "./billing";