mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(jira): collect Atlassian subdomain and handle ConnectedAccount_MissingRequiredFields (#1726)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
eecd11cf0b
commit
c37a122a0f
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user