From 7cbe82de526eb906ce6ac8f58133ecdcfdcec42b Mon Sep 17 00:00:00 2001 From: obchain <167975049+obchain@users.noreply.github.com> Date: Wed, 20 May 2026 08:30:50 +0530 Subject: [PATCH] fix(composio): collect Dynamics 365 org name via required-fields registry (#2127) (#2181) --- .../composio/ComposioConnectModal.test.tsx | 113 +++++- .../composio/ComposioConnectModal.tsx | 336 +++++++++--------- .../composio/toolkitRequiredFields.test.ts | 82 +++++ .../composio/toolkitRequiredFields.ts | 127 +++++++ app/src/lib/i18n/chunks/ar-4.ts | 9 + app/src/lib/i18n/chunks/bn-4.ts | 9 + app/src/lib/i18n/chunks/en-4.ts | 9 + app/src/lib/i18n/chunks/es-4.ts | 9 + app/src/lib/i18n/chunks/fr-4.ts | 9 + app/src/lib/i18n/chunks/hi-4.ts | 9 + app/src/lib/i18n/chunks/id-4.ts | 9 + app/src/lib/i18n/chunks/it-4.ts | 9 + app/src/lib/i18n/chunks/pt-4.ts | 9 + app/src/lib/i18n/chunks/ru-4.ts | 9 + app/src/lib/i18n/chunks/zh-CN-4.ts | 8 + app/src/lib/i18n/en.ts | 9 + 16 files changed, 596 insertions(+), 169 deletions(-) create mode 100644 app/src/components/composio/toolkitRequiredFields.test.ts create mode 100644 app/src/components/composio/toolkitRequiredFields.ts diff --git a/app/src/components/composio/ComposioConnectModal.test.tsx b/app/src/components/composio/ComposioConnectModal.test.tsx index e41c1646e..a29599951 100644 --- a/app/src/components/composio/ComposioConnectModal.test.tsx +++ b/app/src/components/composio/ComposioConnectModal.test.tsx @@ -323,7 +323,7 @@ describe(' — Jira subdomain collection', () => { fireEvent.click(connectButton); await waitFor(() => { - expect(screen.getByText(/Please enter your Atlassian subdomain/i)).toBeInTheDocument(); + expect(screen.getByText(/This field is required/i)).toBeInTheDocument(); }); }); @@ -349,7 +349,7 @@ describe(' — Jira subdomain collection', () => { fireEvent.click(connectButton); await waitFor(() => { - expect(screen.getByText(/Please enter your Atlassian subdomain/i)).toBeInTheDocument(); + expect(screen.getByText(/This field is required/i)).toBeInTheDocument(); }); // Type to clear the error @@ -467,3 +467,112 @@ describe(' — needs-subdomain recovery phase', () => { }); }); }); + +// ── Dynamics 365 org_name required-field flow (#2127) ────────────────── + +describe(' — Dynamics 365 org_name collection (#2127)', () => { + const dynamicsToolkit = composioToolkitMeta('dynamics365'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the Dynamics 365 Organization Name input in the idle phase', () => { + render( {}} />); + + expect(screen.getByLabelText(/Dynamics 365 Organization Name/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('myorg')).toBeInTheDocument(); + // Suffix renders inside the input wrapper so users see the .crm.dynamics.com tail. + expect(screen.getByText('.crm.dynamics.com')).toBeInTheDocument(); + }); + + it('blocks submission when org name is empty and surfaces the generic required-field error', async () => { + render( {}} />); + + fireEvent.click(screen.getByRole('button', { name: /Connect Dynamics 365/i })); + + await waitFor(() => { + expect(screen.getByText(/This field is required/i)).toBeInTheDocument(); + }); + expect(authorize).not.toHaveBeenCalled(); + }); + + it('rejects a full URL with the subdomain-invalid message', async () => { + render( {}} />); + + const input = screen.getByPlaceholderText('myorg'); + fireEvent.change(input, { target: { value: 'https://myorg.crm.dynamics.com' } }); + fireEvent.click(screen.getByRole('button', { name: /Connect Dynamics 365/i })); + + await waitFor(() => { + expect(screen.getByText(/short subdomain only/i)).toBeInTheDocument(); + }); + expect(authorize).not.toHaveBeenCalled(); + }); + + it('forwards the trimmed org_name as extra_params on successful submit', async () => { + vi.mocked(authorize).mockResolvedValue({ + connectUrl: 'https://hosted.composio.dev/dynamics-token', + connectionId: 'ca_dyn_1', + }); + vi.mocked(composioApi.listConnections).mockResolvedValue({ connections: [] }); + + render( {}} />); + + fireEvent.change(screen.getByPlaceholderText('myorg'), { target: { value: ' myorg ' } }); + fireEvent.click(screen.getByRole('button', { name: /Connect Dynamics 365/i })); + + await waitFor(() => { + expect(authorize).toHaveBeenCalledWith('dynamics365', { org_name: 'myorg' }); + }); + }); + + it('transitions to the needs-fields recovery phase when Composio returns 612', async () => { + vi.mocked(authorize).mockRejectedValueOnce( + new Error( + 'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}' + ) + ); + + render( {}} />); + + fireEvent.change(screen.getByPlaceholderText('myorg'), { target: { value: 'myorg' } }); + fireEvent.click(screen.getByRole('button', { name: /Connect Dynamics 365/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Retry connection/i })).toBeInTheDocument(); + expect(screen.getByText(/we need a bit more information/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/ConnectedAccount_MissingRequiredFields/i)).not.toBeInTheDocument(); + }); +}); + +// ── WhatsApp WABA id parity check — registry refactor must not regress (#2127) ─ + +describe(' — WhatsApp WABA id parity (#2127)', () => { + const whatsappToolkit = composioToolkitMeta('whatsapp'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('still renders the WABA id input and forwards waba_id as extra_params', async () => { + vi.mocked(authorize).mockResolvedValue({ + connectUrl: 'https://hosted.composio.dev/wa-token', + connectionId: 'ca_wa_1', + }); + vi.mocked(composioApi.listConnections).mockResolvedValue({ connections: [] }); + + render( {}} />); + + expect(screen.getByLabelText(/WhatsApp Business Account ID/i)).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText(/123456789012345/), { + target: { value: '999000111222333' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Connect WhatsApp/i })); + + await waitFor(() => { + expect(authorize).toHaveBeenCalledWith('whatsapp', { waba_id: '999000111222333' }); + }); + }); +}); diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx index de1fdf298..e1cfe7e58 100644 --- a/app/src/components/composio/ComposioConnectModal.tsx +++ b/app/src/components/composio/ComposioConnectModal.tsx @@ -4,22 +4,26 @@ * Mirrors the flow, positioning, and portal/backdrop plumbing of * `SkillSetupModal` so the two feel identical to the user: * - * disconnected → "Connect" button → POST composio_authorize → - * open connectUrl via tauri-opener → poll listConnections until - * the toolkit flips to ACTIVE → "Connected" success screen with - * a "Disconnect" action. + * disconnected → collect provider-specific required fields (if any) → + * "Connect" button → POST composio_authorize → open connectUrl via + * tauri-opener → poll listConnections until the toolkit flips to + * ACTIVE → "Connected" success screen with a "Disconnect" action. * - * Jira-specific flow: the Atlassian subdomain is collected upfront (before - * the authorize call) via an inline input. If Composio returns - * `ConnectedAccount_MissingRequiredFields` (error code 612) for any toolkit, - * the modal transitions to a `needs-subdomain` phase so the user can supply - * the missing field and retry — instead of seeing the raw backend error. + * Provider-specific required fields (Jira subdomain, WhatsApp WABA id, + * Dynamics 365 org name, …) are declared in the + * [`toolkitRequiredFields`] registry rather than hard-coded as per-toolkit + * booleans here (#2127). If Composio still returns + * `ConnectedAccount_MissingRequiredFields` (error code 612) for any toolkit + * — e.g. a new required field landed backend-side before the registry was + * updated — the modal transitions to a `needs-fields` recovery phase that + * collects the same registry fields and retries, instead of surfacing the + * raw backend error. * * Redundant refetches from the polling hook in `useComposioIntegrations` * keep the Skills page badge in sync too, so the card reflects the new * state as soon as the modal closes. */ -import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { @@ -37,6 +41,11 @@ import { import { useT } from '../../lib/i18n/I18nContext'; import { openUrl } from '../../utils/openUrl'; import type { ComposioToolkitMeta } from './toolkitMeta'; +import { + getRequiredFieldsForToolkit, + type ToolkitRequiredField, + validateRequiredFieldValues, +} from './toolkitRequiredFields'; import TriggerToggles from './TriggerToggles'; function deriveConnectionLabel(c: ComposioConnection): string | null { @@ -59,6 +68,10 @@ const COMPOSIO_MISSING_REQUIRED_FIELDS_SLUG = 'ConnectedAccount_MissingRequiredF * `.atlassian.net` — alphanumerics and hyphens, 1-63 chars, * no leading/trailing hyphens. Rejects full URLs so users are not confused * about what to paste. + * + * Retained for backwards compatibility with consumers that imported the + * helper directly. The registry in `toolkitRequiredFields.ts` uses the + * same regex via `validateSubdomainLabel`, shared with Dynamics 365. */ export function isValidAtlassianSubdomain(value: string): boolean { return /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/i.test(value.trim()); @@ -124,7 +137,10 @@ export function sanitizeAuthError(err: unknown): string { type Phase = | 'idle' - | 'needs-subdomain' + // Recovery phase entered when Composio returns + // `ConnectedAccount_MissingRequiredFields` (code 612) — the user is asked + // for the same registry fields again so they can retry. + | 'needs-fields' | 'authorizing' | 'waiting' | 'connected' @@ -171,13 +187,15 @@ export default function ComposioConnectModal({ ); const [error, setError] = useState(null); const [connectUrl, setConnectUrl] = useState(null); - // WhatsApp Business requires a WABA ID before the OAuth flow can start. - const [wabaId, setWabaId] = useState(''); - const needsWabaId = toolkit.slug === 'whatsapp'; - // Jira requires an Atlassian subdomain (e.g. "acme" for acme.atlassian.net). - const [atlassianSubdomain, setAtlassianSubdomain] = useState(''); - const [subdomainError, setSubdomainError] = useState(null); - const needsAtlassianSubdomain = toolkit.slug === 'jira'; + + // Provider-specific required fields are sourced from the declarative + // registry rather than per-toolkit booleans (#2127). New providers + // (Dynamics 365 `org_name`, future toolkits, …) only need a registry + // entry — no per-toolkit branches inside this component. + const requiredFields = useMemo(() => getRequiredFieldsForToolkit(toolkit.slug), [toolkit.slug]); + const [fieldValues, setFieldValues] = useState>({}); + const [fieldErrors, setFieldErrors] = useState>({}); + const [activeConnection, setActiveConnection] = useState( connection ); @@ -297,48 +315,36 @@ export default function ComposioConnectModal({ }, []); /** - * Validate and collect required fields before calling authorize. - * For Jira: subdomain must match the expected Atlassian format. - * Returns false (and surfaces an inline validation message) when - * a required field is missing or malformed. + * Validate registry-declared required fields. Populates `fieldErrors` + * with per-field i18n keys when any are missing or malformed, and + * returns true only when every field is valid. */ const validateRequiredFields = useCallback((): boolean => { - if (needsWabaId && !wabaId.trim()) { - setError(t('composio.connect.wabaIdRequired')); - return false; - } - if (needsAtlassianSubdomain) { - const trimmed = atlassianSubdomain.trim(); - if (!trimmed) { - setSubdomainError(t('composio.connect.subdomainRequired')); - return false; - } - if (!isValidAtlassianSubdomain(trimmed)) { - setSubdomainError(t('composio.connect.subdomainInvalid')); - return false; - } - } - return true; - }, [needsWabaId, wabaId, needsAtlassianSubdomain, atlassianSubdomain]); + if (requiredFields.length === 0) return true; + const errors = validateRequiredFieldValues(requiredFields, fieldValues); + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }, [requiredFields, fieldValues]); const handleConnect = useCallback(async () => { if (!validateRequiredFields()) return; setPhase('authorizing'); setError(null); - setSubdomainError(null); + setFieldErrors({}); setConnectUrl(null); const extraParams: Record = {}; - if (needsWabaId) extraParams.waba_id = wabaId.trim(); - if (needsAtlassianSubdomain && atlassianSubdomain.trim()) { - extraParams.subdomain = atlassianSubdomain.trim(); + for (const field of requiredFields) { + const value = (fieldValues[field.key] ?? '').trim(); + if (value) extraParams[field.key] = value; } console.debug( - '[composio][authorize] → toolkit=%s has_extra_params=%s', + '[composio][authorize] → toolkit=%s has_extra_params=%s field_count=%d', toolkit.slug, - Object.keys(extraParams).length > 0 + Object.keys(extraParams).length > 0, + requiredFields.length ); try { @@ -363,20 +369,20 @@ export default function ComposioConnectModal({ ); if (isMissingRequiredFieldsError(err)) { - // Composio reported a missing required field (code 612). For Atlassian - // toolkits, transition to the dedicated needs-subdomain phase so the - // user can supply the field and retry. For other toolkits, surface a - // sanitized message in the error phase — the needs-subdomain UI - // currently only collects an Atlassian subdomain, so showing it for - // non-Atlassian toolkits would be misleading and the Retry loop would - // never succeed. + // Composio reported a missing required field (code 612). When the + // registry has any required-field entries for this toolkit, drop + // into the `needs-fields` recovery phase so the user can supply the + // missing value and retry inline. When the registry does not yet + // know about the missing field — e.g. Composio backend just added a + // new required field — fall back to a sanitized error message so + // the user is not stuck on a recovery form that cannot succeed. console.debug( - '[composio][authorize] missing-required-fields toolkit=%s needsAtlassianSubdomain=%s', + '[composio][authorize] missing-required-fields toolkit=%s registry_field_count=%d', toolkit.slug, - needsAtlassianSubdomain + requiredFields.length ); - if (needsAtlassianSubdomain) { - setPhase('needs-subdomain'); + if (requiredFields.length > 0) { + setPhase('needs-fields'); setError(null); } else { setPhase('error'); @@ -388,15 +394,7 @@ export default function ComposioConnectModal({ setPhase('error'); setError(sanitizeAuthError(err)); } - }, [ - validateRequiredFields, - needsWabaId, - wabaId, - needsAtlassianSubdomain, - atlassianSubdomain, - startPolling, - toolkit.slug, - ]); + }, [validateRequiredFields, requiredFields, fieldValues, startPolling, toolkit.slug]); // Fetch the stored scope pref whenever the modal lands in the // 'connected' phase. Re-fetching each time we transition (rather @@ -561,44 +559,21 @@ export default function ComposioConnectModal({ {t('composio.connect.permissionsNoteSuffix')}

- {needsWabaId && ( -
- - ) => { - setWabaId(e.target.value); - if (error) setError(null); - }} - placeholder="e.g. 123456789012345" - className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100" - /> -

- Find it via GET /me/businesses then{' '} - - GET /{business_id}/owned_whatsapp_business_accounts - {' '} - using your Meta access token. -

-
- )} - {needsAtlassianSubdomain && ( - { - setAtlassianSubdomain(v); - if (subdomainError) setSubdomainError(null); - }} - /> - )} + { + setFieldValues(prev => ({ ...prev, [key]: v })); + if (fieldErrors[key]) { + setFieldErrors(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + } + }} + /> {error && phase === 'idle' &&

{error}

}