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 && ( -
- - ) => { - 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 === '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 ( +
+ +
+ ) => 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 ? ( + + ) : ( +

+ Enter the short subdomain only — e.g. acme for{' '} + acme.atlassian.net. Do not paste the full URL. +

+ )} +
+ ); +}