diff --git a/package-lock.json b/package-lock.json index 2ea3d1f..8b4ae29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1528,9 +1528,9 @@ "dev": true }, "node_modules/@uiw/react-json-view": { - "version": "2.0.0-alpha.39", - "resolved": "https://registry.npmjs.org/@uiw/react-json-view/-/react-json-view-2.0.0-alpha.39.tgz", - "integrity": "sha512-D9MHNan56WhtdAsmjtE9x18YLY0JSMnh0a6Ji0/2sVXCF456ZVumYLdx2II7hLQOgRMa4QMaHloytpTUHxsFRw==", + "version": "2.0.0-alpha.41", + "resolved": "https://registry.npmjs.org/@uiw/react-json-view/-/react-json-view-2.0.0-alpha.41.tgz", + "integrity": "sha512-botRpQ5AgymYEsqXSdT2/1LefAJEYfMntvdnx1SqhTQCTW9HygeFZXx9inkYqUmiQZ3+0QlZnodjBvwnUfZhVA==", "license": "MIT", "funding": { "url": "https://jaywcjlove.github.io/#/sponsor" diff --git a/package.json b/package.json index bb8000b..97c6335 100644 --- a/package.json +++ b/package.json @@ -61,4 +61,4 @@ "tslib": "^2.8.1", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/src/modules/integration-picker/IntegrationPicker.tsx b/src/modules/integration-picker/IntegrationPicker.tsx index 1b9e899..1793a17 100644 --- a/src/modules/integration-picker/IntegrationPicker.tsx +++ b/src/modules/integration-picker/IntegrationPicker.tsx @@ -39,6 +39,10 @@ export const IntegrationPicker: React.FC = ({ accountData, connectorData, selectedIntegration, + selectedProvider, + uniqueProviderIntegrations, + providerIntegrations, + hasOnlyOneProvider, fields, guide, @@ -54,6 +58,10 @@ export const IntegrationPicker: React.FC = ({ // Actions setSelectedIntegration, + setSelectedProvider, + setSelectedVersion, + handleProviderSelect, + handleCreateNewAuthConfig, setFormData, setIsFormValid, handleConnect, @@ -82,10 +90,17 @@ export const IntegrationPicker: React.FC = ({ }, [hubData]); const onBack = () => { - setSelectedIntegration(null); - resetConnectionState(); - setSelectedCategory(null); - setSearch(''); + if (selectedIntegration) { + // From form to auth config selection + setSelectedIntegration(null); + resetConnectionState(); + } else if (selectedProvider) { + // From auth config selection to provider list + setSelectedProvider(null); + setSelectedVersion(null); + setSelectedCategory(null); + setSearch(''); + } }; return ( @@ -126,13 +141,16 @@ export const IntegrationPicker: React.FC = ({ hasOnlyOneIntegration } connectorData={connectorData?.config ?? null} + selectedProvider={selectedProvider} + hasOnlyOneProvider={hasOnlyOneProvider} + uniqueProviderIntegrations={uniqueProviderIntegrations} /> ) } height={height} padding="0" headerConfig={ - selectedIntegration + selectedIntegration || selectedProvider ? undefined : { padding: '0', @@ -145,12 +163,17 @@ export const IntegrationPicker: React.FC = ({ hasError={hasError} connectionState={connectionState} selectedIntegration={selectedIntegration} + selectedProvider={selectedProvider} + uniqueProviderIntegrations={uniqueProviderIntegrations} + providerIntegrations={providerIntegrations} connectorData={connectorData?.config ?? null} hubData={hubData ?? null} fields={fields} errorHubData={(errorHubData as Error) ?? null} errorConnectorData={(errorConnectorData as Error) ?? null} - onSelect={setSelectedIntegration} + onProviderSelect={handleProviderSelect} + onAuthConfigSelect={setSelectedIntegration} + onCreateNewAuthConfig={dashboardUrl ? handleCreateNewAuthConfig : undefined} onChange={setFormData} onValidationChange={handleValidationChange} selectedCategory={selectedCategory} diff --git a/src/modules/integration-picker/components/IntegrationPickerContent.tsx b/src/modules/integration-picker/components/IntegrationPickerContent.tsx index c1fbfaf..da7da60 100644 --- a/src/modules/integration-picker/components/IntegrationPickerContent.tsx +++ b/src/modules/integration-picker/components/IntegrationPickerContent.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { ConnectorConfig, ConnectorConfigField, HubData, Integration } from '../types'; +import { AuthConfigSelectionView } from './views/AuthConfigSelectionView'; import { ErrorView } from './views/ErrorView'; import { IntegrationFormView } from './views/IntegrationFormView'; import { IntegrationListView } from './views/IntegrationListView'; @@ -21,6 +22,9 @@ interface IntegrationPickerContentProps { // Data selectedIntegration: Integration | null; + selectedProvider: string | null; + uniqueProviderIntegrations: Integration[]; + providerIntegrations: Integration[]; connectorData: ConnectorConfig | null; hubData: HubData | null; fields: ConnectorConfigField[]; @@ -32,7 +36,9 @@ interface IntegrationPickerContentProps { errorConnectorData: Error | null; // Actions - onSelect: (integration: Integration) => void; + onProviderSelect: (integration: Integration) => void; + onAuthConfigSelect: (integration: Integration) => void; + onCreateNewAuthConfig?: () => void; onChange: (data: Record) => void; onValidationChange?: (isValid: boolean) => void; editingSecrets?: Set; @@ -44,6 +50,9 @@ export const IntegrationPickerContent: React.FC = hasError, connectionState, selectedIntegration, + selectedProvider, + uniqueProviderIntegrations, + providerIntegrations, connectorData, hubData, fields, @@ -51,7 +60,9 @@ export const IntegrationPickerContent: React.FC = search, errorHubData, errorConnectorData, - onSelect, + onProviderSelect, + onAuthConfigSelect, + onCreateNewAuthConfig, onChange, onValidationChange, editingSecrets, @@ -90,23 +101,8 @@ export const IntegrationPickerContent: React.FC = return ; } - // Integration selection flow - if (!selectedIntegration) { - if (!hubData?.integrations.length) { - return ; - } - return ( - - ); - } - - // Form view (when integration is selected and connector data is available) - if (connectorData) { + // Form view (when auth config is selected and connector data is available) + if (selectedIntegration && connectorData) { return ( = ); } - // Fallback - return null; + // Auth Config selection (provider selected, auth config not yet selected) + if (selectedProvider && !selectedIntegration) { + return ( + + ); + } + + // Provider/Connector selection + if (!hubData?.integrations.length) { + return ; + } + return ( + + ); }; diff --git a/src/modules/integration-picker/components/IntegrationPickerTitle.tsx b/src/modules/integration-picker/components/IntegrationPickerTitle.tsx index ba30d5f..350bcbf 100644 --- a/src/modules/integration-picker/components/IntegrationPickerTitle.tsx +++ b/src/modules/integration-picker/components/IntegrationPickerTitle.tsx @@ -1,4 +1,5 @@ -import { ConnectorConfig, HubData } from '../types'; +import { Button, Typography } from '@stackone/malachite'; +import { ConnectorConfig, HubData, Integration } from '../types'; import CardTitle from './cardTitle'; import { IntegrationListHeader } from './views/IntegrationListView'; @@ -14,6 +15,9 @@ interface IntegrationPickerTitleProps { onCategoryChange: (category: string | null) => void; onSearchChange: (search: string) => void; hideBackButton?: boolean; + selectedProvider: string | null; + hasOnlyOneProvider: boolean; + uniqueProviderIntegrations: Integration[]; } export const IntegrationPickerTitle: React.FC = ({ @@ -28,6 +32,9 @@ export const IntegrationPickerTitle: React.FC = ({ onCategoryChange, onSearchChange, hideBackButton, + selectedProvider, + hasOnlyOneProvider, + uniqueProviderIntegrations, }) => { if (connectorData) { return ( @@ -39,6 +46,28 @@ export const IntegrationPickerTitle: React.FC = ({ ); } + if (selectedProvider) { + const showBackButton = !hasOnlyOneProvider && !accountData; + return ( +
+ {showBackButton && ( +
+ ); + } + const shouldShowListHeader = !isLoading && !hasError && hubData?.integrations; if (!shouldShowListHeader) { @@ -47,7 +76,7 @@ export const IntegrationPickerTitle: React.FC = ({ return ( = { + api_key: 'API Key', + oauth2: 'OAuth', + oauth: 'OAuth', + bearer: 'Bearer', + basic: 'Basic Auth', + saml: 'SAML', + oidc: 'OIDC', + custom: 'Custom', +}; + +function getAuthTypeLabel(key: string): string { + if (!key) return 'Unknown'; + return ( + AUTH_TYPE_LABELS[key.toLowerCase()] || + key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + ); +} + +interface AuthConfigCardProps { + integration: Integration; + onSelect: (integration: Integration) => void; +} + +const AuthConfigCard: React.FC = ({ integration, onSelect }) => { + const [expanded, setExpanded] = useState(false); + + const actionsCount = integration.actions_count ?? integration.actions?.length ?? 0; + const accountCount = integration.account_count ?? 0; + const hasActions = integration.actions && integration.actions.length > 0; + + return ( + + + + {integration.name} + + + + {hasActions && ( + + + + +
+ + + + Version {integration.version} + + +
+ +
+ + {actionsCount} {actionsCount === 1 ? 'action' : 'actions'} ·{' '} + {accountCount} {accountCount === 1 ? 'account' : 'accounts'} + +
+ + {expanded && hasActions && ( + <> +
+ + Actions {actionsCount} + +
+
+ { + const description = value as string; + return description ? ( + + {description} + + ) : ( + '\u2014' + ); + }, + }, + ]} + data={(integration.actions ?? []).map((action) => ({ + id: action.id ?? action.name, + name: action.name, + description: action.description ?? '', + }))} + /> + + + )} + + ); +}; + +interface AuthConfigSelectionViewProps { + integrations: Integration[]; + onSelect: (integration: Integration) => void; + onCreateNew?: () => void; +} + +export const AuthConfigSelectionView: React.FC = ({ + integrations, + onSelect, + onCreateNew, +}) => { + return ( + + + + + Select an existing auth config to link an account, or create a new one. + + {onCreateNew && ( + + )} + + + {integrations.map((integration) => ( + + ))} + + + + ); +}; diff --git a/src/modules/integration-picker/components/views/index.ts b/src/modules/integration-picker/components/views/index.ts index 299c751..657610c 100644 --- a/src/modules/integration-picker/components/views/index.ts +++ b/src/modules/integration-picker/components/views/index.ts @@ -1,3 +1,4 @@ +export { AuthConfigSelectionView } from './AuthConfigSelectionView'; export { IntegrationListView } from './IntegrationListView'; export { IntegrationFormView } from './IntegrationFormView'; export { LoadingView } from './LoadingView'; diff --git a/src/modules/integration-picker/hooks/useIntegrationPicker.ts b/src/modules/integration-picker/hooks/useIntegrationPicker.ts index b92738f..1efbe1a 100644 --- a/src/modules/integration-picker/hooks/useIntegrationPicker.ts +++ b/src/modules/integration-picker/hooks/useIntegrationPicker.ts @@ -6,11 +6,13 @@ import { getAccountData, getConnectorConfig, getHubData, + getProviderActions, updateAccount, } from '../queries'; import { ConnectorConfigField, Integration, + IntegrationAction, isFalconConnectorConfig, isLegacyConnectorConfig, } from '../types'; @@ -60,6 +62,8 @@ export const useIntegrationPicker = ({ dashboardUrl, }: UseIntegrationPickerProps) => { const [selectedIntegration, setSelectedIntegration] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(null); + const [selectedVersion, setSelectedVersion] = useState(null); const [formData, setFormData] = useState>({}); const [editingSecrets, setEditingSecrets] = useState>(new Set()); @@ -238,15 +242,18 @@ export const useIntegrationPicker = ({ }, [accountData, hubData]); useEffect(() => { - if (hubData && !selectedIntegration) { + if (hubData && !selectedIntegration && !selectedProvider) { const activeIntegrations = hubData.integrations.filter( (integration) => integration.active, ); if (activeIntegrations.length === 1) { + // Single integration total - auto-select both provider and config + setSelectedProvider(activeIntegrations[0].provider); + setSelectedVersion(activeIntegrations[0].version); setSelectedIntegration(activeIntegrations[0]); } } - }, [hubData, selectedIntegration]); + }, [hubData, selectedIntegration, selectedProvider]); const { data: connectorData, @@ -271,6 +278,89 @@ export const useIntegrationPicker = ({ ...RETRY_CONFIG, }); + // Fetch provider actions when a provider is selected (for auth config cards) + const { data: providerActionsData } = useQuery({ + queryKey: ['providerActions', selectedProvider], + queryFn: async () => { + if (!selectedProvider) return null; + return getProviderActions(baseUrl, token, selectedProvider); + }, + enabled: Boolean(selectedProvider) && !selectedIntegration, + ...RETRY_CONFIG, + }); + + // Deduplicated list of integrations (one per provider+version) for the connector list + const uniqueProviderIntegrations = useMemo(() => { + if (!hubData) return []; + const seen = new Set(); + return hubData.integrations.filter((integration) => { + const key = `${integration.provider}:${integration.version}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }, [hubData]); + + // All integrations for the selected provider+version, enriched with actions data + const providerIntegrations = useMemo(() => { + if (!selectedProvider || !hubData) return []; + const integrations = hubData.integrations.filter( + (i) => + i.provider === selectedProvider && + (!selectedVersion || i.version === selectedVersion), + ); + + if (!providerActionsData?.length) return integrations; + + // Find the matching provider meta (strict match by provider key AND version) + return integrations.map((integration) => { + const providerMeta = providerActionsData.find( + (p) => p.key === integration.provider && p.version === integration.version, + ); + + if (!providerMeta?.actions?.length) return integration; + + const actions: IntegrationAction[] = providerMeta.actions.map((a) => ({ + id: a.id, + name: a.name, + description: a.description, + })); + + return { + ...integration, + actions, + actions_count: + integration.actions_count ?? providerMeta.actions_count ?? actions.length, + }; + }); + }, [selectedProvider, selectedVersion, hubData, providerActionsData]); + + const hasOnlyOneProvider = useMemo(() => { + if (!hubData) return false; + const activeProviderVersions = new Set( + hubData.integrations.filter((i) => i.active).map((i) => `${i.provider}:${i.version}`), + ); + return activeProviderVersions.size <= 1; + }, [hubData]); + + const handleProviderSelect = useCallback((integration: Integration) => { + setSelectedProvider(integration.provider); + setSelectedVersion(integration.version); + }, []); + + const handleCreateNewAuthConfig = useCallback(() => { + if (!dashboardUrl) return; + const params = new URLSearchParams(); + if (selectedProvider) { + params.set('connectorKey', selectedProvider); + } + if (selectedVersion) { + params.set('connectorVersion', selectedVersion); + } + const query = params.toString(); + window.open(`${dashboardUrl}/auth_configs${query ? `?${query}` : ''}`, '_blank'); + }, [dashboardUrl, selectedProvider, selectedVersion]); + const { fields, guide } = useMemo(() => { if (!connectorData || !selectedIntegration) { const fields: ConnectorConfigField[] = []; @@ -696,6 +786,10 @@ export const useIntegrationPicker = ({ accountData, connectorData, selectedIntegration, + selectedProvider, + uniqueProviderIntegrations, + providerIntegrations, + hasOnlyOneProvider, fields, guide, @@ -713,6 +807,10 @@ export const useIntegrationPicker = ({ // Actions setSelectedIntegration, + setSelectedProvider, + setSelectedVersion, + handleProviderSelect, + handleCreateNewAuthConfig, setFormData: setFormDataCallback, setIsFormValid, handleConnect, diff --git a/src/modules/integration-picker/queries.ts b/src/modules/integration-picker/queries.ts index 60aa351..21f5cf9 100644 --- a/src/modules/integration-picker/queries.ts +++ b/src/modules/integration-picker/queries.ts @@ -5,6 +5,7 @@ import { ConnectorConfig, HubConnectorConfig, HubData, + ProviderActionsResponse, } from './types'; export const getHubData = async (token: string, baseUrl: string, provider?: string) => { @@ -85,6 +86,16 @@ export const updateAccount = async ({ }); }; +export const getProviderActions = async (baseUrl: string, token: string, provider: string) => { + return await getRequest({ + url: `${baseUrl}/hub/actions?provider=${encodeURIComponent(provider)}`, + headers: { + 'Content-Type': 'application/json', + 'x-hub-session-token': token, + }, + }); +}; + export const getAccountData = async (baseUrl: string, token: string, accountId: string) => { return await getRequest({ url: `${baseUrl}/hub/accounts/${accountId}`, diff --git a/src/modules/integration-picker/types.ts b/src/modules/integration-picker/types.ts index eba5aa9..2a903f0 100644 --- a/src/modules/integration-picker/types.ts +++ b/src/modules/integration-picker/types.ts @@ -1,3 +1,10 @@ +export interface IntegrationAction { + id?: string; + name: string; + description?: string; + url?: string; +} + export interface Integration { active: boolean; name: string; @@ -5,9 +12,15 @@ export interface Integration { type: string; version: string; authentication_config_key: string; + authentication_config_label?: string | null; environment: string; integration_id: string; logo_url: string; + connector_name?: string; + account_count?: number; + actions_count?: number; + triggers_count?: number; + actions?: IntegrationAction[]; } export interface HubData { @@ -110,6 +123,21 @@ export interface AccountData { integrationId: string; } +export interface ProviderActionMeta { + id?: string; + key?: string; + name: string; + description?: string; +} + +export interface ProviderActionsResponse { + key: string; + version: string; + name?: string; + actions?: ProviderActionMeta[]; + actions_count?: number; +} + export interface AccountCreationResponse { id: string; provider: string;