From 4abc0f0c7ec2a58a7b2cdb51742739c8978b1da7 Mon Sep 17 00:00:00 2001 From: d-klotz Date: Thu, 8 Aug 2024 21:21:26 -0300 Subject: [PATCH] feat: improve RedirectFromAuth.jsx --- package-lock.json | 47 +++++ packages/ui/lib/api/api.js | 11 +- packages/ui/lib/index.js | 2 + .../ui/lib/integration/IntegrationList.jsx | 55 +++--- .../ui/lib/integration/RedirectFromAuth.jsx | 72 +++++++ packages/ui/lib/integration/index.js | 2 + packages/ui/lib/utils/IntegrationUtils.js | 184 ++++++++---------- packages/ui/package.json | 1 + 8 files changed, 231 insertions(+), 143 deletions(-) create mode 100644 packages/ui/lib/integration/RedirectFromAuth.jsx diff --git a/package-lock.json b/package-lock.json index 160bbb4a5..0c7d144d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10042,6 +10042,14 @@ "node": ">=0.10.0" } }, + "node_modules/decode-uri-component": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", + "engines": { + "node": ">=14.16" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -11806,6 +11814,17 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/finalhandler": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", @@ -18261,6 +18280,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.1.0.tgz", + "integrity": "sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==", + "dependencies": { + "decode-uri-component": "^0.4.1", + "filter-obj": "^5.1.0", + "split-on-first": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -19592,6 +19627,17 @@ "node": "*" } }, + "node_modules/split-on-first": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", + "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/split2": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", @@ -21634,6 +21680,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.424.0", "node-fetch": "^3.3.2", + "query-string": "^9.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.4.0", diff --git a/packages/ui/lib/api/api.js b/packages/ui/lib/api/api.js index e9eefde15..5a3654e54 100755 --- a/packages/ui/lib/api/api.js +++ b/packages/ui/lib/api/api.js @@ -131,15 +131,6 @@ export default class API { return this._get(url); } - // authorize callback for scrive with the following params as an example: - // { - // "originalUrl":"https://redirecturl/", - // "entityType":"Freshbooks", - // "data": data - // } - // - // 'authData' should be the qString parsed dictionary of all the - // query params from the auth redirect once the user signs into the third party async authorize(entityType, authData) { const url = `${this.endpointAuthorize}`; const params = { @@ -156,7 +147,7 @@ export default class API { entities: [entity1, entity2], config, }; - return this._post(url, params); // todo: to improve dev experience, return a clear response, with this current implementation one does not now what returns from this request. + return this._post(url, params); } async updateIntegration(integrationId, config) { diff --git a/packages/ui/lib/index.js b/packages/ui/lib/index.js index c5f26c15c..914d9729f 100644 --- a/packages/ui/lib/index.js +++ b/packages/ui/lib/index.js @@ -7,6 +7,7 @@ import { IntegrationHorizontal, IntegrationVertical, IntegrationList, + RedirectFromAuth, } from "./integration"; export { @@ -16,4 +17,5 @@ export { IntegrationHorizontal, IntegrationVertical, IntegrationList, + RedirectFromAuth, }; diff --git a/packages/ui/lib/integration/IntegrationList.jsx b/packages/ui/lib/integration/IntegrationList.jsx index 6882e31d5..14a1473d2 100644 --- a/packages/ui/lib/integration/IntegrationList.jsx +++ b/packages/ui/lib/integration/IntegrationList.jsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import IntegrationSkeleton from "./IntegrationSkeleton"; -import IntegrationUtils from "../utils/IntegrationUtils"; +import { getActiveAndPossibleIntegrationsCombined } from "../utils/IntegrationUtils"; import API from "../api/api"; import { IntegrationHorizontal, IntegrationVertical } from "../integration"; @@ -10,38 +10,38 @@ import { IntegrationHorizontal, IntegrationVertical } from "../integration"; * @param props.friggBaseUrl - Base URL for Frigg backend * @param props.componentLayout - Layout for displaying integrations - either 'default-horizontal' or 'default-vertical' * @param props.authToken - JWT token for authenticated user in Frigg - * @returns {Element} + * @returns {JSX.Element} The rendered component * @constructor */ const IntegrationList = (props) => { const [installedIntegrations, setInstalledIntegrations] = useState([]); - const [integrations, setIntegrations] = useState({}); - const integrationUtils = useRef(null); + const [integrations, setIntegrations] = useState([]); + const [isloading, setIsLoading] = useState(true); + + const loadIntegrations = async () => { + if (!props.authToken) { + console.log("Authentication token is required to fetch integrations."); + } - const refreshIntegrations = useCallback(async () => { const api = new API(props.friggBaseUrl, props.authToken); const integrationsData = await api.listIntegrations(); if (integrationsData.error) { - // dispatch(logoutUser()); - //todo: if integration has an error, we should display an error message + a way to solve it + console.log( + "Something went wrong while fetching integrations, please try again later." + ); } - setIntegrations(integrationsData); if (integrationsData.integrations) { - integrationUtils.current = new IntegrationUtils(integrationsData); + const activeAndPossibleIntegrations = + getActiveAndPossibleIntegrationsCombined(integrationsData); + setIntegrations(activeAndPossibleIntegrations); } - }, [props.authToken, props.friggBaseUrl]); + }; useEffect(() => { - const init = async () => { - if (props.authToken) { - await refreshIntegrations(); - } - }; - - init(); - }, [props.authToken, refreshIntegrations]); + loadIntegrations().then(() => setIsLoading(false)); + }, []); const setInstalled = (data) => { const items = [data, ...installedIntegrations]; @@ -55,7 +55,7 @@ const IntegrationList = (props) => { data={integration} key={`combined-integration-${integration.type}`} handleInstall={setInstalled} - refreshIntegrations={refreshIntegrations} + refreshIntegrations={loadIntegrations} friggBaseUrl={props.friggBaseUrl} authToken={props.authToken} /> @@ -67,7 +67,7 @@ const IntegrationList = (props) => { data={integration} key={`combined-integration-${integration.type}`} handleInstall={setInstalled} - refreshIntegrations={refreshIntegrations} + refreshIntegrations={loadIntegrations} friggBaseUrl={props.friggBaseUrl} /> ); @@ -95,7 +95,7 @@ const IntegrationList = (props) => { return ( <> - {integrationUtils.current === null ? ( + {isloading && (
@@ -107,15 +107,12 @@ const IntegrationList = (props) => {
+ )} + {renderCombinedIntegrations(integrations).length === 0 ? ( +

No {props.integrationType} integrations found.

) : ( - renderCombinedIntegrations( - integrationUtils.current.getActiveAndPossibleIntegrationsCombined() - ) + renderCombinedIntegrations(integrations) )} - {integrationUtils.current !== null && - renderCombinedIntegrations( - integrationUtils.current.getActiveAndPossibleIntegrationsCombined() - ).length === 0 &&

No {props.integrationType} integrations found.

} ); }; diff --git a/packages/ui/lib/integration/RedirectFromAuth.jsx b/packages/ui/lib/integration/RedirectFromAuth.jsx new file mode 100644 index 000000000..5a1585624 --- /dev/null +++ b/packages/ui/lib/integration/RedirectFromAuth.jsx @@ -0,0 +1,72 @@ +import React, { useEffect } from "react"; +import qString from "query-string"; +import API from "../api/api"; +import { LoadingSpinner } from "../components/LoadingSpinner.jsx"; + +/** + * @param {string} props.app - The name of the app being authorized + * @param {string} props.friggBaseUrl - The base URL for the Frigg service + * @param {string} props.authToken - JWT token for authenticated user in Frigg + * @param {string} props.primaryEntityName - The name of the primary entity in the app + * @returns {JSX.Element} The rendered component + * @constructor + */ +const RedirectFromAuth = (props) => { + useEffect(() => { + const handleAuth = async () => { + const api = new API(props.friggBaseUrl, props.authToken); + const params = qString.parse(window.location.search); + + if (params.code) { + const integrations = await api.listIntegrations(); + const targetEntity = await api.authorize(props.app, { + code: params.code, + }); + + if (targetEntity?.error) { + alert(targetEntity.error); + window.location.href = "/integrations"; + return; + } + + const config = { + type: props.app, + category: "CRM", + }; + + const primaryEntity = integrations.entities.authorized.find( + (entity) => entity.type === props.primaryEntityName + ); + + //todo: shouldn't the integration be created only if primary and target entities exist and are different? + //todo2: move the createIntegration function to the integrationList component + const integration = await api.createIntegration( + primaryEntity.id ?? targetEntity.entity_id, + targetEntity.entity_id, + config + ); + + if (integration.error) { + alert(integration.error); + return; + } + + window.location.href = "/integrations"; + } + }; + + handleAuth(); + }, [props.app]); + + return ( +
+
+
+ +
+
+
+ ); +}; + +export default RedirectFromAuth; diff --git a/packages/ui/lib/integration/index.js b/packages/ui/lib/integration/index.js index 3ab30a790..f5c0fa7ea 100644 --- a/packages/ui/lib/integration/index.js +++ b/packages/ui/lib/integration/index.js @@ -4,6 +4,7 @@ import IntegrationList from "./IntegrationList.jsx"; import IntegrationSkeleton from "./IntegrationSkeleton.jsx"; import IntegrationVertical from "./IntegrationVertical"; import QuickActionsMenu from "./QuickActionsMenu"; +import RedirectFromAuth from "./RedirectFromAuth.jsx"; export { IntegrationDropdown, @@ -12,4 +13,5 @@ export { IntegrationSkeleton, IntegrationVertical, QuickActionsMenu, + RedirectFromAuth, }; diff --git a/packages/ui/lib/utils/IntegrationUtils.js b/packages/ui/lib/utils/IntegrationUtils.js index 7155f1a32..cd5c41262 100644 --- a/packages/ui/lib/utils/IntegrationUtils.js +++ b/packages/ui/lib/utils/IntegrationUtils.js @@ -1,104 +1,80 @@ -// class to wrap the response of the /api/integrations endpoint -// and provide helper methods from that data. - -const ENTITIES = 'entities'; -const INTEGRATIONS = 'integrations'; -const PRIMARY = 'primary'; -const AUTHORIZED = 'authorized'; -const OPTIONS = 'options'; - -export default class IntegrationUtils { - constructor(integrations) { - this.integrations = integrations; - } - - // - // return a list of objects containing data related to the possible - // new integrations you may connect - getPossibleIntegrations() { - const options = this.integrations[ENTITIES][OPTIONS]; - return options; - - // exclude the primary - const possibleOptions = []; - options.forEach((opt) => { - if (opt.type !== this.getPrimaryType()) { - // opt.connected = false; - possibleOptions.push(opt); - } - }); - return possibleOptions; - } - - // - // get entities primary type (a string, ie: "Freshbooks") - getPrimaryType() { - return this.integrations[ENTITIES][PRIMARY]; - } - - // - // get a list of authorized entities - getAuthorizedEntities() { - return this.integrations[ENTITIES][AUTHORIZED]; - } - - getDisplayDataForType(type) { - const possibleIntegrations = this.getPossibleIntegrations(); - const integration = possibleIntegrations.find((pi) => pi.type === type); - - if (integration) { - return integration.display; - } - - throw Error(`getDisplayForType() ERR - type ${type} does not exist in possible integrations!`); - } - - getTypeForId(entityId) { - const authorizedEntities = this.getAuthorizedEntities(); - const entity = authorizedEntities.find((ae) => ae.id === entityId); - - if (entity) { - return entity.type; - } - - throw Error(`getTypeForId() ERR - entityId ${entityId} does not exist in authorized entities!`); - } - - // - // return a list of objects containing data related to the active/existing user integrations - getActiveIntegrations() { - const activeIntegrations = []; - const integrations = this.integrations[INTEGRATIONS]; - integrations.forEach((integration) => { - const clone = { ...integration }; - const secondaryId = clone.entities[1].id; // get 2nd element - const type = this.getTypeForId(secondaryId); - clone.type = type; - clone.display = this.getDisplayDataForType(type); - // clone.connected = true; - // clone['secondaryId'] = secondaryId; - activeIntegrations.push(clone); - }); - - return activeIntegrations; - } - - getActiveAndPossibleIntegrationsCombined() { - const combinedIntegrations = []; - const activeIntegrations = this.getActiveIntegrations(); - const possibleIntegrations = this.getPossibleIntegrations(); - - possibleIntegrations.forEach((integration) => { - const foundActive = activeIntegrations.filter((ai) => ai.type === integration.type); - if (foundActive.length > 0) { - foundActive.forEach((ai) => { - combinedIntegrations.push(ai); - }); - } else { - combinedIntegrations.push(integration); - } - }); - - return combinedIntegrations; - } -} +export const getActiveAndPossibleIntegrationsCombined = (integrationsData) => { + const combinedIntegrations = []; + const activeIntegrations = getActiveIntegrations(integrationsData); + const possibleIntegrations = getPossibleIntegrations(integrationsData); + + possibleIntegrations.forEach((integration) => { + const foundActive = activeIntegrations.filter( + (ai) => ai.type === integration.type + ); + if (foundActive.length > 0) { + foundActive.forEach((ai) => { + combinedIntegrations.push(ai); + }); + } else { + combinedIntegrations.push(integration); + } + }); + + return combinedIntegrations; +}; + +// return a list of objects containing data related to the active/existing user integrations +const getActiveIntegrations = (integrationsData) => { + const activeIntegrations = []; + integrationsData.integrations.forEach((integration) => { + const clone = { ...integration }; + const secondaryId = clone.entities[1].id; // get 2nd element + const type = getTypeForId(secondaryId, integrationsData); + clone.type = type; + clone.display = getDisplayDataForType(type, integrationsData); + // clone.connected = true; + // clone['secondaryId'] = secondaryId; + activeIntegrations.push(clone); + }); + + return activeIntegrations; +}; + +const getTypeForId = (entityId, integrationsData) => { + const authorizedEntities = integrationsData.entities.authorized; + const entity = authorizedEntities.find((ae) => ae.id === entityId); + + if (entity) { + return entity.type; + } + + throw Error( + `getTypeForId() ERR - entityId ${entityId} does not exist in authorized entities!` + ); +}; + +const getDisplayDataForType = (type, integrationsData) => { + const possibleIntegrations = getPossibleIntegrations(integrationsData); + const integration = possibleIntegrations.find((pi) => pi.type === type); + + if (integration) { + return integration.display; + } + + throw Error( + `getDisplayForType() ERR - type ${type} does not exist in possible integrations!` + ); +}; + +// return a list of objects containing data related to the possible +// new integrations you may connect +const getPossibleIntegrations = (integrationsData) => { + const options = integrationsData.entities.options; + return options; + + // exclude the primary + const possibleOptions = []; + options.forEach((opt) => { + if (opt.type !== integrationsData.entities.primary) { + // opt.connected = false; + possibleOptions.push(opt); + } + }); + return possibleOptions; +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index ce92e8c2b..f7856f9aa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -33,6 +33,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.424.0", "node-fetch": "^3.3.2", + "query-string": "^9.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.4.0",