fix(jira): collect Atlassian subdomain and handle ConnectedAccount_MissingRequiredFields (#1726)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-05-15 04:14:23 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent eecd11cf0b
commit c37a122a0f
2 changed files with 557 additions and 153 deletions
@@ -3,10 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authorize } from '../../lib/composio/composioApi';
import { type ComposioConnection } from '../../lib/composio/types';
import { openUrl } from '../../utils/openUrl';
import ComposioConnectModal, {
isMissingAtlassianSubdomainError,
normalizeAtlassianSubdomain,
isMissingRequiredFieldsError,
isValidAtlassianSubdomain,
sanitizeAuthError,
} from './ComposioConnectModal';
import { composioToolkitMeta } from './toolkitMeta';
@@ -26,16 +26,124 @@ vi.mock('./TriggerToggles', () => ({ default: () => <div data-testid="trigger-to
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);
// ── Pure helper unit tests ────────────────────────────────────────────
describe('isValidAtlassianSubdomain', () => {
it('accepts typical lowercase subdomain', () => {
expect(isValidAtlassianSubdomain('acme')).toBe(true);
expect(isValidAtlassianSubdomain('my-company')).toBe(true);
expect(isValidAtlassianSubdomain('org123')).toBe(true);
});
it('accepts mixed-case subdomain (case-insensitive check)', () => {
expect(isValidAtlassianSubdomain('MyCompany')).toBe(true);
});
it('accepts single-character subdomain', () => {
expect(isValidAtlassianSubdomain('a')).toBe(true);
expect(isValidAtlassianSubdomain('z')).toBe(true);
expect(isValidAtlassianSubdomain('5')).toBe(true);
});
it('rejects full URLs', () => {
expect(isValidAtlassianSubdomain('https://acme.atlassian.net')).toBe(false);
expect(isValidAtlassianSubdomain('acme.atlassian.net')).toBe(false);
});
it('rejects leading/trailing hyphens', () => {
expect(isValidAtlassianSubdomain('-acme')).toBe(false);
expect(isValidAtlassianSubdomain('acme-')).toBe(false);
});
it('rejects empty string', () => {
expect(isValidAtlassianSubdomain('')).toBe(false);
expect(isValidAtlassianSubdomain(' ')).toBe(false);
});
it('rejects strings with spaces', () => {
expect(isValidAtlassianSubdomain('my company')).toBe(false);
});
it('trims whitespace before validation', () => {
expect(isValidAtlassianSubdomain(' acme ')).toBe(true);
});
});
describe('isMissingRequiredFieldsError', () => {
it('matches the Composio error slug', () => {
const err = new Error(
'Authorization failed: [composio] authorize failed: Backend returned 400 Bad Request: Composio authorization failed: 400 {"error":{"message":"Missing required fields","code":612,"slug":"ConnectedAccount_MissingRequiredFields"}}'
);
expect(isMissingRequiredFieldsError(err)).toBe(true);
});
it('does NOT match on the numeric code alone — avoids false positives from port/resource numbers', () => {
// The slug-only check prevents unrelated "612" occurrences (e.g. port numbers, IDs)
// from being misidentified as the Composio missing-fields error.
const err = new Error('error code 612 from server');
expect(isMissingRequiredFieldsError(err)).toBe(false);
});
it('returns false for unrelated errors', () => {
expect(isMissingRequiredFieldsError(new Error('Network timeout'))).toBe(false);
expect(isMissingRequiredFieldsError(new Error('401 Unauthorized'))).toBe(false);
});
it('returns false for null / undefined', () => {
expect(isMissingRequiredFieldsError(null)).toBe(false);
expect(isMissingRequiredFieldsError(undefined)).toBe(false);
});
it('accepts non-Error objects with the slug in stringified form', () => {
expect(isMissingRequiredFieldsError('ConnectedAccount_MissingRequiredFields')).toBe(true);
});
});
describe('sanitizeAuthError', () => {
it('returns a generic message for missing-required-fields errors', () => {
const err = new Error(
'Authorization failed: [composio] authorize failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: Composio authorization failed: 400 {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
);
const result = sanitizeAuthError(err);
expect(result).not.toContain('ConnectedAccount_MissingRequiredFields');
expect(result).not.toContain('api.tinyhumans.ai');
expect(result).not.toContain('612');
expect(result).toContain('required field');
});
it('strips backend URLs from plain authorization errors', () => {
const err = new Error(
'Authorization failed: Backend returned 500 Internal Server Error for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: internal error'
);
const result = sanitizeAuthError(err);
expect(result).not.toContain('api.tinyhumans.ai');
expect(result).not.toContain('https://');
});
it('strips raw JSON payloads', () => {
const err = new Error(
'Authorization failed: something happened: {"error":{"code":500,"message":"internal"}}'
);
const result = sanitizeAuthError(err);
expect(result).not.toContain('"code"');
expect(result).not.toContain('"message"');
});
it('returns a safe fallback for null/undefined', () => {
expect(sanitizeAuthError(null)).toBe('Something went wrong.');
expect(sanitizeAuthError(undefined)).toBe('Something went wrong.');
});
it('handles non-Error thrown values', () => {
const result = sanitizeAuthError('plain string error');
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
});
// ── Component render tests ────────────────────────────────────────────
describe('<ComposioConnectModal>', () => {
it('hides raw connection ID and "id:" label in connected phase', () => {
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
@@ -112,79 +220,177 @@ 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={() => {}} />
);
// ── Jira-specific flow tests ──────────────────────────────────────────
fireEvent.click(screen.getByRole('button', { name: 'Connect Gmail' }));
await waitFor(() => {
expect(authorize).toHaveBeenCalledWith('gmail', undefined);
});
describe('<ComposioConnectModal> — Jira subdomain collection', () => {
beforeEach(() => {
vi.clearAllMocks();
});
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('shows the Atlassian subdomain input in the idle phase for Jira', () => {
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
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();
expect(screen.getByPlaceholderText('your-subdomain')).toBeInTheDocument();
});
it('does NOT show the Atlassian subdomain input for non-Jira toolkits', () => {
render(<ComposioConnectModal toolkit={mockToolkit} onClose={() => {}} />);
expect(screen.queryByLabelText(/Atlassian subdomain/i)).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('your-subdomain')).not.toBeInTheDocument();
});
it('shows a validation error when connect is clicked with an empty subdomain', async () => {
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
const connectButton = screen.getByRole('button', { name: /Connect Jira/i });
fireEvent.click(connectButton);
await waitFor(() => {
expect(screen.getByText(/Please enter your Atlassian subdomain/i)).toBeInTheDocument();
});
});
it('shows a validation error when the subdomain looks like a full URL', async () => {
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
const input = screen.getByPlaceholderText('your-subdomain');
fireEvent.change(input, { target: { value: 'https://acme.atlassian.net' } });
const connectButton = screen.getByRole('button', { name: /Connect Jira/i });
fireEvent.click(connectButton);
await waitFor(() => {
expect(screen.getByText(/short subdomain only/i)).toBeInTheDocument();
});
});
it('clears subdomain validation error when the user types', async () => {
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
// Trigger validation error
const connectButton = screen.getByRole('button', { name: /Connect Jira/i });
fireEvent.click(connectButton);
await waitFor(() => {
expect(screen.getByText(/Please enter your Atlassian subdomain/i)).toBeInTheDocument();
});
// Type to clear the error
const input = screen.getByPlaceholderText('your-subdomain');
fireEvent.change(input, { target: { value: 'a' } });
await waitFor(() => {
expect(screen.queryByText(/Please enter your Atlassian subdomain/i)).not.toBeInTheDocument();
});
});
});
// ── needs-subdomain phase tests ───────────────────────────────────────
describe('<ComposioConnectModal> — needs-subdomain recovery phase', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('transitions to needs-subdomain phase for Jira when Composio returns the missing-required-fields error', async () => {
// needs-subdomain phase is only shown for Atlassian toolkits (jira).
vi.mocked(authorize).mockRejectedValueOnce(
new Error(
'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
)
);
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
const input = screen.getByPlaceholderText('your-subdomain');
fireEvent.change(input, { target: { value: 'acme' } });
fireEvent.click(screen.getByRole('button', { name: /Connect Jira/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Retry connection/i })).toBeInTheDocument();
expect(screen.getByText(/To connect Jira/i)).toBeInTheDocument();
});
});
it('routes non-Jira missing-required-fields errors to the error phase (not needs-subdomain)', async () => {
// Gmail does not have an Atlassian subdomain — showing the Atlassian subdomain
// form for it would be misleading and the retry would loop forever.
vi.mocked(authorize).mockRejectedValueOnce(
new Error(
'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
)
);
render(<ComposioConnectModal toolkit={mockToolkit} onClose={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /Connect Gmail/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Dismiss/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Retry connection/i })).not.toBeInTheDocument();
});
});
it('does NOT show raw backend payload in the needs-subdomain phase', async () => {
vi.mocked(authorize).mockRejectedValueOnce(
new Error(
'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612,"message":"very sensitive backend payload"}}'
)
);
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
const input = screen.getByPlaceholderText('your-subdomain');
fireEvent.change(input, { target: { value: 'acme' } });
fireEvent.click(screen.getByRole('button', { name: /Connect Jira/i }));
await waitFor(() => {
expect(screen.queryByText(/very sensitive backend payload/i)).not.toBeInTheDocument();
expect(screen.queryByText(/ConnectedAccount_MissingRequiredFields/i)).not.toBeInTheDocument();
});
});
it('clicking Cancel in needs-subdomain goes back to idle', async () => {
vi.mocked(authorize).mockRejectedValueOnce(new Error('ConnectedAccount_MissingRequiredFields'));
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
const input = screen.getByPlaceholderText('your-subdomain');
fireEvent.change(input, { target: { value: 'acme' } });
fireEvent.click(screen.getByRole('button', { name: /Connect Jira/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Retry connection/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Connect Jira/i })).toBeInTheDocument();
});
});
it('surfaces a sanitized (non-raw) error for unrelated authorization failures', async () => {
vi.mocked(authorize).mockRejectedValueOnce(
new Error(
'Authorization failed: Backend returned 500 Internal Server Error for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: {"error":{"message":"internal server error payload","code":500}}'
)
);
render(<ComposioConnectModal toolkit={mockToolkit} onClose={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /Connect Gmail/i }));
await waitFor(() => {
// Should be in error phase, not needs-subdomain
expect(screen.getByRole('button', { name: /Dismiss/i })).toBeInTheDocument();
// Raw URL should not be shown
expect(screen.queryByText(/api.tinyhumans.ai/i)).not.toBeInTheDocument();
// Raw JSON payload should not be shown
expect(screen.queryByText(/internal server error payload/i)).not.toBeInTheDocument();
});
});
});
@@ -9,6 +9,12 @@
* 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.
*
* 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.
@@ -40,34 +46,89 @@ function deriveConnectionLabel(c: ComposioConnection): string | null {
return null;
}
const ATLASSIAN_DOMAIN_SUFFIX = '.atlassian.net';
/**
* The Composio error slug for missing required fields (code 612). Matching
* on the slug string is more precise than matching the numeric code, which
* could appear in unrelated messages (e.g. port numbers, resource IDs).
*/
const COMPOSIO_MISSING_REQUIRED_FIELDS_SLUG = 'ConnectedAccount_MissingRequiredFields';
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);
/**
* Validate an Atlassian subdomain. Accepts the short form used in
* `<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.
*/
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());
}
/**
* Detect a `ConnectedAccount_MissingRequiredFields` (code 612) error from
* the backend/Composio. Returns true if the thrown error message contains
* the known slug. Matching only on the slug avoids false positives from
* unrelated messages that happen to contain the numeric code "612".
* Safe to call with any value — returns false for null/non-Error.
*/
export function isMissingRequiredFieldsError(err: unknown): boolean {
if (!err) return false;
const msg = err instanceof Error ? err.message : String(err);
return msg.includes(COMPOSIO_MISSING_REQUIRED_FIELDS_SLUG);
}
/**
* Return a safe, user-facing summary of an authorization failure. Strips the
* raw backend URL and JSON payload from the message so sensitive Composio
* internals are never shown in the UI.
*/
export function sanitizeAuthError(err: unknown): string {
if (isMissingRequiredFieldsError(err)) {
// Never surface raw 612 payloads — callers should handle this separately.
return 'A required field is missing. Please provide the missing details and try again.';
}
return normalized;
if (!err) return 'Something went wrong.';
const raw = err instanceof Error ? err.message : String(err);
// Strip any URL that looks like a backend endpoint so it is not displayed.
const stripped = raw.replace(/https?:\/\/[^\s"]+/g, '<backend>');
// Trim at the first occurrence of a JSON blob to avoid leaking payloads.
// The URL stripping above may consume the `:` before `{`, so we match
// the optional colon and any surrounding whitespace before the `{`.
// This covers both `: {"error"...}` and the bare ` {"error"...}` form.
const jsonIdx = stripped.search(/\s*:?\s*\{"error"/);
// Fall back to trimming at any bare `{` that follows whitespace if we
// did not find a `{"error"` form (defensive — handles other JSON shapes).
const jsonIdxFallback = stripped.search(/\s\{/);
const cutIdx =
jsonIdx !== -1 ? jsonIdx : jsonIdxFallback !== -1 ? jsonIdxFallback : stripped.length;
const trimmed = stripped.slice(0, cutIdx).trimEnd();
// Collapse repeated colons / prefixes produced by the RPC error chain.
// Apply iteratively until stable to handle nested wrapping.
let result = trimmed;
let prev: string;
do {
prev = result;
result = result
.replace(/^(Authorization failed:\s*)+/i, '')
.replace(/^\[composio\]\s*authorize failed:\s*/i, '')
.replace(/^Backend returned \d+[^:]*(?:for POST <backend>[^:]*)?:?\s*/i, '')
.replace(/^Composio authorization failed:\s*/i, '')
.trim();
} while (result !== prev);
return result || 'Authorization failed.';
}
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';
type Phase =
| 'idle'
| 'needs-subdomain'
| 'authorizing'
| 'waiting'
| 'connected'
| 'disconnecting'
| 'error';
interface ComposioConnectModalProps {
toolkit: ComposioToolkitMeta;
@@ -103,7 +164,9 @@ 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';
// 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';
const [activeConnection, setActiveConnection] = useState<ComposioConnection | undefined>(
connection
@@ -219,58 +282,111 @@ export default function ComposioConnectModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleConnect = useCallback(async () => {
/**
* 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.
*/
const validateRequiredFields = useCallback((): boolean => {
if (needsWabaId && !wabaId.trim()) {
setError('Please enter your WhatsApp Business Account ID (WABA ID) to continue.');
return;
return false;
}
const jiraSubdomain = needsAtlassianSubdomain
? validateAtlassianSubdomain(atlassianSubdomain)
: null;
if (needsAtlassianSubdomain && !jiraSubdomain) {
setError(
'Enter your Atlassian subdomain, such as "acme" from "acme.atlassian.net", to continue.'
);
return;
if (needsAtlassianSubdomain) {
const trimmed = atlassianSubdomain.trim();
if (!trimmed) {
setSubdomainError('Please enter your Atlassian subdomain to continue.');
return false;
}
if (!isValidAtlassianSubdomain(trimmed)) {
setSubdomainError(
'Enter the short subdomain only (e.g. "acme"), not the full URL. ' +
'It should contain only letters, numbers, and hyphens.'
);
return false;
}
}
return true;
}, [needsWabaId, wabaId, needsAtlassianSubdomain, atlassianSubdomain]);
const handleConnect = useCallback(async () => {
if (!validateRequiredFields()) return;
setPhase('authorizing');
setError(null);
setSubdomainError(null);
setConnectUrl(null);
const extraParams: Record<string, string> = {};
if (needsWabaId) extraParams.waba_id = wabaId.trim();
if (needsAtlassianSubdomain && atlassianSubdomain.trim()) {
extraParams.subdomain = atlassianSubdomain.trim();
}
console.debug(
'[composio][authorize] → toolkit=%s has_extra_params=%s',
toolkit.slug,
Object.keys(extraParams).length > 0
);
try {
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
);
console.debug(
'[composio][authorize] ← toolkit=%s connection_id=%s',
toolkit.slug,
resp.connectionId
);
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.'
console.error(
'[composio][authorize] failed toolkit=%s slug_check=%s',
toolkit.slug,
isMissingRequiredFieldsError(err)
);
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.
console.debug(
'[composio][authorize] missing-required-fields toolkit=%s needsAtlassianSubdomain=%s',
toolkit.slug,
needsAtlassianSubdomain
);
if (needsAtlassianSubdomain) {
setPhase('needs-subdomain');
setError(null);
} else {
setPhase('error');
setError(
'This connection requires additional configuration. Please contact support for assistance.'
);
}
return;
}
setPhase('error');
setError(`Authorization failed: ${msg}`);
setError(sanitizeAuthError(err));
}
}, [
atlassianSubdomain,
needsAtlassianSubdomain,
validateRequiredFields,
needsWabaId,
wabaId,
needsAtlassianSubdomain,
atlassianSubdomain,
startPolling,
toolkit.slug,
wabaId,
]);
// Fetch the stored scope pref whenever the modal lands in the
@@ -458,30 +574,14 @@ export default function ComposioConnectModal({
</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>
<AtlassianSubdomainInput
value={atlassianSubdomain}
error={subdomainError}
onChange={v => {
setAtlassianSubdomain(v);
if (subdomainError) setSubdomainError(null);
}}
/>
)}
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
<button
@@ -493,6 +593,41 @@ export default function ComposioConnectModal({
</>
)}
{phase === 'needs-subdomain' && (
<>
<p className="text-sm text-stone-600">
To connect {toolkit.name}, enter your Atlassian subdomain (e.g.{' '}
<span className="font-mono">acme</span> for{' '}
<span className="font-mono">acme.atlassian.net</span>) and try again.
</p>
<AtlassianSubdomainInput
value={atlassianSubdomain}
error={subdomainError}
onChange={v => {
setAtlassianSubdomain(v);
if (subdomainError) setSubdomainError(null);
}}
autoFocus
/>
<button
type="button"
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
Retry connection
</button>
<button
type="button"
onClick={() => {
setPhase('idle');
setSubdomainError(null);
setError(null);
}}
className="w-full rounded-xl border border-stone-200 bg-white text-stone-600 text-xs font-medium py-2 hover:bg-stone-50 transition-colors">
Cancel
</button>
</>
)}
{phase === 'authorizing' && (
<p className="text-sm text-stone-500">Requesting connect URL</p>
)}
@@ -663,3 +798,66 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
</div>
);
}
// ── Atlassian subdomain input ───────────────────────────────────────
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;
}
/**
* 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.
*/
function AtlassianSubdomainInput({
value,
error,
onChange,
autoFocus,
}: AtlassianSubdomainInputProps) {
return (
<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>
<div className="flex items-center rounded-xl border border-stone-200 bg-white 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 placeholder:text-stone-400 bg-transparent focus:outline-none"
/>
<span className="pr-3 text-xs text-stone-400 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">
Enter the short subdomain only e.g. <span className="font-mono">acme</span> for{' '}
<span className="font-mono">acme.atlassian.net</span>. Do not paste the full URL.
</p>
)}
</div>
);
}