@growth-operations/ui-shared 1.0.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/README.md +209 -0
- package/package.json +23 -0
- package/src/billing/BillingTab.jsx +246 -0
- package/src/home/AppHome.jsx +115 -0
- package/src/home/CreditMeter.jsx +85 -0
- package/src/home/FeatureNudges.jsx +38 -0
- package/src/home/HealthHero.jsx +95 -0
- package/src/home/InstallProgress.jsx +102 -0
- package/src/home/OnboardingChecklist.jsx +58 -0
- package/src/home/SyncPulse.jsx +45 -0
- package/src/index.js +64 -0
- package/src/lib/format.js +58 -0
- package/src/lib/useStrictModeEffect.js +84 -0
- package/src/sdk/app/alerts.js +41 -0
- package/src/sdk/app/base.js +57 -0
- package/src/sdk/app/encryption.js +98 -0
- package/src/sdk/app/settings.js +65 -0
- package/src/sdk/hubspot/base.js +67 -0
- package/src/sdk/hubspot/forms.js +12 -0
- package/src/sdk/hubspot/lists.js +31 -0
- package/src/sdk/hubspot/pipeline.js +103 -0
- package/src/sdk/hubspot/properties.js +50 -0
- package/src/sdk/index.js +33 -0
- package/src/sdk/token/TokenContext.jsx +89 -0
- package/src/sdk/token/getShortLivedToken.js +24 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// CRM-card field encryption helpers.
|
|
2
|
+
//
|
|
3
|
+
// Used to encrypt small values (e.g. a token or secret) before storing them on a
|
|
4
|
+
// CRM record property, deriving the key from the record's own identity so the
|
|
5
|
+
// ciphertext is scoped to that record. These are generic Web Crypto helpers and
|
|
6
|
+
// are ported as-is — no app-specific names. The PBKDF2 salt is a fixed constant
|
|
7
|
+
// ("hubspot-ats"); it only needs to be stable across encrypt/decrypt, not secret.
|
|
8
|
+
//
|
|
9
|
+
// getKeyFromContext derives the key from `${context.extension.objectTypeId}-${context.crm.objectId}`,
|
|
10
|
+
// so this is only usable in a CRM-card context where context.crm.objectId exists.
|
|
11
|
+
|
|
12
|
+
// Convert string to base64 (UTF-8 safe).
|
|
13
|
+
export function strToBase64(str) {
|
|
14
|
+
return btoa(
|
|
15
|
+
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function toSolidBytes(match, p1) {
|
|
16
|
+
return String.fromCharCode("0x" + p1);
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Convert base64 to string (UTF-8 safe).
|
|
22
|
+
export function base64ToStr(str) {
|
|
23
|
+
return decodeURIComponent(
|
|
24
|
+
atob(str)
|
|
25
|
+
.split("")
|
|
26
|
+
.map(function (c) {
|
|
27
|
+
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
|
|
28
|
+
})
|
|
29
|
+
.join("")
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Convert string to Uint8Array.
|
|
34
|
+
function strToUint8Array(str) {
|
|
35
|
+
return Uint8Array.from(str.split("").map((ch) => ch.charCodeAt(0)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Derive an AES-GCM key bound to the current CRM record.
|
|
39
|
+
export async function getKeyFromContext(context) {
|
|
40
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
41
|
+
"raw",
|
|
42
|
+
strToUint8Array(`${context.extension.objectTypeId}-${context.crm.objectId}`),
|
|
43
|
+
{ name: "PBKDF2" },
|
|
44
|
+
false,
|
|
45
|
+
["deriveBits", "deriveKey"]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return crypto.subtle.deriveKey(
|
|
49
|
+
{
|
|
50
|
+
name: "PBKDF2",
|
|
51
|
+
salt: strToUint8Array("hubspot-ats"),
|
|
52
|
+
iterations: 100000,
|
|
53
|
+
hash: "SHA-256",
|
|
54
|
+
},
|
|
55
|
+
keyMaterial,
|
|
56
|
+
{ name: "AES-GCM", length: 256 },
|
|
57
|
+
true,
|
|
58
|
+
["encrypt", "decrypt"]
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function encrypt(data, context) {
|
|
63
|
+
const key = await getKeyFromContext(context);
|
|
64
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
65
|
+
const encodedData = strToUint8Array(JSON.stringify(data));
|
|
66
|
+
|
|
67
|
+
const encryptedContent = await crypto.subtle.encrypt(
|
|
68
|
+
{ name: "AES-GCM", iv: iv },
|
|
69
|
+
key,
|
|
70
|
+
encodedData
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Combine IV and encrypted content.
|
|
74
|
+
const encryptedArray = new Uint8Array(iv.length + encryptedContent.byteLength);
|
|
75
|
+
encryptedArray.set(iv);
|
|
76
|
+
encryptedArray.set(new Uint8Array(encryptedContent), iv.length);
|
|
77
|
+
|
|
78
|
+
// Convert to base64 for storage.
|
|
79
|
+
return strToBase64(String.fromCharCode.apply(null, encryptedArray));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function decrypt(encryptedData, context) {
|
|
83
|
+
const key = await getKeyFromContext(context);
|
|
84
|
+
|
|
85
|
+
// Convert from base64 and split IV and content.
|
|
86
|
+
const encryptedArray = strToUint8Array(base64ToStr(encryptedData));
|
|
87
|
+
const iv = encryptedArray.slice(0, 12);
|
|
88
|
+
const content = encryptedArray.slice(12);
|
|
89
|
+
|
|
90
|
+
const decryptedContent = await crypto.subtle.decrypt(
|
|
91
|
+
{ name: "AES-GCM", iv: iv },
|
|
92
|
+
key,
|
|
93
|
+
content
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const decoder = new TextDecoder();
|
|
97
|
+
return JSON.parse(decoder.decode(decryptedContent));
|
|
98
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Settings API client with a module-level cache.
|
|
2
|
+
//
|
|
3
|
+
// Two ways to use this:
|
|
4
|
+
// 1. The default getSettings/updateSettings exports — hit /api/v1/settings and
|
|
5
|
+
// share one module-level cache. Good for the common case.
|
|
6
|
+
// 2. makeSettingsApi({ path }) — build an isolated client with its own cache,
|
|
7
|
+
// for apps whose settings live at a different path, or that want a cache not
|
|
8
|
+
// shared with other importers.
|
|
9
|
+
//
|
|
10
|
+
// getSettings accepts { force } to bypass the cache (e.g. polling for live sync
|
|
11
|
+
// status, which a stale cache would otherwise mask). updateSettings invalidates
|
|
12
|
+
// the cache on write.
|
|
13
|
+
//
|
|
14
|
+
// All requests go through callAppApi → hubspot.fetch rules apply (object body,
|
|
15
|
+
// no custom headers).
|
|
16
|
+
import { callAppApi } from "./base";
|
|
17
|
+
|
|
18
|
+
// Build an isolated settings client with its own module-level cache.
|
|
19
|
+
// path: backend settings endpoint (default "/api/v1/settings").
|
|
20
|
+
export function makeSettingsApi({ path = "/api/v1/settings" } = {}) {
|
|
21
|
+
let cachedSettings = null;
|
|
22
|
+
let settingsPromise = null;
|
|
23
|
+
|
|
24
|
+
async function getSettings(context, { force = false } = {}) {
|
|
25
|
+
// Return cached settings if available — unless the caller forces a fresh
|
|
26
|
+
// read (the cache would otherwise mask live changes).
|
|
27
|
+
if (!force && cachedSettings) {
|
|
28
|
+
return cachedSettings;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Return existing in-flight fetch (don't share it with a forced read).
|
|
32
|
+
if (!force && settingsPromise) {
|
|
33
|
+
return settingsPromise;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fetchPromise = (async () => {
|
|
37
|
+
try {
|
|
38
|
+
const settings = await callAppApi(context, path, "GET");
|
|
39
|
+
cachedSettings = settings;
|
|
40
|
+
settingsPromise = null;
|
|
41
|
+
return settings;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
settingsPromise = null;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
|
|
48
|
+
if (!force) settingsPromise = fetchPromise;
|
|
49
|
+
return fetchPromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function updateSettings(context, settings) {
|
|
53
|
+
const result = await callAppApi(context, path, "PUT", settings);
|
|
54
|
+
// Invalidate cache when settings are updated.
|
|
55
|
+
cachedSettings = null;
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { getSettings, updateSettings };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default client at /api/v1/settings.
|
|
63
|
+
const defaultSettingsApi = makeSettingsApi();
|
|
64
|
+
export const getSettings = defaultSettingsApi.getSettings;
|
|
65
|
+
export const updateSettings = defaultSettingsApi.updateSettings;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// callHubSpotApi / buildHubSpotUrl — direct calls to HubSpot's public API.
|
|
2
|
+
//
|
|
3
|
+
// For calls that go straight to api.hubapi.com using a short-lived OAuth token
|
|
4
|
+
// (see ../token/getShortLivedToken), rather than through the app's own backend.
|
|
5
|
+
//
|
|
6
|
+
// hubspot.fetch rules enforced here:
|
|
7
|
+
// - Authorization: Bearer {token} is the ONE client header hubspot.fetch
|
|
8
|
+
// permits. We send only that.
|
|
9
|
+
// - `payload` is passed as a plain OBJECT. NEVER JSON.stringify it — hubspot.fetch
|
|
10
|
+
// serializes it. (This was a latent bug in some app copies, which did
|
|
11
|
+
// `body: JSON.stringify(payload)` against api.hubapi.com; fixed here.)
|
|
12
|
+
import { hubspot, logger } from "@hubspot/ui-extensions";
|
|
13
|
+
|
|
14
|
+
export class HubSpotApiError extends Error {
|
|
15
|
+
constructor(message, statusCode, responseText) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.statusCode = statusCode;
|
|
18
|
+
this.responseText = responseText;
|
|
19
|
+
this.name = "HubSpotApiError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// url: full HubSpot URL, build with buildHubSpotUrl()
|
|
24
|
+
// token: short-lived OAuth access token string
|
|
25
|
+
// method: HTTP method (default "GET")
|
|
26
|
+
// payload: plain object — NOT a JSON string (see header)
|
|
27
|
+
export async function callHubSpotApi(url, token, method = "GET", payload = null) {
|
|
28
|
+
logger.debug(`[HubSpotApi] ${method} ${url}`);
|
|
29
|
+
|
|
30
|
+
const response = await hubspot.fetch(url, {
|
|
31
|
+
timeout: 30000,
|
|
32
|
+
method,
|
|
33
|
+
// Authorization is the only permitted client header.
|
|
34
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
35
|
+
// body must be a plain object — hubspot.fetch serializes it. Do NOT
|
|
36
|
+
// JSON.stringify here.
|
|
37
|
+
...(payload && { body: payload }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const errorText = await response.text();
|
|
42
|
+
logger.error(`[HubSpotApi] Error: ${method} ${url} - Status ${response.status}`);
|
|
43
|
+
|
|
44
|
+
// Surface expired-token failures distinctly so callers can refresh + retry.
|
|
45
|
+
try {
|
|
46
|
+
const errorJson = JSON.parse(errorText);
|
|
47
|
+
if (errorJson.category === "EXPIRED_AUTHENTICATION") {
|
|
48
|
+
logger.warn("[HubSpotApi] Token expired");
|
|
49
|
+
throw new HubSpotApiError("TOKEN_EXPIRED", response.status, errorText);
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e instanceof HubSpotApiError) {
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
// body wasn't JSON; fall through to the generic error below
|
|
56
|
+
}
|
|
57
|
+
throw new HubSpotApiError(errorText, response.status, errorText);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return response.json();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build a HubSpot public API URL from an endpoint path, e.g.
|
|
64
|
+
// buildHubSpotUrl("crm/v3/properties/contacts").
|
|
65
|
+
export function buildHubSpotUrl(endpoint) {
|
|
66
|
+
return `https://api.hubapi.com/${endpoint}`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// HubSpot forms fetcher (for form-selection pickers).
|
|
2
|
+
//
|
|
3
|
+
// Goes through callHubSpotApi → hubspot.fetch rules apply.
|
|
4
|
+
import { buildHubSpotUrl, callHubSpotApi } from "./base";
|
|
5
|
+
|
|
6
|
+
// context is accepted for signature consistency with the other fetchers (so
|
|
7
|
+
// callers can always pass (context, token, ...)), though forms needs no
|
|
8
|
+
// context fields.
|
|
9
|
+
export async function getForms(context, token) {
|
|
10
|
+
const url = buildHubSpotUrl("marketing/v3/forms");
|
|
11
|
+
return callHubSpotApi(url, token);
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// HubSpot contact-list fetcher (for list-selection pickers).
|
|
2
|
+
//
|
|
3
|
+
// Paginates crm/v3/lists/search until all DYNAMIC lists are collected. The POST
|
|
4
|
+
// body is a plain OBJECT — callHubSpotApi sends it as-is (NEVER JSON.stringify).
|
|
5
|
+
import { buildHubSpotUrl, callHubSpotApi } from "./base";
|
|
6
|
+
|
|
7
|
+
// context is accepted for signature consistency. query: optional name filter.
|
|
8
|
+
export async function getLists(context, token, query = "") {
|
|
9
|
+
const url = buildHubSpotUrl("crm/v3/lists/search");
|
|
10
|
+
const allLists = [];
|
|
11
|
+
let offset = 0;
|
|
12
|
+
let hasMore = true;
|
|
13
|
+
const count = 100; // Max count per request.
|
|
14
|
+
|
|
15
|
+
while (hasMore) {
|
|
16
|
+
const payload = {
|
|
17
|
+
query: query,
|
|
18
|
+
count: count,
|
|
19
|
+
offset: offset,
|
|
20
|
+
processingTypes: ["DYNAMIC"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const response = await callHubSpotApi(url, token, "POST", payload);
|
|
24
|
+
allLists.push(...response.lists);
|
|
25
|
+
|
|
26
|
+
hasMore = response.hasMore;
|
|
27
|
+
offset = response.offset;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { lists: allLists };
|
|
31
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// HubSpot pipeline + stage fetchers and a stage-diffing helper.
|
|
2
|
+
//
|
|
3
|
+
// Goes through callHubSpotApi → hubspot.fetch rules apply.
|
|
4
|
+
//
|
|
5
|
+
// Generalization note: the source hardcoded the pipeline object type as
|
|
6
|
+
// `a${context.extension.appId}_application` (an app-specific custom object).
|
|
7
|
+
// Here the object type is a parameter that DEFAULTS to that convention, so
|
|
8
|
+
// existing apps keep working while others can pass their own object type.
|
|
9
|
+
import { buildHubSpotUrl, callHubSpotApi } from "./base";
|
|
10
|
+
|
|
11
|
+
// Default pipeline object type: the app's "<app>_application" custom object,
|
|
12
|
+
// derived from context.extension.appId (the historical convention).
|
|
13
|
+
function defaultPipelineObjectType(context) {
|
|
14
|
+
return `a${context.extension.appId}_application`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getPipelines(context, token, objectType = defaultPipelineObjectType(context)) {
|
|
18
|
+
const url = buildHubSpotUrl(`crm/v3/pipelines/${objectType}`);
|
|
19
|
+
return callHubSpotApi(url, token);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function getPipelineStages(
|
|
23
|
+
context,
|
|
24
|
+
token,
|
|
25
|
+
pipelineId,
|
|
26
|
+
objectType = defaultPipelineObjectType(context)
|
|
27
|
+
) {
|
|
28
|
+
const url = buildHubSpotUrl(`crm/v3/pipelines/${objectType}/${pipelineId}/stages`);
|
|
29
|
+
return callHubSpotApi(url, token);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Diff the app's stored stages against HubSpot's current pipeline stages and
|
|
33
|
+
// describe the changes (init / update / null when nothing changed).
|
|
34
|
+
//
|
|
35
|
+
// `makeNewStage` builds the record for a stage that exists in HubSpot but not in
|
|
36
|
+
// the app's stored config. It defaults to { stage_id, name } only; apps that
|
|
37
|
+
// attach extra per-stage data (e.g. a children array) can pass their own factory.
|
|
38
|
+
export function computeStageChanges(
|
|
39
|
+
currentStages,
|
|
40
|
+
newPipelineStages,
|
|
41
|
+
makeNewStage = (stage) => ({ stage_id: stage.id, name: stage.label })
|
|
42
|
+
) {
|
|
43
|
+
if (!currentStages) {
|
|
44
|
+
return {
|
|
45
|
+
type: "init",
|
|
46
|
+
changes: newPipelineStages.map(makeNewStage),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const existingStagesMap = new Map(
|
|
51
|
+
currentStages.map((stage) => [stage.stage_id, stage])
|
|
52
|
+
);
|
|
53
|
+
const pipelineStagesMap = new Map(
|
|
54
|
+
newPipelineStages.map((stage) => [stage.id, stage])
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const stagesToRemove = currentStages.filter(
|
|
58
|
+
(stage) => !pipelineStagesMap.has(stage.stage_id)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const stagesToAdd = [];
|
|
62
|
+
const stagesToUpdate = [];
|
|
63
|
+
let hasChanges = false;
|
|
64
|
+
|
|
65
|
+
newPipelineStages.forEach((pipelineStage) => {
|
|
66
|
+
const existingStage = existingStagesMap.get(pipelineStage.id);
|
|
67
|
+
|
|
68
|
+
if (existingStage) {
|
|
69
|
+
if (existingStage.name !== pipelineStage.label) {
|
|
70
|
+
stagesToUpdate.push({
|
|
71
|
+
...existingStage,
|
|
72
|
+
name: pipelineStage.label,
|
|
73
|
+
oldName: existingStage.name,
|
|
74
|
+
});
|
|
75
|
+
hasChanges = true;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
stagesToAdd.push(makeNewStage(pipelineStage));
|
|
79
|
+
hasChanges = true;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (stagesToRemove.length > 0 || hasChanges) {
|
|
84
|
+
return {
|
|
85
|
+
type: "update",
|
|
86
|
+
stagesToRemove,
|
|
87
|
+
stagesToAdd,
|
|
88
|
+
stagesToUpdate,
|
|
89
|
+
finalStages: newPipelineStages.map((pipelineStage) => {
|
|
90
|
+
const existingStage = existingStagesMap.get(pipelineStage.id);
|
|
91
|
+
if (existingStage) {
|
|
92
|
+
return {
|
|
93
|
+
...existingStage,
|
|
94
|
+
name: pipelineStage.label,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return makeNewStage(pipelineStage);
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// HubSpot property fetchers/writers for field-mapping pickers.
|
|
2
|
+
//
|
|
3
|
+
// All calls go through callHubSpotApi with a short-lived token → hubspot.fetch
|
|
4
|
+
// rules apply (object body, Authorization-only header). The PATCH body is passed
|
|
5
|
+
// as a plain object (NOT JSON.stringify'd).
|
|
6
|
+
import { buildHubSpotUrl, callHubSpotApi } from "./base";
|
|
7
|
+
|
|
8
|
+
// List the writable properties for a HubSpot object type, shaped for a mapping
|
|
9
|
+
// picker: [{ name, label, type }] sorted by label. Excludes hidden, archived,
|
|
10
|
+
// calculated, and read-only-value properties (a custom mapping can't write to a
|
|
11
|
+
// calculated/read-only property).
|
|
12
|
+
//
|
|
13
|
+
// objectType: the HubSpot CRM object type used in the API path, e.g. "contacts",
|
|
14
|
+
// "orders", "line_items", "products", or a custom object's fully-qualified id.
|
|
15
|
+
export async function getObjectProperties(context, token, objectType) {
|
|
16
|
+
if (!objectType) return [];
|
|
17
|
+
const url = buildHubSpotUrl(`crm/v3/properties/${objectType}`);
|
|
18
|
+
const response = await callHubSpotApi(url, token);
|
|
19
|
+
return (response.results || [])
|
|
20
|
+
.filter(
|
|
21
|
+
(prop) =>
|
|
22
|
+
!prop.hidden &&
|
|
23
|
+
!prop.archived &&
|
|
24
|
+
!prop.calculated &&
|
|
25
|
+
!prop.modificationMetadata?.readOnlyValue
|
|
26
|
+
)
|
|
27
|
+
.map((prop) => ({
|
|
28
|
+
name: prop.name,
|
|
29
|
+
label: prop.label || prop.name,
|
|
30
|
+
type: prop.type,
|
|
31
|
+
}))
|
|
32
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Raw property list for an object type (unfiltered) — when you need the full
|
|
36
|
+
// HubSpot response rather than the picker-shaped subset.
|
|
37
|
+
export async function getProperties(context, token, objectType) {
|
|
38
|
+
const url = buildHubSpotUrl(`crm/v3/properties/${objectType}`);
|
|
39
|
+
return callHubSpotApi(url, token, "GET");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// PATCH properties onto the current CRM record (CRM-card context only — relies
|
|
43
|
+
// on context.crm.objectTypeId / context.crm.objectId).
|
|
44
|
+
export async function updateProperties(context, token, properties) {
|
|
45
|
+
const url = buildHubSpotUrl(
|
|
46
|
+
`crm/v3/objects/${context.crm.objectTypeId}/${context.crm.objectId}`
|
|
47
|
+
);
|
|
48
|
+
// properties is wrapped in a plain object — callHubSpotApi sends it as-is.
|
|
49
|
+
return callHubSpotApi(url, token, "PATCH", { properties });
|
|
50
|
+
}
|
package/src/sdk/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Growth Ops HubSpot app SDK — the token + API utility layer every token-using
|
|
2
|
+
// app reimplements, generalized to be app-agnostic. See README "SDK" section.
|
|
3
|
+
|
|
4
|
+
// App backend (context.variables.BASE_URL) via hubspot.fetch.
|
|
5
|
+
export { callAppApi, AppApiError } from "./app/base";
|
|
6
|
+
export { getAlerts, dismissAlert } from "./app/alerts";
|
|
7
|
+
export { getSettings, updateSettings, makeSettingsApi } from "./app/settings";
|
|
8
|
+
export {
|
|
9
|
+
strToBase64,
|
|
10
|
+
base64ToStr,
|
|
11
|
+
getKeyFromContext,
|
|
12
|
+
encrypt,
|
|
13
|
+
decrypt,
|
|
14
|
+
} from "./app/encryption";
|
|
15
|
+
|
|
16
|
+
// HubSpot public API (api.hubapi.com) via a short-lived OAuth token.
|
|
17
|
+
export { callHubSpotApi, buildHubSpotUrl, HubSpotApiError } from "./hubspot/base";
|
|
18
|
+
export {
|
|
19
|
+
getObjectProperties,
|
|
20
|
+
getProperties,
|
|
21
|
+
updateProperties,
|
|
22
|
+
} from "./hubspot/properties";
|
|
23
|
+
export { getForms } from "./hubspot/forms";
|
|
24
|
+
export { getLists } from "./hubspot/lists";
|
|
25
|
+
export {
|
|
26
|
+
getPipelines,
|
|
27
|
+
getPipelineStages,
|
|
28
|
+
computeStageChanges,
|
|
29
|
+
} from "./hubspot/pipeline";
|
|
30
|
+
|
|
31
|
+
// Token lifecycle.
|
|
32
|
+
export { getShortLivedToken, isTokenExpired } from "./token/getShortLivedToken";
|
|
33
|
+
export { TokenProvider, useToken, TokenContext } from "./token/TokenContext";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// TokenContext / TokenProvider / useToken — short-lived HubSpot token lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// Wrap a tree in <TokenProvider context={context}> and any descendant can call
|
|
4
|
+
// useToken().ensureValidToken() to get a valid short-lived OAuth token, fetching
|
|
5
|
+
// a fresh one only when the cached one is missing or expired. The token data is
|
|
6
|
+
// held at module level (not React state) so it survives remounts and never
|
|
7
|
+
// triggers re-renders.
|
|
8
|
+
//
|
|
9
|
+
// The fetch goes through callAppApi (GET /api/v1/tokens/short-lived?portalId=...),
|
|
10
|
+
// so the hubspot.fetch rules live in app/base.js. The backend returns a token
|
|
11
|
+
// object exposing the access token as `token` (alias) / `access_token`, plus an
|
|
12
|
+
// `expires_at` epoch-seconds field used for cache invalidation.
|
|
13
|
+
import { createContext, useContext } from "react";
|
|
14
|
+
import { callAppApi } from "../app/base";
|
|
15
|
+
import { isTokenExpired } from "./getShortLivedToken";
|
|
16
|
+
|
|
17
|
+
const TokenContext = createContext(null);
|
|
18
|
+
|
|
19
|
+
// Module-level cache — doesn't cause re-renders, survives component remounts.
|
|
20
|
+
let cachedTokenData = null;
|
|
21
|
+
let tokenPromise = null;
|
|
22
|
+
let appContext = null;
|
|
23
|
+
|
|
24
|
+
// Fetch the full short-lived token object (token string + expires_at) for the
|
|
25
|
+
// current portal.
|
|
26
|
+
async function fetchTokenData(context) {
|
|
27
|
+
const portalId = context?.portal?.id;
|
|
28
|
+
const query = portalId ? `?portalId=${portalId}` : "";
|
|
29
|
+
return callAppApi(context, `/api/v1/tokens/short-lived${query}`, "GET");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Return a valid access token string, fetching a fresh one if the cache is empty
|
|
33
|
+
// or expired. Concurrent callers share one in-flight fetch.
|
|
34
|
+
const ensureValidToken = async () => {
|
|
35
|
+
if (cachedTokenData && !isTokenExpired(cachedTokenData)) {
|
|
36
|
+
return cachedTokenData.token || cachedTokenData.access_token;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (tokenPromise) return tokenPromise;
|
|
40
|
+
|
|
41
|
+
tokenPromise = (async () => {
|
|
42
|
+
try {
|
|
43
|
+
const freshTokenData = await fetchTokenData(appContext);
|
|
44
|
+
cachedTokenData = freshTokenData;
|
|
45
|
+
tokenPromise = null;
|
|
46
|
+
return freshTokenData.token || freshTokenData.access_token;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
tokenPromise = null;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
return tokenPromise;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Force a refresh on the next ensureValidToken() call.
|
|
57
|
+
const invalidateToken = () => {
|
|
58
|
+
cachedTokenData = null;
|
|
59
|
+
tokenPromise = null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Stable context value — never changes identity, so it won't re-render consumers.
|
|
63
|
+
const stableValue = {
|
|
64
|
+
ensureValidToken,
|
|
65
|
+
invalidateToken,
|
|
66
|
+
token: null,
|
|
67
|
+
loading: false,
|
|
68
|
+
error: null,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function useToken() {
|
|
72
|
+
const context = useContext(TokenContext);
|
|
73
|
+
if (!context) {
|
|
74
|
+
throw new Error("useToken must be used within a TokenProvider");
|
|
75
|
+
}
|
|
76
|
+
return context;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function TokenProvider({ children, context }) {
|
|
80
|
+
// Stash the extension context at module level so the token functions (which
|
|
81
|
+
// live outside React) can reach it.
|
|
82
|
+
appContext = context;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<TokenContext.Provider value={stableValue}>{children}</TokenContext.Provider>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { TokenContext };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// getShortLivedToken — fetch a short-lived HubSpot OAuth token from the app
|
|
2
|
+
// backend for the current portal.
|
|
3
|
+
//
|
|
4
|
+
// Hits GET /api/v1/tokens/short-lived?portalId=... via callAppApi (so the
|
|
5
|
+
// hubspot.fetch rules are handled there). Returns the bare token string; the
|
|
6
|
+
// backend's Token model exposes the access token as `token` (aliased), with
|
|
7
|
+
// access_token as a fallback for safety.
|
|
8
|
+
import { callAppApi } from "../app/base";
|
|
9
|
+
|
|
10
|
+
export async function getShortLivedToken(context) {
|
|
11
|
+
const portalId = context?.portal?.id;
|
|
12
|
+
const query = portalId ? `?portalId=${portalId}` : "";
|
|
13
|
+
const result = await callAppApi(
|
|
14
|
+
context,
|
|
15
|
+
`/api/v1/tokens/short-lived${query}`,
|
|
16
|
+
"GET"
|
|
17
|
+
);
|
|
18
|
+
return result.token || result.access_token;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Is a token-data object (with an `expires_at` epoch-seconds field) past expiry?
|
|
22
|
+
export function isTokenExpired(tokenData) {
|
|
23
|
+
return Date.now() / 1000 > tokenData.expires_at;
|
|
24
|
+
}
|