@useparagon/connect 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.
@@ -0,0 +1,1065 @@
1
+ import jwtDecode from 'jwt-decode';
2
+
3
+ import { VisibleConnectAction } from './types/action';
4
+ import ActionCatalogConfigs from './helpers/actionCatalog';
5
+ import { IPersona, PersonaMeta } from './entities/persona.interface';
6
+ import {
7
+ CredentialStatus,
8
+ IConnectCredential,
9
+ } from './entities/connectCredential.interface';
10
+ import { ICredential } from './entities/credential.interface';
11
+ import {
12
+ IConnectIntegrationWithCredentialInfo,
13
+ IIntegrationMetadata,
14
+ getIntegrationTypeName,
15
+ isCustomIntegrationTypeName,
16
+ } from './entities/integration.interface'
17
+ import { IConnectSDKProject } from './entities/project.interface';
18
+ import { Action, overrideActionAlias } from './types/action';
19
+ import {
20
+ AuthenticatedConnectUser,
21
+ IConnectUser,
22
+ INFER_CONTENT_TYPE_FROM_CONNECT_OPTIONS,
23
+ ModalConfig,
24
+ } from './types/connect';
25
+ import { ConnectAddOn } from './types/stripe';
26
+ import { getAssetUrl } from './utils/connect';
27
+ import { hash } from './utils/crypto';
28
+ import { isValidUrl, sanitizeUrl } from './utils/http';
29
+ import { CacheThrottle } from './utils/throttle';
30
+
31
+ import { Props as ConnectModalProps } from './types/connectModal';
32
+ import { IConnectUserContext } from './helpers/ConnectUserContext';
33
+ import { shouldShowPreAuthInputs, startOAuthFlow } from './helpers/oauth';
34
+ import SDKEventEmitter from './SDKEventEmitter';
35
+ import { ConnectSdkEnvironments } from './server.types';
36
+ import {
37
+ AuthenticateOptions,
38
+ ConnectParams,
39
+ ConnectUser,
40
+ DocumentLoadingState,
41
+ EventInfo,
42
+ FunctionPropertyNames,
43
+ IConnectSDK,
44
+ InstallOptions,
45
+ IntegrationInstallEvent,
46
+ SDKFunctionErrorMessage,
47
+ SDKFunctionInvocationMessage,
48
+ SDKFunctionResponseMessage,
49
+ SDKReceivedMessage,
50
+ SDK_EVENT,
51
+ TriggerWorkflowRequest,
52
+ UIUpdateMessage,
53
+ UnwrapPromise,
54
+ UserProvidedIntegrationConfig,
55
+ } from './types/sdk';
56
+
57
+ const PARAGON_ROOT_IFRAME_ID = 'paragon-connect-frame';
58
+ const PARAGON_ROOT_IFRAME_CONTAINER_ID = `${PARAGON_ROOT_IFRAME_ID}-container`;
59
+ const PARAGON_STATE_STORAGE_KEY = 'paragon-connect-user-state';
60
+ export const PARAGON_OVERFLOW_EMPTY_VALUE = 'PARAGON_OVERFLOW_EMPTY_VALUE';
61
+
62
+ export default class ConnectSDK extends SDKEventEmitter implements IConnectSDK {
63
+ root: HTMLIFrameElement | undefined;
64
+ private rootLoaded: boolean = false;
65
+ private projectId: string | undefined;
66
+ private modalState: ConnectModalProps = {
67
+ integration: null,
68
+ onClose: this.onClose.bind(this),
69
+ overlayStyle: { overflow: 'auto' },
70
+ onOpen: this.onOpen.bind(this),
71
+ apiInstallationOptions: {
72
+ isApiInstallation: false,
73
+ showPortalAfterInstall: false,
74
+ },
75
+ };
76
+ private userState: ConnectUser = {
77
+ authenticated: false,
78
+ };
79
+ private loadedConfigs: { [type: string]: ModalConfig } = {};
80
+ private loadedIntegrations: { [type: string]: IConnectIntegrationWithCredentialInfo } = {};
81
+ private endUserIntegrationConfig: { [type: string]: UserProvidedIntegrationConfig } = {};
82
+ private environments: ConnectSdkEnvironments;
83
+ private project: IConnectSDKProject;
84
+
85
+ /**
86
+ * map b/w integration key name to integration metadata
87
+ */
88
+ private metadata: Record<string, IIntegrationMetadata> = {};
89
+
90
+ /**
91
+ * cache service
92
+ */
93
+ private cachedApiResponse: CacheThrottle = new CacheThrottle({ ttl: 1000 * 5 });
94
+
95
+ /**
96
+ * in order to not call multiple same GET request at same time
97
+ * we are storing {key -> promise} map
98
+ */
99
+ private keyToRequestPromiseMap: Record<string, Promise<unknown>> = {};
100
+
101
+ /**
102
+ * this will store the original overflow style of body
103
+ * we are using PARAGON_OVERFLOW_EMPTY_VALUE as default
104
+ * so that we will be able to know whether style is added in body or not
105
+ */
106
+ private originalBodyOverflow: string | null = PARAGON_OVERFLOW_EMPTY_VALUE;
107
+
108
+ constructor(environments?: ConnectSdkEnvironments) {
109
+ super();
110
+
111
+ const assetURL: string = getAssetUrl({
112
+ CDN_PUBLIC_URL: process.env.CDN_PUBLIC_URL || '',
113
+ DASHBOARD_PUBLIC_URL: process.env.DASHBOARD_PUBLIC_URL || '',
114
+ NODE_ENV: process.env.NODE_ENV || '',
115
+ PLATFORM_ENV: process.env.PLATFORM_ENV || '',
116
+ VERSION: process.env.VERSION || '',
117
+ });
118
+ this.environments = {
119
+ CDN_PUBLIC_URL: assetURL,
120
+ CONNECT_PUBLIC_URL: process.env.CONNECT_PUBLIC_URL as string,
121
+ DASHBOARD_PUBLIC_URL: process.env.DASHBOARD_PUBLIC_URL as string,
122
+ NODE_ENV: process.env.NODE_ENV as string,
123
+ PASSPORT_PUBLIC_URL: process.env.PASSPORT_PUBLIC_URL as string,
124
+ PLATFORM_ENV: process.env.PLATFORM_ENV as string,
125
+ VERSION: process.env.VERSION as string,
126
+ ZEUS_PUBLIC_URL: process.env.ZEUS_PUBLIC_URL as string,
127
+ ...environments,
128
+ };
129
+
130
+ if (!this.environments.CONNECT_PUBLIC_URL) {
131
+ throw new Error('Paragon SDK error! No `CONNECT_PUBLIC_URL` configured.');
132
+ } else if (!this.environments.DASHBOARD_PUBLIC_URL) {
133
+ throw new Error('Paragon SDK error! No `DASHBOARD_PUBLIC_URL` configured.');
134
+ } else if (!this.environments.PASSPORT_PUBLIC_URL) {
135
+ throw new Error('Paragon SDK error! No `PASSPORT_PUBLIC_URL` configured.');
136
+ } else if (!this.environments.ZEUS_PUBLIC_URL) {
137
+ throw new Error('Paragon SDK error! No `ZEUS_PUBLIC_URL` configured.');
138
+ }
139
+
140
+ this.loadState();
141
+
142
+ window.addEventListener('message', this.eventMessageHandler.bind(this));
143
+
144
+ if (window.document.readyState === DocumentLoadingState.LOADING) {
145
+ // still loading, wait for the event
146
+ window.document.addEventListener('DOMContentLoaded', () => {
147
+ this.createReactRoot();
148
+ });
149
+ } else {
150
+ // DOM is ready!
151
+ this.createReactRoot();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * post message handler
157
+ * @param event
158
+ */
159
+ private async eventMessageHandler(event: MessageEvent): Promise<void> {
160
+ switch (event.data.type) {
161
+ case 'oauth_success_callback':
162
+ await this._oauthCallback(event.data.credential);
163
+ break;
164
+ case 'oauth_error_callback':
165
+ await this._oauthErrorCallback(event.data.error);
166
+ break;
167
+ default:
168
+ await this.functionInvocationHandler(event);
169
+ }
170
+ }
171
+
172
+ private async functionInvocationHandler<T extends FunctionPropertyNames<ConnectSDK>>(
173
+ event: MessageEvent,
174
+ ): Promise<void> {
175
+ if ((event.data as SDKReceivedMessage).messageType !== 'SDK_FUNCTION_INVOCATION') {
176
+ return;
177
+ }
178
+ const { type, id, parameters } = event.data as SDKFunctionInvocationMessage<T>;
179
+ let reply: SDKFunctionResponseMessage<T> | SDKFunctionErrorMessage | undefined;
180
+
181
+ try {
182
+ // @ts-ignore The spread isn't recognized here as valid args
183
+ const result = (await this[type](...parameters)) as UnwrapPromise<ReturnType<ConnectSDK[T]>>;
184
+ reply = { messageType: 'SDK_FUNCTION_RESPONSE', id, type, result };
185
+ } catch (err) {
186
+ reply = { messageType: 'SDK_FUNCTION_ERROR', error: true, message: err.message, id };
187
+ }
188
+
189
+ (event.source as Window).postMessage(reply, this.environments.CONNECT_PUBLIC_URL);
190
+ }
191
+
192
+ private createReactRoot(): void {
193
+ this.root = document.createElement('iframe');
194
+ this.root.onload = () => {
195
+ this.rootLoaded = true;
196
+ // when root is loaded then render component
197
+ this.render();
198
+ };
199
+
200
+ const isAtRoot: boolean = document.querySelector(`#${PARAGON_ROOT_IFRAME_CONTAINER_ID}`)
201
+ ? true
202
+ : false;
203
+ this.root.id = PARAGON_ROOT_IFRAME_ID;
204
+ this.root.src = `${this.environments.CONNECT_PUBLIC_URL}/ui${
205
+ this.projectId ? `?projectId=${this.projectId}` : ''
206
+ }`;
207
+ this.root.style.position = isAtRoot ? 'absolute' : 'fixed';
208
+ this.root.style.top = '0';
209
+ this.root.style.left = '0';
210
+ this.root.style.width = isAtRoot ? '100%' : '100vw';
211
+ this.root.style.height = isAtRoot ? '100%' : '100vh';
212
+ this.root.style.zIndex = '2147483647';
213
+ this.root.style.display = 'none';
214
+
215
+ (document.querySelector(`#${PARAGON_ROOT_IFRAME_CONTAINER_ID}`) || document.body)?.appendChild(
216
+ this.root,
217
+ );
218
+ }
219
+
220
+ private validateAction(action: Action, validateIsEnabled: boolean = false): boolean {
221
+ if (!Object.values(Action).includes(action) && !isCustomIntegrationTypeName(action)) {
222
+ throw new Error(
223
+ `"${action}" is not a valid integration type. The integrations you have configured for this Paragon project are:
224
+
225
+ ${Object.keys(this.loadedConfigs)
226
+ .map((validAction: string) => `- "${validAction}"`)
227
+ .join('\n')}
228
+ `,
229
+ );
230
+ }
231
+
232
+ if (!this.loadedConfigs[action]) {
233
+ throw new Error(
234
+ `paragon.connect() was called with "${action}", but you do not have this integration set up in your Paragon project yet.
235
+
236
+ ${Object.keys(this.loadedConfigs)
237
+ .map((validAction: string) => `- "${validAction}"`)
238
+ .join('\n')}`,
239
+ );
240
+ }
241
+
242
+ if (!this.loadedIntegrations[action]?.isActive) {
243
+ throw new Error(
244
+ `paragon.connect() was called with "${action}", but this integration is not active in your Paragon project yet.`,
245
+ );
246
+ }
247
+
248
+ if (validateIsEnabled && this.userState.authenticated) {
249
+ return Boolean(this.userState.integrations[action]?.enabled);
250
+ }
251
+
252
+ return true;
253
+ }
254
+
255
+ private async bootstrapSDKState(userMeta?: PersonaMeta): Promise<void> {
256
+ if (!this.projectId || !this.userState.authenticated) {
257
+ return;
258
+ }
259
+
260
+ this.project = (await this.sendConnectRequest<IConnectSDKProject>(
261
+ `/sdk/projects/${this.projectId}`,
262
+ {
263
+ headers: {
264
+ 'X-Paragon-Cache-Metadata': '1',
265
+ ...(userMeta
266
+ ? {
267
+ 'X-Paragon-User-Metadata': JSON.stringify(userMeta),
268
+ }
269
+ : {}),
270
+ },
271
+ },
272
+ false,
273
+ )) as IConnectSDKProject;
274
+
275
+ await this.updateLocalState();
276
+ }
277
+
278
+ setModalState(statePartial: Partial<ConnectModalProps>): void {
279
+ this.modalState = {
280
+ config: undefined,
281
+ ...this.modalState,
282
+ ...statePartial,
283
+ };
284
+ this.render();
285
+ }
286
+
287
+ /**
288
+ * Loads previous SDK state saved in localStorage.
289
+ *
290
+ * **Warning:** This favors stored to current state. This should typically be called in
291
+ * unauthenticated contexts, as a fallback.
292
+ */
293
+ private loadState(): void {
294
+ if (typeof window !== 'undefined') {
295
+ try {
296
+ const localState: {
297
+ userState: Record<string, unknown> | undefined;
298
+ projectId: string | undefined;
299
+ } = window.localStorage.getItem(PARAGON_STATE_STORAGE_KEY)
300
+ ? JSON.parse(window.localStorage.getItem(PARAGON_STATE_STORAGE_KEY) as string)
301
+ : {};
302
+
303
+ if (localState.projectId) {
304
+ this.projectId = localState.projectId;
305
+ }
306
+ if (localState.userState) {
307
+ const { userState } = localState;
308
+ if ('authenticated' in userState && userState.authenticated && 'token' in userState) {
309
+ this.updateAuthenticatedUser(userState);
310
+ // Refresh user state automatically, if available
311
+ this.bootstrapSDKState().catch(() => {
312
+ // PARA-3267
313
+ this.clearState();
314
+ });
315
+ } else {
316
+ throw new Error(
317
+ 'Malformatted or unauthenticated user was persisted into localStorage. Refusing to load.',
318
+ );
319
+ }
320
+ }
321
+ } catch {
322
+ this.clearState();
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Saves the current SDK state into localStorage, if available.
329
+ */
330
+ private saveState(): void {
331
+ if (typeof window !== 'undefined' && this.userState.authenticated) {
332
+ window.localStorage.setItem(
333
+ PARAGON_STATE_STORAGE_KEY,
334
+ JSON.stringify({ projectId: this.projectId, userState: this.userState }),
335
+ );
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Clears the current SDK state from localStorage, if available.
341
+ */
342
+ private clearState(): void {
343
+ if (typeof window !== 'undefined') {
344
+ window.localStorage.removeItem(PARAGON_STATE_STORAGE_KEY);
345
+ }
346
+ }
347
+
348
+ private render(): void {
349
+ if (this.root && this.rootLoaded) {
350
+ const { onClose, onOpen, ...modalState } = this.modalState;
351
+ const update: UIUpdateMessage = {
352
+ messageType: 'UI_UPDATE',
353
+ nextContext: {
354
+ user: this.userState,
355
+ projectId: this.projectId,
356
+ environments: this.environments,
357
+ project: this.project,
358
+ endUserIntegrationConfig: this.modalState.integration
359
+ ? this.endUserIntegrationConfig[this.modalState.integration.type]
360
+ : undefined,
361
+ },
362
+ nextModalState: modalState,
363
+ };
364
+
365
+ if (!this.root.contentWindow) {
366
+ throw new Error('Browser not supported');
367
+ }
368
+ this.root.contentWindow.postMessage(update, this.environments.CONNECT_PUBLIC_URL);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * this will update container style
374
+ * @param param0
375
+ * @returns
376
+ */
377
+ updateContainerStyle({ isModalShown }: { isModalShown: boolean }) {
378
+ if (!this.root) {
379
+ return;
380
+ }
381
+ if (isModalShown) {
382
+ this.root.style.display = 'block';
383
+ } else {
384
+ this.root.style.display = 'none';
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Authenticate your end user into the Paragon Connect SDK.
390
+ *
391
+ * @param projectId Your Paragon project ID.
392
+ * @param token A JWT signed by your App Server. The JWT should include a user ID and a
393
+ * session expiration time.
394
+ */
395
+ async authenticate(
396
+ projectId: string,
397
+ token: string,
398
+ options?: AuthenticateOptions,
399
+ ): Promise<void> {
400
+ if (!projectId || !token) {
401
+ throw new Error('projectId or token not specified to paragon.authenticate()');
402
+ }
403
+ let decodedJwt: { sub?: string; id?: string } = {};
404
+ try {
405
+ decodedJwt = jwtDecode<typeof decodedJwt>(token);
406
+ } catch (err) {
407
+ throw new Error('A well-formed JWT was not provided to paragon.authenticate()');
408
+ }
409
+
410
+ this.projectId = projectId;
411
+ this.userState = {
412
+ authenticated: true,
413
+ token,
414
+ userId: (decodedJwt.sub || decodedJwt.id) as string,
415
+ integrations: {},
416
+ meta: options?.metadata ?? {},
417
+ };
418
+ try {
419
+ await this.bootstrapSDKState(options?.metadata);
420
+ } catch (err) {
421
+ console.warn('paragon.authenticate() could not login user', err);
422
+ this.logout();
423
+ throw new Error(`Failed to authenticate user with Paragon: ${err.message}`);
424
+ }
425
+ this.render();
426
+ }
427
+
428
+ /**
429
+ * Get the Paragon authentication and integration state of your end user.
430
+ */
431
+ getUser(): ConnectUser {
432
+ return Object.fromEntries(
433
+ Object.entries(this.userState).filter(([key]: [string, unknown]) => key !== 'token'),
434
+ ) as ConnectUser;
435
+ }
436
+
437
+ updateAuthenticatedUser(statePartial: Partial<AuthenticatedConnectUser>): void {
438
+ if (this.userState.authenticated) {
439
+ this.userState = { ...this.userState, ...statePartial };
440
+ } else if (statePartial.authenticated) {
441
+ this.userState = statePartial as AuthenticatedConnectUser;
442
+ }
443
+ this.render();
444
+ }
445
+
446
+ /**
447
+ * Logout the currently authenticated end user from the Paragon SDK.
448
+ */
449
+ logout(): void {
450
+ this.userState = { authenticated: false };
451
+ this.clearState();
452
+ this.render();
453
+ }
454
+
455
+ /**
456
+ * Display the Paragon Connect modal
457
+ */
458
+ async connect(
459
+ action: Action,
460
+ params: ConnectParams = { showPortalAfterInstall: true, isApiInstallation: false },
461
+ ): Promise<void> {
462
+ const { mapObjectFields, overrideRedirectUrl, ...callbacks } = params;
463
+
464
+ return new Promise((resolve: () => void, reject: (err: Error) => void) => {
465
+ this.subscribeToIntegration(action, {
466
+ ...callbacks,
467
+ onInstall: (event: IntegrationInstallEvent, user: AuthenticatedConnectUser) => {
468
+ resolve();
469
+ callbacks?.onInstall
470
+ ? callbacks.onInstall(event, user)
471
+ : callbacks?.onSuccess?.(event, user);
472
+ },
473
+ onError: (err: Error) => {
474
+ reject(err);
475
+ callbacks?.onError?.(err);
476
+ },
477
+ });
478
+
479
+ try {
480
+ if (
481
+ mapObjectFields &&
482
+ !this.project.accessibleFeatures.includes(ConnectAddOn.DynamicFieldMapper)
483
+ ) {
484
+ throw new Error(
485
+ `Dynamic Field Mapping is available on our Enterprise plan. Contact sales@useparagon.com to learn more or upgrade.`,
486
+ );
487
+ }
488
+
489
+ this.validateAction(action);
490
+
491
+ if (overrideRedirectUrl && !isValidUrl(overrideRedirectUrl)) {
492
+ throw new Error(`${overrideRedirectUrl} is not valid url.`);
493
+ }
494
+ this.endUserIntegrationConfig[action] = {
495
+ mapObjectFields,
496
+ overrideRedirectUrl: overrideRedirectUrl ? sanitizeUrl(overrideRedirectUrl) : undefined,
497
+ };
498
+ const integration: IConnectIntegrationWithCredentialInfo = Object.values(
499
+ this.loadedIntegrations,
500
+ ).find(
501
+ (integration: IConnectIntegrationWithCredentialInfo) =>
502
+ getIntegrationTypeName(integration) === action,
503
+ ) as IConnectIntegrationWithCredentialInfo;
504
+
505
+ /**
506
+ * in chrome getting issue for asynchronous popup opening
507
+ * see PARA-1505
508
+ */
509
+ if (params.isApiInstallation && !shouldShowPreAuthInputs(integration)) {
510
+ void startOAuthFlow({
511
+ context: {
512
+ user: this.userState,
513
+ projectId: this.projectId,
514
+ environments: this.environments,
515
+ endUserIntegrationConfig: this.modalState.integration
516
+ ? this.endUserIntegrationConfig[this.modalState.integration.type]
517
+ : undefined,
518
+ } as IConnectUserContext,
519
+ integration,
520
+ });
521
+ }
522
+
523
+ this.setModalState({
524
+ integration,
525
+ config: this.loadedConfigs[action],
526
+ apiInstallationOptions: {
527
+ isApiInstallation: params.isApiInstallation,
528
+ showPortalAfterInstall: params.showPortalAfterInstall,
529
+ },
530
+ });
531
+ } catch (err) {
532
+ this.emitError(err, action);
533
+ }
534
+ });
535
+ }
536
+
537
+ async _oauthCallback(credential: ICredential, credentialId?: string): Promise<void> {
538
+ const { integration } = this.modalState;
539
+ if (!integration) {
540
+ return;
541
+ }
542
+ const type = getIntegrationTypeName(integration) as string;
543
+ try {
544
+ const user = this.userState as AuthenticatedConnectUser;
545
+ const requestURI = credentialId
546
+ ? `/sdk/credentials/${credentialId}/complete-setup`
547
+ : `/sdk/credentials`;
548
+ const oauthResponse = await this.sendConnectRequest<IConnectCredential>(requestURI, {
549
+ method: 'POST',
550
+ body: JSON.stringify({
551
+ integrationId: integration.id,
552
+ config: {},
553
+ payload: credential,
554
+ }),
555
+ });
556
+
557
+ if (!oauthResponse) {
558
+ throw new Error('Unable to save oauth credentials');
559
+ }
560
+
561
+ const { id, config, providerId, status, providerData } = oauthResponse;
562
+ this.updateAuthenticatedUser({
563
+ integrations: {
564
+ ...user.integrations,
565
+ [type]: {
566
+ ...user.integrations[type],
567
+ ...config,
568
+ credentialStatus: status,
569
+ enabled: status === CredentialStatus.VALID,
570
+ credentialId: id,
571
+ providerId,
572
+ providerData,
573
+ },
574
+ },
575
+ });
576
+
577
+ if (status === CredentialStatus.VALID) {
578
+ this.triggerSDKEvent({
579
+ type: SDK_EVENT.ON_INTEGRATION_INSTALL,
580
+ integrationId: integration.id,
581
+ integrationType: type,
582
+ });
583
+ }
584
+ } catch (err) {
585
+ this.emitError(err, type);
586
+ await this._oauthErrorCallback(err.message);
587
+ }
588
+ }
589
+
590
+ // tslint:disable-next-line: function-name
591
+ async _oauthErrorCallback(errorMessage: string | object): Promise<void> {
592
+ let message: string | object = errorMessage;
593
+ //also log error in console
594
+ console.error('Failed to connect account to Paragon over OAuth', message);
595
+
596
+ if (typeof message === 'object') {
597
+ message = JSON.stringify(message);
598
+ }
599
+ // currently alerting generic error or error message for oauth failure
600
+ // TODO: to make generic oauth error modal to show errormessage property
601
+ window.alert(`Something went wrong connecting your account. Please try again or ${message}`);
602
+ }
603
+
604
+ /**
605
+ * Send a Connect API request. Automatically handles authorization and errors.
606
+ *
607
+ * @param path The path of the request, including the leading slash, i.e. `/sdk/actions`
608
+ * @param init Options for the request, excluding headers (automatically added).
609
+ * @param prefixWithProjectPath Defaults to true. Prepends /projects/${this.projectId}` to the
610
+ * path.
611
+ */
612
+ async sendConnectRequest<TResponse>(
613
+ path: string,
614
+ init?: RequestInit & { cacheResult?: boolean },
615
+ prefixWithProjectPath: boolean = true,
616
+ ): Promise<TResponse | undefined> {
617
+ if (!this.userState.authenticated || !this.projectId) {
618
+ throw new Error(
619
+ `Connect SDK attempted to make an API request, but no user was authenticated.
620
+ Call paragon.authenticate(<projectId>, <user token>) before using the SDK.`,
621
+ );
622
+ }
623
+ const url: string = `${this.environments.ZEUS_PUBLIC_URL}${
624
+ prefixWithProjectPath ? `/projects/${this.projectId}` : ''
625
+ }${path}`;
626
+
627
+ const cacheKey: string = hash(JSON.stringify({ url, payload: { ...init } }));
628
+ const cachedResult = await this.cachedApiResponse.get(cacheKey, true);
629
+ if (init?.cacheResult && cachedResult) {
630
+ return cachedResult;
631
+ }
632
+
633
+ const shouldCacheRequest: boolean = Boolean(!init || init.method === 'GET' || init.cacheResult);
634
+
635
+ if (shouldCacheRequest && typeof this.keyToRequestPromiseMap[cacheKey] === 'object') {
636
+ return this.keyToRequestPromiseMap[cacheKey] as Promise<TResponse | undefined>;
637
+ }
638
+
639
+ const accessToken: string = this.userState.token;
640
+
641
+ const onGoingRequest: Promise<TResponse | undefined> = new Promise((resolve, reject) => {
642
+ (async () => {
643
+ try {
644
+ const result = await this.sendRequest<TResponse>(
645
+ url,
646
+ {
647
+ ...init,
648
+ headers: {
649
+ Accept: 'application/json',
650
+ 'Content-Type': 'application/json',
651
+ ...init?.headers,
652
+ Authorization: `Bearer ${accessToken}`,
653
+ },
654
+ },
655
+ shouldCacheRequest ? cacheKey : undefined,
656
+ );
657
+ resolve(result);
658
+ } catch (err) {
659
+ reject(err);
660
+ }
661
+ })();
662
+ });
663
+
664
+ if (shouldCacheRequest) {
665
+ this.keyToRequestPromiseMap[cacheKey] = onGoingRequest;
666
+ }
667
+ return onGoingRequest;
668
+ }
669
+
670
+ private async sendRequest<TResponse>(
671
+ url: string,
672
+ init: RequestInit,
673
+ cacheKey?: string,
674
+ ): Promise<TResponse | undefined> {
675
+ let response: Response | undefined;
676
+ let responseBody: TResponse | undefined;
677
+ let responseError: Error | undefined;
678
+ try {
679
+ response = await fetch(url, init);
680
+ } catch (error) {
681
+ responseError = error;
682
+ }
683
+
684
+ if ((response && !response.ok) || responseError) {
685
+ const requestReturnedAuthorizationError: boolean =
686
+ response?.status === 401 || response?.status === 403;
687
+ if (!url.includes('proxy') && requestReturnedAuthorizationError) {
688
+ this.logout();
689
+ }
690
+
691
+ const errorMessage: string = (await response?.text()) || (responseError?.message as string);
692
+
693
+ if (cacheKey) {
694
+ await this.cachedApiResponse.del(cacheKey);
695
+ delete this.keyToRequestPromiseMap[cacheKey];
696
+ }
697
+ throw new Error(errorMessage);
698
+ }
699
+
700
+ try {
701
+ responseBody = await response?.json();
702
+ } catch {
703
+ responseBody = undefined;
704
+ }
705
+
706
+ if (cacheKey) {
707
+ await this.cachedApiResponse.set(cacheKey, responseBody);
708
+ delete this.keyToRequestPromiseMap[cacheKey];
709
+ }
710
+
711
+ return responseBody;
712
+ }
713
+
714
+ async sendProxyRequest<TResponse>(
715
+ action: VisibleConnectAction,
716
+ path: string,
717
+ init: {
718
+ method: RequestInit['method'];
719
+ body: RequestInit['body'] | object;
720
+ headers: RequestInit['headers'];
721
+ },
722
+ ): Promise<TResponse | undefined> {
723
+ let baseProxyPath: string = `/sdk/proxy/${action}`;
724
+ if (isCustomIntegrationTypeName(action)) {
725
+ baseProxyPath = `/sdk/proxy/custom/${
726
+ Object.values(this.loadedIntegrations).find(
727
+ (integration: IConnectIntegrationWithCredentialInfo) => {
728
+ if (integration.customIntegration) {
729
+ return integration.customIntegration.slug === action;
730
+ }
731
+ return false;
732
+ },
733
+ )?.id
734
+ }`;
735
+ }
736
+ const pathWithLeadingSlash: string = path.startsWith('/') ? path : `/${path}`;
737
+ const response = await this.sendConnectRequest<{ output: TResponse }>(
738
+ `${baseProxyPath}${pathWithLeadingSlash}`,
739
+ {
740
+ method: init.method,
741
+ body: typeof init.body === 'object' ? JSON.stringify(init.body) : init.body,
742
+ headers: {
743
+ 'Content-Type': INFER_CONTENT_TYPE_FROM_CONNECT_OPTIONS,
744
+ ...init.headers,
745
+ },
746
+ },
747
+ );
748
+ return response?.output;
749
+ }
750
+
751
+ async event(name: string, payload: Record<string, unknown>): Promise<void> {
752
+ await this.sendConnectRequest(
753
+ `/v2/projects/${this.projectId}/sdk/events/trigger`,
754
+ {
755
+ method: 'POST',
756
+ body: JSON.stringify({
757
+ name,
758
+ payload,
759
+ }),
760
+ },
761
+ false,
762
+ );
763
+ }
764
+
765
+ /**
766
+ * @summary this will be called to close the modal
767
+ */
768
+ onClose(): void {
769
+ const { integration, apiInstallationOptions } = this.modalState;
770
+
771
+ if (!integration) {
772
+ // This might happen if the "X" button is closed, because `onClose` will fire twice. We
773
+ // ignore it if this is the case.
774
+ return;
775
+ }
776
+ this.triggerSDKEvent({
777
+ type: SDK_EVENT.ON_PORTAL_CLOSE,
778
+ integrationId: integration.id,
779
+ integrationType: getIntegrationTypeName(integration) as string,
780
+ });
781
+
782
+ if (this.originalBodyOverflow !== PARAGON_OVERFLOW_EMPTY_VALUE) {
783
+ window.document.body.style.overflow = this.originalBodyOverflow as string;
784
+ this.originalBodyOverflow = PARAGON_OVERFLOW_EMPTY_VALUE;
785
+ }
786
+
787
+ // Not clearing integration state as its required in oauth_callback
788
+ if (!apiInstallationOptions?.isApiInstallation) {
789
+ this.setModalState({ integration: null });
790
+ }
791
+ }
792
+
793
+ /**
794
+ * @summary this will be called when modal will be visible
795
+ */
796
+ onOpen(): void {
797
+ const { integration } = this.modalState;
798
+ if (!integration) {
799
+ return;
800
+ }
801
+ this.triggerSDKEvent({
802
+ type: SDK_EVENT.ON_PORTAL_OPEN,
803
+ integrationId: integration.id,
804
+ integrationType: getIntegrationTypeName(integration) as string,
805
+ });
806
+
807
+ if (this.root && this.originalBodyOverflow === PARAGON_OVERFLOW_EMPTY_VALUE) {
808
+ const boundingRect = this.root.getBoundingClientRect();
809
+ // If the Connect Portal is bigger than the window
810
+ if (window.innerHeight <= boundingRect.height && window.innerWidth <= boundingRect.width) {
811
+ this.originalBodyOverflow = window.document.body.style.overflow;
812
+ window.document.body.style.overflow = 'hidden';
813
+ }
814
+ }
815
+ }
816
+
817
+ /**
818
+ * this is shared in ConnectUserContext
819
+ * so that event can be trigger from components
820
+ * @param eventInfo
821
+ */
822
+ triggerSDKEvent(eventInfo: EventInfo): void {
823
+ this.triggerEvent(eventInfo, this.userState as AuthenticatedConnectUser);
824
+ }
825
+
826
+ /**
827
+ * get single or all metadata info for integrations
828
+ * @param integrationKeyName
829
+ * @returns
830
+ */
831
+ getIntegrationMetadata(
832
+ integrationKeyName?: string,
833
+ ): IIntegrationMetadata | IIntegrationMetadata[] {
834
+ if (!integrationKeyName) {
835
+ return Object.values(this.metadata);
836
+ } else if (!this.metadata[integrationKeyName]) {
837
+ throw new Error(`${integrationKeyName} is not a valid integration name`);
838
+ }
839
+
840
+ return this.metadata[integrationKeyName];
841
+ }
842
+
843
+ /**
844
+ * trigger connect endpoint trigger workflow
845
+ * @param workflowId
846
+ * @param payload
847
+ */
848
+ triggerWorkflow(
849
+ workflowId: string,
850
+ { body = {}, query = {}, headers = {} }: TriggerWorkflowRequest = {
851
+ body: {},
852
+ headers: {},
853
+ query: {},
854
+ },
855
+ ): Promise<object | undefined> {
856
+ if (!workflowId) {
857
+ throw new Error('workflowId is required.');
858
+ }
859
+
860
+ const queryString: string = new URLSearchParams(query).toString();
861
+
862
+ return this.sendConnectRequest<object>(
863
+ `/sdk/triggers/${workflowId}?${queryString}`,
864
+ {
865
+ method: 'POST',
866
+ body: JSON.stringify(body),
867
+ headers,
868
+ },
869
+ true,
870
+ );
871
+ }
872
+
873
+ /**
874
+ * for programmatically installing an integration
875
+ */
876
+ installIntegration(
877
+ action: Action,
878
+ params: Omit<InstallOptions, 'isApiInstallation'> = { showPortalAfterInstall: false },
879
+ ): Promise<void> {
880
+ if (!this.userState.authenticated) {
881
+ throw new Error(
882
+ `User not authenticated, Call paragon.authenticate(<projectId>, <user token>) before using the SDK.`,
883
+ );
884
+ }
885
+ this.ensureHeadlessIsSupported();
886
+
887
+ const isAlreadyEnabled = this.validateAction(action, true);
888
+ if (isAlreadyEnabled) {
889
+ throw new Error(`Integration "${action}" is already installed.`);
890
+ }
891
+
892
+ // as we have added integration dependency in ConnectModal useEffect to trigger enableIntegration
893
+ // so this reset is needed incase previous one fails
894
+ this.setModalState({ integration: null });
895
+
896
+ return this.connect(action, { ...params, isApiInstallation: true });
897
+ }
898
+
899
+ /**
900
+ * gates headless feature to pro and enterprise users
901
+ */
902
+ ensureHeadlessIsSupported(): void {
903
+ if (!this.project.accessibleFeatures.includes(ConnectAddOn.HeadlessConnectPortal)) {
904
+ throw new Error(
905
+ 'Headless Connect Portal is available on our Pro plan and above. Contact sales@useparagon.com to learn more or upgrade.',
906
+ );
907
+ }
908
+ }
909
+
910
+ async uninstallIntegration(action: Action): Promise<void> {
911
+ if (!this.userState.authenticated) {
912
+ throw new Error(
913
+ `User not authenticated, Call paragon.authenticate(<projectId>, <user token>) before using the SDK.`,
914
+ );
915
+ }
916
+ this.ensureHeadlessIsSupported();
917
+ if (!this.validateAction(action, true)) {
918
+ throw new Error(`Integration "${action}" is not installed.`);
919
+ }
920
+
921
+ const integrationId = this.loadedIntegrations[action].id;
922
+ await this.sendConnectRequest(`/sdk/integrations/${integrationId}`, {
923
+ method: 'DELETE',
924
+ });
925
+
926
+ this.updateAuthenticatedUser({
927
+ integrations: {
928
+ ...this.userState.integrations,
929
+ [action]: { enabled: false, configuredWorkflows: {} },
930
+ },
931
+ });
932
+
933
+ this.saveState();
934
+
935
+ this.triggerSDKEvent({
936
+ type: SDK_EVENT.ON_INTEGRATION_UNINSTALL,
937
+ integrationId,
938
+ integrationType: action,
939
+ });
940
+ }
941
+
942
+ async disableWorkflow(workflowId: string): Promise<void> {
943
+ if (!this.userState.authenticated) {
944
+ throw new Error(
945
+ `User not authenticated, Call paragon.authenticate(<projectId>, <user token>) before using the SDK.`,
946
+ );
947
+ }
948
+
949
+ if (!this.isWorkflowEnabled(workflowId)) {
950
+ throw new Error(`Workflow ${workflowId} cannot be disabled for this user.`);
951
+ }
952
+
953
+ await this.sendConnectRequest<IConnectCredential>(`/sdk/workflows/${workflowId}`, {
954
+ method: 'DELETE',
955
+ });
956
+
957
+ await this.updateLocalState();
958
+ }
959
+
960
+ private async updateLocalState(): Promise<void> {
961
+ const [userData, integrations] = await Promise.all([
962
+ this.fetchUserData(),
963
+ this.fetchIntegrations(),
964
+ ]);
965
+
966
+ this.updateAuthenticatedUser({ integrations: userData.integrations, meta: userData.meta });
967
+ this.updateIntegrations(integrations);
968
+ this.saveState();
969
+ }
970
+
971
+ /**
972
+ * also returns false if unable to find workflow in configured workflow property of any integration
973
+ */
974
+ private isWorkflowEnabled(workflowId: string): boolean {
975
+ const result = Object.values((this.userState as AuthenticatedConnectUser).integrations).find(
976
+ (integration) => integration?.configuredWorkflows?.[workflowId]?.enabled,
977
+ );
978
+
979
+ return result === undefined ? false : true;
980
+ }
981
+
982
+ /**
983
+ * gets the user data from api `/sdk/me`
984
+ */
985
+ private async fetchUserData(): Promise<IConnectUser> {
986
+ const userData = await this.sendConnectRequest<IConnectUser>('/sdk/me');
987
+ if (!userData) {
988
+ throw new Error('Unable to get user Data');
989
+ } else {
990
+ return userData;
991
+ }
992
+ }
993
+
994
+ /**
995
+ * Gets the integrations from api `/sdk/integrations`
996
+ * Required this another request for integrations, as `/sdk/me` provides only minimal data for integrations
997
+ * we also need integration config and other credentials info which is not included in `sdk/me`
998
+ */
999
+ private async fetchIntegrations(): Promise<IConnectIntegrationWithCredentialInfo[]> {
1000
+ const integrations = await this.sendConnectRequest<IConnectIntegrationWithCredentialInfo[]>(
1001
+ '/sdk/integrations',
1002
+ );
1003
+
1004
+ if (!integrations) {
1005
+ throw new Error('Unable to fetch integrations');
1006
+ }
1007
+
1008
+ return integrations;
1009
+ }
1010
+
1011
+ /**
1012
+ * Updates internal state where integration information may be used.
1013
+ */
1014
+ private updateIntegrations(integrations: IConnectIntegrationWithCredentialInfo[]): void {
1015
+ integrations.forEach((integration: IConnectIntegrationWithCredentialInfo) => {
1016
+ if (integration.configs.length) {
1017
+ try {
1018
+ const type = getIntegrationTypeName(integration) as string;
1019
+
1020
+ // update loaded configs
1021
+ this.loadedConfigs[type] = integration.configs[0].values;
1022
+
1023
+ // update loaded integrations
1024
+ this.loadedIntegrations[type] = integration;
1025
+
1026
+ // update integration metadata
1027
+ if (Boolean(integration.isActive)) {
1028
+ this.metadata[type] = {
1029
+ type,
1030
+ name:
1031
+ integration.customIntegration?.name ?? ActionCatalogConfigs[integration.type].name,
1032
+ brandColor:
1033
+ integration.configs[0].values.accentColor ??
1034
+ ActionCatalogConfigs[integration.type].brandColor,
1035
+ icon:
1036
+ integration.customIntegration?.icon ??
1037
+ `${this.environments.CDN_PUBLIC_URL}/integrations/${
1038
+ overrideActionAlias[integration.type] || integration.type
1039
+ }.svg`,
1040
+ };
1041
+ }
1042
+
1043
+ // update modal state (if being used)
1044
+ if (this.modalState.integration?.id === integration.id) {
1045
+ this.setModalState({ integration });
1046
+ }
1047
+ } catch (err) {
1048
+ console.warn(err);
1049
+ }
1050
+ }
1051
+ });
1052
+ }
1053
+
1054
+ async setUserMetadata(meta: PersonaMeta): Promise<AuthenticatedConnectUser> {
1055
+ await this.sendConnectRequest<IPersona>('/sdk/me', {
1056
+ method: 'PATCH',
1057
+ body: JSON.stringify({ meta }),
1058
+ });
1059
+
1060
+ await this.updateLocalState();
1061
+ this.render();
1062
+
1063
+ return this.userState as AuthenticatedConnectUser;
1064
+ }
1065
+ }