diff --git a/app/src/components/composio/ComposioConnectModal.test.tsx b/app/src/components/composio/ComposioConnectModal.test.tsx
index 969dc8472..8400bbe4b 100644
--- a/app/src/components/composio/ComposioConnectModal.test.tsx
+++ b/app/src/components/composio/ComposioConnectModal.test.tsx
@@ -1,8 +1,13 @@
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { authorize } from '../../lib/composio/composioApi';
import { type ComposioConnection } from '../../lib/composio/types';
-import ComposioConnectModal from './ComposioConnectModal';
+import { openUrl } from '../../utils/openUrl';
+import ComposioConnectModal, {
+ isMissingAtlassianSubdomainError,
+ normalizeAtlassianSubdomain,
+} from './ComposioConnectModal';
import { composioToolkitMeta } from './toolkitMeta';
vi.mock('../../lib/composio/composioApi', () => ({
@@ -19,8 +24,18 @@ vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('./TriggerToggles', () => ({ default: () =>
}));
const mockToolkit = composioToolkitMeta('gmail');
+const jiraToolkit = composioToolkitMeta('jira');
describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(authorize).mockResolvedValue({
+ connectUrl: 'https://composio.example/jira/consent',
+ connectionId: 'conn-123',
+ });
+ vi.mocked(openUrl).mockResolvedValue(undefined);
+ });
+
it('hides raw connection ID and "id:" label in connected phase', () => {
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
@@ -97,4 +112,79 @@ describe('', () => {
expect(screen.queryByText('(Acme)')).not.toBeInTheDocument();
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
});
+
+ it('keeps default toolkit authorization free of empty extra params', async () => {
+ render(
+ {}} />
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Connect Gmail' }));
+
+ await waitFor(() => {
+ expect(authorize).toHaveBeenCalledWith('gmail', undefined);
+ });
+ });
+
+ it('normalizes pasted Atlassian URLs to the Jira subdomain', () => {
+ expect(normalizeAtlassianSubdomain('https://Acme.atlassian.net/jira/software')).toBe('acme');
+ expect(normalizeAtlassianSubdomain('acme.atlassian.net')).toBe('acme');
+ });
+
+ it('detects Composio missing-subdomain errors without exposing raw payloads', () => {
+ expect(
+ isMissingAtlassianSubdomainError(
+ 'Composio authorization failed: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","message":"Missing required fields: Your Subdomain"}}'
+ )
+ ).toBe(true);
+ });
+
+ it('requires an Atlassian subdomain before Jira authorization', async () => {
+ render(
+ {}} />
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
+
+ expect(await screen.findByText(/Enter your Atlassian subdomain/i)).toBeInTheDocument();
+ expect(authorize).not.toHaveBeenCalled();
+ expect(openUrl).not.toHaveBeenCalled();
+ });
+
+ it('sends the normalized Jira subdomain as an authorize extra param', async () => {
+ render(
+ {}} />
+ );
+
+ fireEvent.change(screen.getByLabelText(/Atlassian subdomain/i), {
+ target: { value: 'https://Acme.atlassian.net/jira/software' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
+
+ await waitFor(() => {
+ expect(authorize).toHaveBeenCalledWith('jira', { subdomain: 'acme' });
+ });
+ expect(openUrl).toHaveBeenCalledWith('https://composio.example/jira/consent');
+ });
+
+ it('maps Jira missing-field backend errors back to the inline subdomain form', async () => {
+ vi.mocked(authorize).mockRejectedValueOnce(
+ new Error(
+ 'Composio authorization failed: 400 {"error":{"slug":"ConnectedAccount_MissingRequiredFields","message":"Missing required fields: Your Subdomain"}}'
+ )
+ );
+
+ render(
+ {}} />
+ );
+
+ fireEvent.change(screen.getByLabelText(/Atlassian subdomain/i), { target: { value: 'acme' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
+
+ expect(
+ await screen.findByText(/Jira needs your Atlassian subdomain before authorization/i)
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText(/Atlassian subdomain/i)).toBeInTheDocument();
+ expect(screen.queryByText(/ConnectedAccount_MissingRequiredFields/i)).not.toBeInTheDocument();
+ expect(openUrl).not.toHaveBeenCalled();
+ });
});
diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx
index 8ae03d4bd..1c9205832 100644
--- a/app/src/components/composio/ComposioConnectModal.tsx
+++ b/app/src/components/composio/ComposioConnectModal.tsx
@@ -40,6 +40,33 @@ function deriveConnectionLabel(c: ComposioConnection): string | null {
return null;
}
+const ATLASSIAN_DOMAIN_SUFFIX = '.atlassian.net';
+
+export function normalizeAtlassianSubdomain(value: string): string {
+ let normalized = value.trim().toLowerCase();
+ normalized = normalized.replace(/^https?:\/\//, '');
+ normalized = normalized.replace(/\/.*$/, '');
+ normalized = normalized.replace(/:\d+$/, '');
+ if (normalized.endsWith(ATLASSIAN_DOMAIN_SUFFIX)) {
+ normalized = normalized.slice(0, -ATLASSIAN_DOMAIN_SUFFIX.length);
+ }
+ return normalized;
+}
+
+function validateAtlassianSubdomain(value: string): string | null {
+ const subdomain = normalizeAtlassianSubdomain(value);
+ if (!subdomain) return null;
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) return null;
+ return subdomain;
+}
+
+export function isMissingAtlassianSubdomainError(message: string): boolean {
+ return (
+ /ConnectedAccount_MissingRequiredFields/i.test(message) &&
+ /subdomain|Your Subdomain/i.test(message)
+ );
+}
+
type Phase = 'idle' | 'authorizing' | 'waiting' | 'connected' | 'disconnecting' | 'error';
interface ComposioConnectModalProps {
@@ -76,6 +103,8 @@ export default function ComposioConnectModal({
// WhatsApp Business requires a WABA ID before the OAuth flow can start.
const [wabaId, setWabaId] = useState('');
const needsWabaId = toolkit.slug === 'whatsapp';
+ const [atlassianSubdomain, setAtlassianSubdomain] = useState('');
+ const needsAtlassianSubdomain = toolkit.slug === 'jira';
const [activeConnection, setActiveConnection] = useState(
connection
);
@@ -195,22 +224,54 @@ export default function ComposioConnectModal({
setError('Please enter your WhatsApp Business Account ID (WABA ID) to continue.');
return;
}
+ const jiraSubdomain = needsAtlassianSubdomain
+ ? validateAtlassianSubdomain(atlassianSubdomain)
+ : null;
+ if (needsAtlassianSubdomain && !jiraSubdomain) {
+ setError(
+ 'Enter your Atlassian subdomain, such as "acme" from "acme.atlassian.net", to continue.'
+ );
+ return;
+ }
setPhase('authorizing');
setError(null);
setConnectUrl(null);
try {
- const extraParams = needsWabaId ? { waba_id: wabaId.trim() } : undefined;
- const resp = await authorize(toolkit.slug, extraParams);
+ const extraParams: Record = {};
+ if (needsWabaId) extraParams.waba_id = wabaId.trim();
+ if (jiraSubdomain) extraParams.subdomain = jiraSubdomain;
+ const resp = await authorize(
+ toolkit.slug,
+ Object.keys(extraParams).length > 0 ? extraParams : undefined
+ );
setConnectUrl(resp.connectUrl);
await openUrl(resp.connectUrl);
setPhase('waiting');
startPolling();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
+ if (needsAtlassianSubdomain && isMissingAtlassianSubdomainError(msg)) {
+ console.warn('[composio] authorize missing required Jira subdomain', {
+ toolkit: toolkit.slug,
+ errorSlug: 'ConnectedAccount_MissingRequiredFields',
+ });
+ setPhase('idle');
+ setError(
+ 'Jira needs your Atlassian subdomain before authorization. Enter the subdomain and retry.'
+ );
+ return;
+ }
setPhase('error');
setError(`Authorization failed: ${msg}`);
}
- }, [needsWabaId, startPolling, toolkit.slug, wabaId]);
+ }, [
+ atlassianSubdomain,
+ needsAtlassianSubdomain,
+ needsWabaId,
+ startPolling,
+ toolkit.slug,
+ wabaId,
+ ]);
// Fetch the stored scope pref whenever the modal lands in the
// 'connected' phase. Re-fetching each time we transition (rather
@@ -396,6 +457,32 @@ export default function ComposioConnectModal({
)}
+ {needsAtlassianSubdomain && (
+
+
+
) => {
+ setAtlassianSubdomain(e.target.value);
+ if (error) setError(null);
+ }}
+ placeholder="e.g. acme"
+ className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
+ />
+
+ Use only the subdomain from your Jira Cloud URL, for example{' '}
+ acme from{' '}
+ acme.atlassian.net.
+
+
+ )}
{error && phase === 'idle' && {error}
}