fix(composio): collect Dynamics 365 org name via required-fields registry (#2127) (#2181)

This commit is contained in:
obchain
2026-05-19 20:00:50 -07:00
committed by GitHub
parent 34bcf343dc
commit 7cbe82de52
16 changed files with 596 additions and 169 deletions
@@ -323,7 +323,7 @@ describe('<ComposioConnectModal> — 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('<ComposioConnectModal> — 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('<ComposioConnectModal> — needs-subdomain recovery phase', () => {
});
});
});
// ── Dynamics 365 org_name required-field flow (#2127) ──────────────────
describe('<ComposioConnectModal> — 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(<ComposioConnectModal toolkit={dynamicsToolkit} onClose={() => {}} />);
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(<ComposioConnectModal toolkit={dynamicsToolkit} onClose={() => {}} />);
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(<ComposioConnectModal toolkit={dynamicsToolkit} onClose={() => {}} />);
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(<ComposioConnectModal toolkit={dynamicsToolkit} onClose={() => {}} />);
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(<ComposioConnectModal toolkit={dynamicsToolkit} onClose={() => {}} />);
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('<ComposioConnectModal> — 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(<ComposioConnectModal toolkit={whatsappToolkit} onClose={() => {}} />);
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' });
});
});
});
@@ -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
* `<subdomain>.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<string | null>(null);
const [connectUrl, setConnectUrl] = useState<string | null>(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<string | null>(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<Record<string, string>>({});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [activeConnection, setActiveConnection] = useState<ComposioConnection | undefined>(
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<string, string> = {};
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')}
</p>
</div>
{needsWabaId && (
<div className="space-y-1.5">
<label
htmlFor="waba-id-input"
className="block text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('composio.connect.wabaIdLabel')}
<span className="ml-1 text-coral-500">*</span>
</label>
<input
id="waba-id-input"
type="text"
value={wabaId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
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"
/>
<p className="text-[11px] leading-relaxed text-stone-400 dark:text-neutral-500">
Find it via <span className="font-mono">GET /me/businesses</span> then{' '}
<span className="font-mono">
GET /&#123;business_id&#125;/owned_whatsapp_business_accounts
</span>{' '}
using your Meta access token.
</p>
</div>
)}
{needsAtlassianSubdomain && (
<AtlassianSubdomainInput
value={atlassianSubdomain}
error={subdomainError}
onChange={v => {
setAtlassianSubdomain(v);
if (subdomainError) setSubdomainError(null);
}}
/>
)}
<RequiredFieldsForm
fields={requiredFields}
values={fieldValues}
errors={fieldErrors}
onChange={(key, v) => {
setFieldValues(prev => ({ ...prev, [key]: v }));
if (fieldErrors[key]) {
setFieldErrors(prev => {
const next = { ...prev };
delete next[key];
return next;
});
}
}}
/>
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
<button
type="button"
@@ -609,19 +584,26 @@ export default function ComposioConnectModal({
</>
)}
{phase === 'needs-subdomain' && (
{phase === 'needs-fields' && (
<>
<p className="text-sm text-stone-600 dark:text-neutral-300">
{`${t('composio.connect.needsSubdomain')} ${toolkit.name}, ${t('composio.connect.needsSubdomainSuffix')}`}
{`${t('composio.connect.needsFieldsPrefix')} ${toolkit.name} ${t('composio.connect.needsFieldsSuffix')}`}
</p>
<AtlassianSubdomainInput
value={atlassianSubdomain}
error={subdomainError}
onChange={v => {
setAtlassianSubdomain(v);
if (subdomainError) setSubdomainError(null);
<RequiredFieldsForm
fields={requiredFields}
values={fieldValues}
errors={fieldErrors}
autoFocusFirst
onChange={(key, v) => {
setFieldValues(prev => ({ ...prev, [key]: v }));
if (fieldErrors[key]) {
setFieldErrors(prev => {
const next = { ...prev };
delete next[key];
return next;
});
}
}}
autoFocus
/>
<button
type="button"
@@ -633,7 +615,7 @@ export default function ComposioConnectModal({
type="button"
onClick={() => {
setPhase('idle');
setSubdomainError(null);
setFieldErrors({});
setError(null);
}}
className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 text-xs font-medium py-2 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors">
@@ -852,67 +834,87 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
);
}
// ── Atlassian subdomain input ───────────────────────────────────────
// ── Generic required-fields form ────────────────────────────────────
interface AtlassianSubdomainInputProps {
value: string;
error: string | null;
onChange: (value: string) => void;
/** Autofocus the input on mount (used in the needs-subdomain recovery phase). */
autoFocus?: boolean;
interface RequiredFieldsFormProps {
fields: readonly ToolkitRequiredField[];
values: Record<string, string>;
errors: Record<string, string>;
onChange: (key: string, value: string) => void;
/** Autofocus the first input on mount (used by the `needs-fields` recovery phase). */
autoFocusFirst?: boolean;
}
/**
* Reusable inline subdomain collector for Atlassian-hosted toolkits (Jira,
* Confluence). Validates the short-form subdomain (`acme` for
* `acme.atlassian.net`) and surfaces an inline validation message when the
* user types a full URL or an invalid value.
* Generic renderer for provider-specific required fields declared in
* `toolkitRequiredFields.ts`. Replaces the per-toolkit
* `AtlassianSubdomainInput` / `WabaIdInput` blocks (#2127). Each field
* shows a label, optional fixed suffix inside the input
* (e.g. `.atlassian.net`), an optional hint, and an inline error message
* driven by the `errors` map (keyed by field key, value is an i18n key).
*/
function AtlassianSubdomainInput({
value,
error,
function RequiredFieldsForm({
fields,
values,
errors,
onChange,
autoFocus,
}: AtlassianSubdomainInputProps) {
autoFocusFirst,
}: RequiredFieldsFormProps) {
const { t } = useT();
if (fields.length === 0) return null;
return (
<div className="space-y-1.5">
<label
htmlFor="atlassian-subdomain-input"
className="block text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('composio.connect.atlassianSubdomainLabel')}
<span className="ml-1 text-coral-500">*</span>
</label>
<div className="flex items-center rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:border-primary-400 focus-within:ring-2 focus-within:ring-primary-100 overflow-hidden">
<input
id="atlassian-subdomain-input"
type="text"
value={value}
autoFocus={autoFocus}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
placeholder="your-subdomain"
aria-describedby="atlassian-subdomain-hint"
aria-invalid={!!error}
className="flex-1 min-w-0 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 bg-transparent focus:outline-none"
/>
<span className="pr-3 text-xs text-stone-400 dark:text-neutral-500 select-none whitespace-nowrap">
.atlassian.net
</span>
</div>
{/* Always render the hint paragraph with the same id so aria-describedby resolves
correctly regardless of error state. When there is an error, role="alert"
causes screen readers to announce the message immediately. */}
{error ? (
<p id="atlassian-subdomain-hint" role="alert" className="text-[11px] text-coral-600">
{error}
</p>
) : (
<p
id="atlassian-subdomain-hint"
className="text-[11px] leading-relaxed text-stone-400 dark:text-neutral-500">
{t('composio.connect.atlassianSubdomainHint')}
</p>
)}
</div>
<>
{fields.map((field, idx) => {
const inputId = `composio-required-${field.key}`;
const hintId = `${inputId}-hint`;
const value = values[field.key] ?? '';
const errorKey = errors[field.key];
const errorText = errorKey ? t(errorKey) : null;
return (
<div key={field.key} className="space-y-1.5">
<label
htmlFor={inputId}
className="block text-xs font-medium text-stone-700 dark:text-neutral-200">
{t(field.labelKey)}
<span className="ml-1 text-coral-500">*</span>
</label>
<div className="flex items-center rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:border-primary-400 focus-within:ring-2 focus-within:ring-primary-100 overflow-hidden">
<input
id={inputId}
data-testid={inputId}
type="text"
value={value}
autoFocus={autoFocusFirst && idx === 0}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(field.key, e.target.value)}
placeholder={field.placeholder}
aria-describedby={hintId}
aria-invalid={!!errorText}
className="flex-1 min-w-0 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 bg-transparent focus:outline-none"
/>
{field.suffix && (
<span className="pr-3 text-xs text-stone-400 dark:text-neutral-500 select-none whitespace-nowrap">
{field.suffix}
</span>
)}
</div>
{/* Always render the hint paragraph with the same id so
aria-describedby resolves regardless of error state. */}
{errorText ? (
<p id={hintId} role="alert" className="text-[11px] text-coral-600">
{errorText}
</p>
) : (
field.hintKey && (
<p
id={hintId}
className="text-[11px] leading-relaxed text-stone-400 dark:text-neutral-500">
{t(field.hintKey)}
</p>
)
)}
</div>
);
})}
</>
);
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
getRequiredFieldsForToolkit,
TOOLKIT_REQUIRED_FIELDS,
validateRequiredFieldValues,
} from './toolkitRequiredFields';
describe('toolkitRequiredFields registry', () => {
it('exposes the Dynamics 365 org_name field with the .crm.dynamics.com suffix', () => {
const fields = getRequiredFieldsForToolkit('dynamics365');
expect(fields).toHaveLength(1);
expect(fields[0].key).toBe('org_name');
expect(fields[0].suffix).toBe('.crm.dynamics.com');
expect(fields[0].placeholder).toBe('myorg');
});
it('exposes the Jira subdomain field with the .atlassian.net suffix', () => {
const fields = getRequiredFieldsForToolkit('jira');
expect(fields).toHaveLength(1);
expect(fields[0].key).toBe('subdomain');
expect(fields[0].suffix).toBe('.atlassian.net');
});
it('exposes the WhatsApp waba_id field with no suffix', () => {
const fields = getRequiredFieldsForToolkit('whatsapp');
expect(fields).toHaveLength(1);
expect(fields[0].key).toBe('waba_id');
expect(fields[0].suffix).toBeUndefined();
});
it('returns an empty array for toolkits with no required fields (e.g. gmail)', () => {
expect(getRequiredFieldsForToolkit('gmail')).toEqual([]);
expect(getRequiredFieldsForToolkit('unknown-future-toolkit')).toEqual([]);
});
it('is exposed as a readonly frozen object', () => {
expect(Object.isFrozen(TOOLKIT_REQUIRED_FIELDS)).toBe(true);
});
});
describe('validateRequiredFieldValues', () => {
const dynamicsFields = getRequiredFieldsForToolkit('dynamics365');
it('returns an empty error map when all required fields are valid', () => {
expect(validateRequiredFieldValues(dynamicsFields, { org_name: 'acme' })).toEqual({});
});
it('flags empty values with the requiredFieldEmpty i18n key', () => {
expect(validateRequiredFieldValues(dynamicsFields, {})).toEqual({
org_name: 'composio.connect.requiredFieldEmpty',
});
expect(validateRequiredFieldValues(dynamicsFields, { org_name: ' ' })).toEqual({
org_name: 'composio.connect.requiredFieldEmpty',
});
});
it('flags a full URL with the subdomain-invalid i18n key (custom validator)', () => {
expect(
validateRequiredFieldValues(dynamicsFields, { org_name: 'https://acme.crm.dynamics.com' })
).toEqual({ org_name: 'composio.connect.subdomainInvalid' });
});
it('flags leading/trailing hyphens for subdomain fields', () => {
expect(validateRequiredFieldValues(dynamicsFields, { org_name: '-acme' })).toEqual({
org_name: 'composio.connect.subdomainInvalid',
});
expect(validateRequiredFieldValues(dynamicsFields, { org_name: 'acme-' })).toEqual({
org_name: 'composio.connect.subdomainInvalid',
});
});
it('skips custom validation for fields without a validator (whatsapp waba_id)', () => {
const whatsappFields = getRequiredFieldsForToolkit('whatsapp');
// Non-empty waba_id is accepted with no format check (Composio validates server-side).
expect(validateRequiredFieldValues(whatsappFields, { waba_id: '123abc' })).toEqual({});
// Empty is still rejected with the generic required-field error.
expect(validateRequiredFieldValues(whatsappFields, {})).toEqual({
waba_id: 'composio.connect.requiredFieldEmpty',
});
});
});
@@ -0,0 +1,127 @@
/**
* Per-toolkit declarative registry for provider-specific required fields the
* Composio connect flow must collect *before* calling
* `openhuman.composio_authorize`. Without these fields the backend returns
* `ConnectedAccount_MissingRequiredFields` (code 612) and the user is left
* with an unhelpful raw error (#2127, #1702).
*
* Adding a new provider-specific field is intentionally a single registry
* entry — no per-toolkit branches inside `ComposioConnectModal` anymore.
*
* Field values are forwarded verbatim into the `extra_params` object of
* `composio_authorize`; the key on each entry is also the param name the
* backend expects (e.g. `waba_id`, `subdomain`, `org_name`).
*/
/**
* String-typed i18n keys (the `useT().t` callback accepts plain strings;
* see `app/src/lib/i18n/I18nContext.tsx`). Kept as an alias here so the
* intent — "this is an i18n lookup key, not arbitrary text" — is local to
* the registry without forcing the rest of the codebase to adopt a strict
* key union.
*/
type TranslationKey = string;
export interface ToolkitRequiredField {
/**
* Field id. Also used verbatim as the `extra_params` key forwarded to
* `openhuman.composio_authorize`, so it must match exactly what the
* Composio backend expects for the toolkit.
*/
key: string;
/** i18n key for the input label. */
labelKey: TranslationKey;
/** Optional i18n key for the hint paragraph rendered below the input. */
hintKey?: TranslationKey;
/** Optional placeholder text shown when the input is empty. */
placeholder?: string;
/**
* Optional fixed suffix rendered inside the input (e.g. `.atlassian.net`).
* Purely cosmetic — never included in the submitted value.
*/
suffix?: string;
/**
* Validate the trimmed value. Return null when valid, or the i18n key for
* an inline error message when invalid. When omitted, only the
* non-empty check (`required`) is enforced.
*/
validate?: (value: string) => TranslationKey | null;
}
/**
* Subdomain-style validator shared between Atlassian (`<sub>.atlassian.net`)
* and Dynamics 365 (`<myorg>.crm.dynamics.com`) — both reject full URLs and
* accept the short DNS-label form (1-63 chars, alphanumerics + hyphens, no
* leading/trailing hyphen).
*/
function validateSubdomainLabel(value: string): TranslationKey | null {
const trimmed = value.trim();
if (!/^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/i.test(trimmed)) {
return 'composio.connect.subdomainInvalid';
}
return null;
}
/**
* Registry of toolkit slug → required-field definitions. Empty list (or
* absent entry) means the toolkit has no upfront required fields and the
* existing OAuth-only flow runs unchanged.
*/
export const TOOLKIT_REQUIRED_FIELDS: Readonly<Record<string, readonly ToolkitRequiredField[]>> =
Object.freeze({
whatsapp: [
{
key: 'waba_id',
labelKey: 'composio.connect.wabaIdLabel',
hintKey: 'composio.connect.wabaIdHint',
placeholder: 'e.g. 123456789012345',
},
],
jira: [
{
key: 'subdomain',
labelKey: 'composio.connect.atlassianSubdomainLabel',
hintKey: 'composio.connect.atlassianSubdomainHint',
placeholder: 'your-subdomain',
suffix: '.atlassian.net',
validate: validateSubdomainLabel,
},
],
dynamics365: [
{
key: 'org_name',
labelKey: 'composio.connect.dynamicsOrgNameLabel',
hintKey: 'composio.connect.dynamicsOrgNameHint',
placeholder: 'myorg',
suffix: '.crm.dynamics.com',
validate: validateSubdomainLabel,
},
],
});
/** Return the required-field list for a toolkit slug (empty when none). */
export function getRequiredFieldsForToolkit(slug: string): readonly ToolkitRequiredField[] {
return TOOLKIT_REQUIRED_FIELDS[slug] ?? [];
}
/**
* Validate a values map against a toolkit's required-field definitions.
* Returns a map of field-key → i18n error key. Empty map means all valid.
*/
export function validateRequiredFieldValues(
fields: readonly ToolkitRequiredField[],
values: Record<string, string>
): Record<string, TranslationKey> {
const errors: Record<string, TranslationKey> = {};
for (const field of fields) {
const value = (values[field.key] ?? '').trim();
if (!value) {
errors[field.key] = 'composio.connect.requiredFieldEmpty';
continue;
}
const customError = field.validate?.(value);
if (customError) {
errors[field.key] = customError;
}
}
return errors;
}
+9
View File
@@ -46,6 +46,15 @@ const ar4: TranslationMap = {
'composio.connect.subdomainInvalid':
'أدخل النطاق الفرعي القصير فقط (مثل "acme")، وليس الرابط الكامل. يجب أن يحتوي فقط على أحرف وأرقام وشُرَط.',
'composio.connect.subdomainRequired': 'يرجى إدخال نطاقك الفرعي في Atlassian للمتابعة.',
'composio.connect.dynamicsOrgNameLabel': 'اسم مؤسسة Dynamics 365',
'composio.connect.dynamicsOrgNameHint':
'على سبيل المثال، "myorg" لـ myorg.crm.dynamics.com. أدخل اسم المؤسسة المختصر فقط، وليس الرابط الكامل.',
'composio.connect.needsFieldsPrefix': 'للاتصال',
'composio.connect.needsFieldsSuffix':
'نحتاج إلى مزيد من المعلومات. املأ الحقول الناقصة أدناه وحاول مرة أخرى.',
'composio.connect.requiredFieldEmpty': 'هذا الحقل مطلوب.',
'composio.connect.wabaIdHint':
'احصل عليه عبر GET /me/businesses ثم GET /{business_id}/owned_whatsapp_business_accounts باستخدام رمز وصول Meta الخاص بك.',
'composio.connect.wabaIdLabel': 'تسمية معرف WABA',
'composio.connect.wabaIdRequired': 'يرجى إدخال معرف حساب WhatsApp Business (WABA ID) للمتابعة.',
'composio.connect.waitingFor': 'بانتظار',
+9
View File
@@ -46,6 +46,15 @@ const bn4: TranslationMap = {
'composio.connect.subdomainInvalid':
'শুধু সংক্ষিপ্ত সাবডোমেইন লিখুন (যেমন "acme"), পুরো URL নয়। এতে শুধু অক্ষর, সংখ্যা এবং হাইফেন থাকা উচিত।',
'composio.connect.subdomainRequired': 'চালিয়ে যেতে আপনার Atlassian সাবডোমেইন দিন।',
'composio.connect.dynamicsOrgNameLabel': 'Dynamics 365 সংস্থার নাম',
'composio.connect.dynamicsOrgNameHint':
'উদাহরণস্বরূপ, myorg.crm.dynamics.com-এর জন্য "myorg"। সম্পূর্ণ URL নয়, শুধু সংক্ষিপ্ত সংস্থার নাম লিখুন।',
'composio.connect.needsFieldsPrefix': 'সংযোগ করতে',
'composio.connect.needsFieldsSuffix':
'আমাদের আরও কিছু তথ্য প্রয়োজন। নিচের অনুপস্থিত ফিল্ডগুলি পূরণ করুন এবং আবার চেষ্টা করুন।',
'composio.connect.requiredFieldEmpty': 'এই ফিল্ডটি আবশ্যক।',
'composio.connect.wabaIdHint':
'আপনার Meta অ্যাক্সেস টোকেন ব্যবহার করে GET /me/businesses তারপর GET /{business_id}/owned_whatsapp_business_accounts এর মাধ্যমে এটি খুঁজে পান।',
'composio.connect.wabaIdLabel': 'WABA ID লেবেল',
'composio.connect.wabaIdRequired':
'চালিয়ে যেতে আপনার WhatsApp Business Account ID (WABA ID) দিন।',
+9
View File
@@ -46,6 +46,15 @@ const en4: TranslationMap = {
'composio.connect.subdomainInvalid':
'Enter the short subdomain only (e.g. "acme"), not the full URL. It should contain only letters, numbers, and hyphens.',
'composio.connect.subdomainRequired': 'Please enter your Atlassian subdomain to continue.',
'composio.connect.dynamicsOrgNameLabel': 'Dynamics 365 Organization Name',
'composio.connect.dynamicsOrgNameHint':
'For example, "myorg" for myorg.crm.dynamics.com. Enter the short org name only, not the full URL.',
'composio.connect.needsFieldsPrefix': 'To connect',
'composio.connect.needsFieldsSuffix':
'we need a bit more information. Fill in the missing fields below and try again.',
'composio.connect.requiredFieldEmpty': 'This field is required.',
'composio.connect.wabaIdHint':
'Find it via GET /me/businesses then GET /{business_id}/owned_whatsapp_business_accounts using your Meta access token.',
'composio.connect.wabaIdLabel': 'Waba id label',
'composio.connect.wabaIdRequired':
'Please enter your WhatsApp Business Account ID (WABA ID) to continue.',
+9
View File
@@ -46,6 +46,15 @@ const es4: TranslationMap = {
'composio.connect.subdomainInvalid':
'Introduce solo el subdominio corto (p. ej. "acme"), no la URL completa. Debe contener solo letras, números y guiones.',
'composio.connect.subdomainRequired': 'Ingresa tu subdominio de Atlassian para continuar.',
'composio.connect.dynamicsOrgNameLabel': 'Nombre de la organización de Dynamics 365',
'composio.connect.dynamicsOrgNameHint':
'Por ejemplo, "myorg" para myorg.crm.dynamics.com. Introduce solo el nombre corto de la organización, no la URL completa.',
'composio.connect.needsFieldsPrefix': 'Para conectar',
'composio.connect.needsFieldsSuffix':
'necesitamos un poco más de información. Completa los campos que faltan abajo y vuelve a intentarlo.',
'composio.connect.requiredFieldEmpty': 'Este campo es obligatorio.',
'composio.connect.wabaIdHint':
'Encuéntralo mediante GET /me/businesses y luego GET /{business_id}/owned_whatsapp_business_accounts usando tu token de acceso de Meta.',
'composio.connect.wabaIdLabel': 'Etiqueta de ID de WABA',
'composio.connect.wabaIdRequired':
'Ingresa tu ID de cuenta de WhatsApp Business (WABA ID) para continuar.',
+9
View File
@@ -46,6 +46,15 @@ const fr4: TranslationMap = {
'composio.connect.subdomainInvalid':
'Saisissez uniquement le sous-domaine court (par ex. "acme"), pas l\'URL complète. Il doit contenir uniquement des lettres, chiffres et tirets.',
'composio.connect.subdomainRequired': 'Saisis ton sous-domaine Atlassian pour continuer.',
'composio.connect.dynamicsOrgNameLabel': "Nom de l'organisation Dynamics 365",
'composio.connect.dynamicsOrgNameHint':
'Par exemple, "myorg" pour myorg.crm.dynamics.com. Saisis uniquement le nom court de l\'organisation, pas l\'URL complète.',
'composio.connect.needsFieldsPrefix': 'Pour connecter',
'composio.connect.needsFieldsSuffix':
"nous avons besoin d'un peu plus d'informations. Remplis les champs manquants ci-dessous et réessaie.",
'composio.connect.requiredFieldEmpty': 'Ce champ est obligatoire.',
'composio.connect.wabaIdHint':
"Trouve-le via GET /me/businesses puis GET /{business_id}/owned_whatsapp_business_accounts en utilisant ton jeton d'accès Meta.",
'composio.connect.wabaIdLabel': "Libellé de l'identifiant WABA",
'composio.connect.wabaIdRequired':
'Saisis ton identifiant WhatsApp Business Account (WABA ID) pour continuer.',
+9
View File
@@ -46,6 +46,15 @@ const hi4: TranslationMap = {
'composio.connect.subdomainInvalid':
'केवल छोटा सबडोमेन दर्ज करें (जैसे "acme"), पूर्ण URL नहीं। इसमें केवल अक्षर, संख्याएँ और हाइफ़न होने चाहिए।',
'composio.connect.subdomainRequired': 'जारी रखने के लिए अपना Atlassian subdomain डालें।',
'composio.connect.dynamicsOrgNameLabel': 'Dynamics 365 संगठन का नाम',
'composio.connect.dynamicsOrgNameHint':
'उदाहरण के लिए, myorg.crm.dynamics.com के लिए "myorg"। केवल छोटा संगठन नाम दर्ज करें, पूरा URL नहीं।',
'composio.connect.needsFieldsPrefix': 'कनेक्ट करने के लिए',
'composio.connect.needsFieldsSuffix':
'हमें कुछ अतिरिक्त जानकारी चाहिए। नीचे लापता फ़ील्ड भरें और फिर से प्रयास करें।',
'composio.connect.requiredFieldEmpty': 'यह फ़ील्ड आवश्यक है।',
'composio.connect.wabaIdHint':
'अपने Meta एक्सेस टोकन का उपयोग करके GET /me/businesses फिर GET /{business_id}/owned_whatsapp_business_accounts के माध्यम से इसे प्राप्त करें।',
'composio.connect.wabaIdLabel': 'Waba आईडी लेबल',
'composio.connect.wabaIdRequired':
'जारी रखने के लिए अपना WhatsApp Business Account ID (WABA ID) डालें।',
+9
View File
@@ -46,6 +46,15 @@ const id4: TranslationMap = {
'composio.connect.subdomainInvalid':
'Masukkan hanya subdomain pendek (mis. "acme"), bukan URL lengkap. Hanya boleh berisi huruf, angka, dan tanda hubung.',
'composio.connect.subdomainRequired': 'Masukkan subdomain Atlassian Anda untuk melanjutkan.',
'composio.connect.dynamicsOrgNameLabel': 'Nama Organisasi Dynamics 365',
'composio.connect.dynamicsOrgNameHint':
'Misalnya, "myorg" untuk myorg.crm.dynamics.com. Masukkan nama organisasi pendek saja, bukan URL lengkap.',
'composio.connect.needsFieldsPrefix': 'Untuk menghubungkan',
'composio.connect.needsFieldsSuffix':
'kami memerlukan informasi tambahan. Isi bidang yang hilang di bawah dan coba lagi.',
'composio.connect.requiredFieldEmpty': 'Bidang ini wajib diisi.',
'composio.connect.wabaIdHint':
'Temukan melalui GET /me/businesses lalu GET /{business_id}/owned_whatsapp_business_accounts menggunakan token akses Meta Anda.',
'composio.connect.wabaIdLabel': 'Label ID WABA',
'composio.connect.wabaIdRequired':
'Masukkan ID Akun Bisnis WhatsApp (WABA ID) Anda untuk melanjutkan.',
+9
View File
@@ -46,6 +46,15 @@ const it4: TranslationMap = {
'composio.connect.subdomainInvalid':
'Inserisci solo il sottodominio breve (es. "acme"), non l\'URL completo. Deve contenere solo lettere, numeri e trattini.',
'composio.connect.subdomainRequired': 'Inserisci il tuo sottodominio Atlassian per continuare.',
'composio.connect.dynamicsOrgNameLabel': "Nome dell'organizzazione Dynamics 365",
'composio.connect.dynamicsOrgNameHint':
'Per esempio, "myorg" per myorg.crm.dynamics.com. Inserisci solo il nome breve dell\'organizzazione, non l\'URL completo.',
'composio.connect.needsFieldsPrefix': 'Per connettere',
'composio.connect.needsFieldsSuffix':
'ci servono altre informazioni. Compila i campi mancanti qui sotto e riprova.',
'composio.connect.requiredFieldEmpty': 'Questo campo è obbligatorio.',
'composio.connect.wabaIdHint':
'Trovalo tramite GET /me/businesses poi GET /{business_id}/owned_whatsapp_business_accounts usando il tuo token di accesso Meta.',
'composio.connect.wabaIdLabel': 'Etichetta WABA ID',
'composio.connect.wabaIdRequired':
'Inserisci il tuo ID WhatsApp Business Account (WABA ID) per continuare.',
+9
View File
@@ -47,6 +47,15 @@ const pt4: TranslationMap = {
'Informe apenas o subdomínio curto (ex.: "acme"), não a URL completa. Deve conter apenas letras, números e hífens.',
'composio.connect.subdomainRequired':
'Por favor, insira seu subdomínio Atlassian para continuar.',
'composio.connect.dynamicsOrgNameLabel': 'Nome da organização do Dynamics 365',
'composio.connect.dynamicsOrgNameHint':
'Por exemplo, "myorg" para myorg.crm.dynamics.com. Insira apenas o nome curto da organização, não a URL completa.',
'composio.connect.needsFieldsPrefix': 'Para conectar',
'composio.connect.needsFieldsSuffix':
'precisamos de mais algumas informações. Preencha os campos faltantes abaixo e tente novamente.',
'composio.connect.requiredFieldEmpty': 'Este campo é obrigatório.',
'composio.connect.wabaIdHint':
'Encontre-o via GET /me/businesses depois GET /{business_id}/owned_whatsapp_business_accounts usando seu token de acesso do Meta.',
'composio.connect.wabaIdLabel': 'Rótulo de ID WABA',
'composio.connect.wabaIdRequired':
'Por favor, insira seu ID de Conta Empresarial do WhatsApp (WABA ID) para continuar.',
+9
View File
@@ -46,6 +46,15 @@ const ru4: TranslationMap = {
'composio.connect.subdomainInvalid':
'Введите только короткий поддомен (например, "acme"), а не полный URL. Допустимы только буквы, цифры и дефисы.',
'composio.connect.subdomainRequired': 'Введи свой поддомен Atlassian для продолжения.',
'composio.connect.dynamicsOrgNameLabel': 'Название организации Dynamics 365',
'composio.connect.dynamicsOrgNameHint':
'Например, "myorg" для myorg.crm.dynamics.com. Введите только короткое название организации, а не полный URL.',
'composio.connect.needsFieldsPrefix': 'Чтобы подключить',
'composio.connect.needsFieldsSuffix':
'нам нужно немного больше информации. Заполните недостающие поля ниже и повторите попытку.',
'composio.connect.requiredFieldEmpty': 'Это поле обязательно для заполнения.',
'composio.connect.wabaIdHint':
'Найдите его через GET /me/businesses, затем GET /{business_id}/owned_whatsapp_business_accounts, используя ваш токен доступа Meta.',
'composio.connect.wabaIdLabel': 'ID аккаунта WhatsApp Business',
'composio.connect.wabaIdRequired':
'Введи ID аккаунта WhatsApp Business (WABA ID) для продолжения.',
+8
View File
@@ -46,6 +46,14 @@ const zhCN4: TranslationMap = {
'composio.connect.subdomainInvalid':
'仅输入短子域名(例如 "acme"),而非完整 URL。只能包含字母、数字和连字符。',
'composio.connect.subdomainRequired': '请输入你的 Atlassian 子域名以继续。',
'composio.connect.dynamicsOrgNameLabel': 'Dynamics 365 组织名称',
'composio.connect.dynamicsOrgNameHint':
'例如,myorg.crm.dynamics.com 的组织名称为 "myorg"。仅输入简短的组织名称,而不是完整 URL。',
'composio.connect.needsFieldsPrefix': '若要连接',
'composio.connect.needsFieldsSuffix': '我们需要一些额外信息。请填写下面缺失的字段并重试。',
'composio.connect.requiredFieldEmpty': '此字段为必填项。',
'composio.connect.wabaIdHint':
'通过 Meta 访问令牌调用 GET /me/businesses,然后 GET /{business_id}/owned_whatsapp_business_accounts 获取。',
'composio.connect.wabaIdLabel': 'WhatsApp 企业账户 ID',
'composio.connect.wabaIdRequired': '请输入你的 WhatsApp 企业账户 IDWABA ID)以继续。',
'composio.connect.waitingFor': '等待中',
+9
View File
@@ -1359,6 +1359,9 @@ const en: TranslationMap = {
'composio.connect.atlassianSubdomainHint': 'acme',
'composio.connect.atlassianSubdomainLabel': 'Atlassian subdomain',
'composio.connect.connect': 'Connect',
'composio.connect.dynamicsOrgNameHint':
'For example, "myorg" for myorg.crm.dynamics.com. Enter the short org name only, not the full URL.',
'composio.connect.dynamicsOrgNameLabel': 'Dynamics 365 Organization Name',
'composio.connect.connectionFailed': 'Connection failed (status: ${hit.status}).',
'composio.connect.disconnectFailed': 'Disconnect failed: {msg}',
'composio.connect.disconnecting': 'Disconnecting…',
@@ -1367,6 +1370,9 @@ const en: TranslationMap = {
"account. We'll open a browser window, you approve access there, and this app will detect the connection automatically.",
'composio.connect.isConnected': 'is connected.',
'composio.connect.manage': 'Manage',
'composio.connect.needsFieldsPrefix': 'To connect',
'composio.connect.needsFieldsSuffix':
'we need a bit more information. Fill in the missing fields below and try again.',
'composio.connect.needsSubdomain': 'To connect',
'composio.connect.needsSubdomainSuffix':
'enter your Atlassian subdomain (e.g. acme for acme.atlassian.net) and try again.',
@@ -1379,12 +1385,15 @@ const en: TranslationMap = {
"OpenHuman's own agent permissions are controlled below as read, write, and admin toggles.",
'composio.connect.reopenBrowser': 'Reopen browser',
'composio.connect.requestingUrl': 'Requesting connect URL…',
'composio.connect.requiredFieldEmpty': 'This field is required.',
'composio.connect.retryConnection': 'Retry connection',
'composio.connect.scopeLoadError': "Couldn't load scope preferences: {msg}",
'composio.connect.scopeSaveError': "Couldn't save {key} scope: {msg}",
'composio.connect.subdomainInvalid':
'Enter the short subdomain only (e.g. "acme"), not the full URL. It should contain only letters, numbers, and hyphens.',
'composio.connect.subdomainRequired': 'Please enter your Atlassian subdomain to continue.',
'composio.connect.wabaIdHint':
'Find it via GET /me/businesses then GET /{business_id}/owned_whatsapp_business_accounts using your Meta access token.',
'composio.connect.wabaIdLabel': 'WhatsApp Business Account ID (WABA ID)',
'composio.connect.wabaIdRequired':
'Please enter your WhatsApp Business Account ID (WABA ID) to continue.',