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}
}
)}
- {phase === 'needs-subdomain' && (
+ {phase === 'needs-fields' && (
<>
- {`${t('composio.connect.needsSubdomain')} ${toolkit.name}, ${t('composio.connect.needsSubdomainSuffix')}`}
+ {`${t('composio.connect.needsFieldsPrefix')} ${toolkit.name} ${t('composio.connect.needsFieldsSuffix')}`}
- {
- setAtlassianSubdomain(v);
- if (subdomainError) setSubdomainError(null);
+ {
+ setFieldValues(prev => ({ ...prev, [key]: v }));
+ if (fieldErrors[key]) {
+ setFieldErrors(prev => {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ });
+ }
}}
- autoFocus
/>