diff --git a/app/src/components/composio/ComposioConnectModal.test.tsx b/app/src/components/composio/ComposioConnectModal.test.tsx
index 8400bbe4b..c3a8b36b0 100644
--- a/app/src/components/composio/ComposioConnectModal.test.tsx
+++ b/app/src/components/composio/ComposioConnectModal.test.tsx
@@ -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: () =>
{
+ // 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('', () => {
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('', () => {
expect(screen.queryByText('(Acme)')).not.toBeInTheDocument();
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
});
+});
- it('keeps default toolkit authorization free of empty extra params', async () => {
- render(
- {}} />
- );
+// ── Jira-specific flow tests ──────────────────────────────────────────
- fireEvent.click(screen.getByRole('button', { name: 'Connect Gmail' }));
-
- await waitFor(() => {
- expect(authorize).toHaveBeenCalledWith('gmail', undefined);
- });
+describe(' — 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( {}} />);
- 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();
+ expect(screen.getByPlaceholderText('your-subdomain')).toBeInTheDocument();
+ });
+
+ it('does NOT show the Atlassian subdomain input for non-Jira toolkits', () => {
+ render( {}} />);
+
+ 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( {}} />);
+
+ 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( {}} />);
+
+ 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( {}} />);
+
+ // 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(' — 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( {}} />);
+
+ 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( {}} />);
+ 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( {}} />);
+
+ 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( {}} />);
+
+ 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( {}} />);
+
+ 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();
+ });
});
});
diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx
index 1c9205832..c7db39f94 100644
--- a/app/src/components/composio/ComposioConnectModal.tsx
+++ b/app/src/components/composio/ComposioConnectModal.tsx
@@ -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
+ * `.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, '');
+
+ // 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 [^:]*)?:?\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(null);
const needsAtlassianSubdomain = toolkit.slug === 'jira';
const [activeConnection, setActiveConnection] = useState(
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 = {};
+ 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 = {};
- 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({
)}
{needsAtlassianSubdomain && (
-
-
- Atlassian subdomain
- *
-
-
) => {
- 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 .
-
-
+ {
+ setAtlassianSubdomain(v);
+ if (subdomainError) setSubdomainError(null);
+ }}
+ />
)}
{error && phase === 'idle' && {error}
}
)}
+ {phase === 'needs-subdomain' && (
+ <>
+
+ To connect {toolkit.name}, enter your Atlassian subdomain (e.g.{' '}
+ acme for{' '}
+ acme.atlassian.net ) and try again.
+
+ {
+ setAtlassianSubdomain(v);
+ if (subdomainError) setSubdomainError(null);
+ }}
+ autoFocus
+ />
+ 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
+
+ {
+ 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
+
+ >
+ )}
+
{phase === 'authorizing' && (
Requesting connect URL…
)}
@@ -663,3 +798,66 @@ function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProp
);
}
+
+// ── 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 (
+
+
+ Atlassian subdomain
+ *
+
+
+ ) => 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"
+ />
+
+ .atlassian.net
+
+
+ {/* 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 ? (
+
+ {error}
+
+ ) : (
+
+ Enter the short subdomain only — e.g. acme for{' '}
+ acme.atlassian.net . Do not paste the full URL.
+
+ )}
+
+ );
+}