Fix Jira Composio subdomain authorization (#1733)

This commit is contained in:
Aqil Aziz
2026-05-14 19:46:36 -07:00
committed by GitHub
parent 6ab6a86cf2
commit 2970ff5dd0
2 changed files with 183 additions and 6 deletions
@@ -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: () => <div data-testid="trigger-toggles" /> }));
const mockToolkit = composioToolkitMeta('gmail');
const jiraToolkit = composioToolkitMeta('jira');
describe('<ComposioConnectModal>', () => {
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('<ComposioConnectModal>', () => {
expect(screen.queryByText('(Acme)')).not.toBeInTheDocument();
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
});
it('keeps default toolkit authorization free of empty extra params', async () => {
render(
<ComposioConnectModal toolkit={mockToolkit} connection={undefined} onClose={() => {}} />
);
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(
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
);
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(
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
);
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(
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
);
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();
});
});
@@ -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<ComposioConnection | undefined>(
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<string, string> = {};
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({
</p>
</div>
)}
{needsAtlassianSubdomain && (
<div className="space-y-1.5">
<label
htmlFor="atlassian-subdomain-input"
className="block text-xs font-medium text-stone-700">
Atlassian subdomain
<span className="ml-1 text-coral-500">*</span>
</label>
<input
id="atlassian-subdomain-input"
type="text"
value={atlassianSubdomain}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
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"
/>
<p className="text-[11px] leading-relaxed text-stone-400">
Use only the subdomain from your Jira Cloud URL, for example{' '}
<span className="font-mono">acme</span> from{' '}
<span className="font-mono">acme.atlassian.net</span>.
</p>
</div>
)}
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
<button
type="button"