From 085409ae754c1a0ee36cd3d7754e448c266c46bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:11:40 +0100 Subject: [PATCH 1/4] wip: oauth flow --- dev/main.tsx | 2 + dev/vite-env.d.ts | 2 +- src/StackOneHub.tsx | 5 + .../integration-picker/IntegrationPicker.tsx | 5 +- .../hooks/useIntegrationPicker.ts | 124 +++++++++++++++++- src/modules/integration-picker/types.ts | 1 + 6 files changed, 130 insertions(+), 9 deletions(-) diff --git a/dev/main.tsx b/dev/main.tsx index 8cd2494..802ef37 100644 --- a/dev/main.tsx +++ b/dev/main.tsx @@ -11,6 +11,7 @@ const HubWrapper: React.FC = () => { const [error, setError] = useState(); const [token, setToken] = useState(); const apiUrl = import.meta.env.VITE_API_URL ?? 'https://api.stackone.com'; + const appUrl = import.meta.env.VITE_APP_URL ?? 'https://app.stackone.com'; const [theme, setTheme] = useState<'light' | 'dark'>('light'); const [accountId, setAccountId] = useState(); @@ -81,6 +82,7 @@ const HubWrapper: React.FC = () => { setMode(undefined); }} accountId={accountId} + appUrl={appUrl} /> ); diff --git a/dev/vite-env.d.ts b/dev/vite-env.d.ts index 5af38cf..4998407 100644 --- a/dev/vite-env.d.ts +++ b/dev/vite-env.d.ts @@ -7,7 +7,7 @@ interface ImportMetaEnv { readonly VITE_ORIGIN_OWNER_NAME: string; readonly VITE_ORIGIN_USERNAME: string; readonly VITE_API_URL: string; - readonly VITE_DASHBOARD_URL: string; + readonly VITE_APP_URL: string; } export interface ImportMeta { diff --git a/src/StackOneHub.tsx b/src/StackOneHub.tsx index d84d31a..054e770 100644 --- a/src/StackOneHub.tsx +++ b/src/StackOneHub.tsx @@ -23,6 +23,7 @@ interface StackOneHubProps { mode?: HubModes; token?: string; baseUrl?: string; + appUrl?: string; height?: string; theme?: 'light' | 'dark' | PartialMalachiteTheme; accountId?: string; @@ -35,6 +36,7 @@ export const StackOneHub: React.FC = ({ mode, token, baseUrl, + appUrl, height = '500px', theme = 'light', accountId, @@ -44,6 +46,8 @@ export const StackOneHub: React.FC = ({ }) => { const defaultBaseUrl = 'https://api.stackone.com'; const apiUrl = baseUrl ?? defaultBaseUrl; + const defaultDashboardUrl = 'https://app.stackone.com'; + const dashboardUrl = appUrl ?? defaultDashboardUrl; useEffect(() => { if (theme === 'dark') { applyDarkTheme(); @@ -119,6 +123,7 @@ export const StackOneHub: React.FC = ({ void; onClose?: () => void; onCancel?: () => void; @@ -20,6 +21,7 @@ export const IntegrationPicker: React.FC = ({ height, accountId, onSuccess, + dashboardUrl, }) => { const { // Data @@ -48,6 +50,7 @@ export const IntegrationPicker: React.FC = ({ baseUrl, accountId, onSuccess, + dashboardUrl, }); return ( @@ -75,7 +78,7 @@ export const IntegrationPicker: React.FC = ({ hasError={hasError} connectionState={connectionState} selectedIntegration={selectedIntegration} - connectorData={connectorData ?? null} + connectorData={connectorData?.config ?? null} hubData={hubData ?? null} fields={fields} guide={guide} diff --git a/src/modules/integration-picker/hooks/useIntegrationPicker.ts b/src/modules/integration-picker/hooks/useIntegrationPicker.ts index 5ea2c91..6802b50 100644 --- a/src/modules/integration-picker/hooks/useIntegrationPicker.ts +++ b/src/modules/integration-picker/hooks/useIntegrationPicker.ts @@ -1,6 +1,6 @@ import { evaluate } from '@stackone/expressions'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { connectAccount, getAccountData, @@ -8,7 +8,7 @@ import { getHubData, updateAccount, } from '../queries'; -import { Integration } from '../types'; +import { ConnectorConfigField, Integration } from '../types'; const DUMMY_VALUE = 'totally-fake-value'; @@ -17,6 +17,13 @@ interface UseIntegrationPickerProps { baseUrl: string; accountId?: string; onSuccess?: () => void; + dashboardUrl?: string; +} + +export enum EventType { + AccountConnected = 'AccountConnected', + CloseModal = 'CloseModal', + CloseOAuth2 = 'CloseOAuth2', } export const useIntegrationPicker = ({ @@ -24,9 +31,11 @@ export const useIntegrationPicker = ({ baseUrl, accountId, onSuccess, + dashboardUrl, }: UseIntegrationPickerProps) => { const [selectedIntegration, setSelectedIntegration] = useState(null); const [formData, setFormData] = useState>({}); + const connectWindow = useRef(null); const [connectionState, setConnectionState] = useState<{ loading: boolean; success: boolean; @@ -39,6 +48,43 @@ export const useIntegrationPicker = ({ success: false, }); + const processMessageCallback = (event: MessageEvent) => { + console.log('processMessageCallback', event); + // if (event.origin !== location.origin) { + // console.log('origin does not match', event.origin, location.origin); + // return; + // } + + if (event.data.type === EventType.AccountConnected) { + console.log('account connected'); + setConnectionState({ loading: false, success: true }); + parent.postMessage(event.data, '*'); + connectWindow.current && connectWindow.current.close(); + connectWindow.current = null; + window.removeEventListener('message', processMessageCallback, false); + } else if (event.data.type === EventType.CloseOAuth2) { + console.log('close oauth2'); + if (event.data.error) { + setConnectionState({ + loading: false, + success: false, + error: { + message: event.data.error, + provider_response: event.data.errorDescription, + }, + }); + connectWindow.current && connectWindow.current.close(); + connectWindow.current = null; + window.removeEventListener('message', processMessageCallback, false); + } else { + setConnectionState({ loading: false, success: false, error: undefined }); + connectWindow.current && connectWindow.current.close(); + connectWindow.current = null; + window.removeEventListener('message', processMessageCallback, false); + } + } + }; + // Fetch account data for editing scenario const { data: accountData, @@ -103,7 +149,8 @@ export const useIntegrationPicker = ({ // Extract fields and guide from connector config const { fields, guide } = useMemo(() => { if (!connectorData || !selectedIntegration) { - return { fields: [] }; + const fields: ConnectorConfigField[] = []; + return { fields }; } const authConfig = @@ -112,7 +159,7 @@ export const useIntegrationPicker = ({ const baseFields = authConfigForEnvironment?.fields || []; - const fieldsWithPrefilledValues = baseFields + const fieldsWithPrefilledValues: ConnectorConfigField[] = baseFields .map((field) => { const setupValue = accountData?.setupInformation?.[field.key]; @@ -156,7 +203,7 @@ export const useIntegrationPicker = ({ return { ...field, - value: evaluatedValue, + value: evaluatedValue as string | number | undefined, }; }) .filter((value) => value != null); @@ -167,8 +214,19 @@ export const useIntegrationPicker = ({ }; }, [connectorData, selectedIntegration, accountData, formData, hubData]); + const authConfig = useMemo(() => { + if (!connectorData || !selectedIntegration) { + return null; + } + return connectorData.config.authentication?.[ + selectedIntegration.authentication_config_key + ]?.[selectedIntegration.environment]; + }, [connectorData, selectedIntegration]); + const handleConnect = useCallback(async () => { - if (!selectedIntegration) return; + if (!selectedIntegration) { + return; + } setConnectionState({ loading: true, success: false }); @@ -186,6 +244,47 @@ export const useIntegrationPicker = ({ }); } + if (authConfig?.type === 'oauth2') { + console.log('oauth2'); + window.addEventListener('message', processMessageCallback, false); + const callbackEmbeddedAccountsUrl = encodeURIComponent( + `${dashboardUrl}/embedded/accounts/callback`, + ); + let windowUrl = `${baseUrl}/connect/oauth2/${selectedIntegration.provider}?redirect_uri=${callbackEmbeddedAccountsUrl}&token=${token}`; + + Object.keys(cleanedFormData).forEach((key) => { + windowUrl += `&${key}=${cleanedFormData[key]}`; + }); + + const width = 1024; + const height = 800; + const screenX = + typeof window.screenX != 'undefined' ? window.screenX : window.screenLeft; + const screenY = + typeof window.screenY != 'undefined' ? window.screenY : window.screenTop; + const outerWidth = + typeof window.outerWidth != 'undefined' + ? window.outerWidth + : document.body.clientWidth; + const outerHeight = + typeof window.outerHeight != 'undefined' + ? window.outerHeight + : document.body.clientHeight - 22; + const left = screenX + (outerWidth - width) / 2; + const top = screenY + (outerHeight - height) / 2.5; + const features = + 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top; + + connectWindow.current = window.open(windowUrl, 'Connect Account', features); + + if (connectWindow.current) { + connectWindow.current.focus(); + } + + console.log(windowUrl); + return; + } + if (accountId) { await updateAccount( baseUrl, @@ -222,7 +321,18 @@ export const useIntegrationPicker = ({ }, }); } - }, [baseUrl, token, selectedIntegration, formData, onSuccess, accountData, fields, accountId]); + }, [ + baseUrl, + dashboardUrl, + token, + selectedIntegration, + formData, + onSuccess, + accountData, + fields, + accountId, + authConfig, + ]); const isLoading = isLoadingHubData || isLoadingConnectorData || isLoadingAccountData; const hasError = !!(errorHubData || errorConnectorData || errorAccountData); diff --git a/src/modules/integration-picker/types.ts b/src/modules/integration-picker/types.ts index cd7ce7a..9f5cf98 100644 --- a/src/modules/integration-picker/types.ts +++ b/src/modules/integration-picker/types.ts @@ -49,6 +49,7 @@ export interface ConnectorConfig { supportLink?: string; description: string; }; + type: 'oauth2' | 'oidc' | 'custom'; }; }; }; From c3d01c513fd6d4b0e8526c6741b2ae2272ef2f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:52:59 +0100 Subject: [PATCH 2/4] fix: key uniqueness --- .../components/IntegrationFields.tsx | 31 ++++++++----------- .../hooks/useIntegrationPicker.ts | 14 +++++++-- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/modules/integration-picker/components/IntegrationFields.tsx b/src/modules/integration-picker/components/IntegrationFields.tsx index 27f608a..8d29cf1 100644 --- a/src/modules/integration-picker/components/IntegrationFields.tsx +++ b/src/modules/integration-picker/components/IntegrationFields.tsx @@ -68,20 +68,21 @@ export const IntegrationForm: React.FC = ({ {error && {error.provider_response}} {fields.map((field) => { + const key = + typeof field.key === 'object' + ? JSON.stringify(field.key) + : String(field.key); return ( -
+
{(field.type === 'text' || field.type === 'number' || field.type === 'password') && ( - handleFieldChange(field.key, value) - } + onChange={(value: string) => handleFieldChange(key, value)} disabled={field.readOnly} label={field.label} tooltip={field.guide?.tooltip} @@ -92,14 +93,11 @@ export const IntegrationForm: React.FC = ({ {field.type === 'text_area' && (