From 2970ff5dd0ac623e053be06b0d20edfdbfd6e86c Mon Sep 17 00:00:00 2001 From: Aqil Aziz Date: Fri, 15 May 2026 09:46:36 +0700 Subject: [PATCH] Fix Jira Composio subdomain authorization (#1733) --- .../composio/ComposioConnectModal.test.tsx | 96 ++++++++++++++++++- .../composio/ComposioConnectModal.tsx | 93 +++++++++++++++++- 2 files changed, 183 insertions(+), 6 deletions(-) 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}

}