mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Fix Jira Composio subdomain authorization (#1733)
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { authorize } from '../../lib/composio/composioApi';
|
||||
import { type ComposioConnection } from '../../lib/composio/types';
|
||||
import ComposioConnectModal from './ComposioConnectModal';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import ComposioConnectModal, {
|
||||
isMissingAtlassianSubdomainError,
|
||||
normalizeAtlassianSubdomain,
|
||||
} from './ComposioConnectModal';
|
||||
import { composioToolkitMeta } from './toolkitMeta';
|
||||
|
||||
vi.mock('../../lib/composio/composioApi', () => ({
|
||||
@@ -19,8 +24,18 @@ vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
vi.mock('./TriggerToggles', () => ({ default: () => <div data-testid="trigger-toggles" /> }));
|
||||
|
||||
const mockToolkit = composioToolkitMeta('gmail');
|
||||
const jiraToolkit = composioToolkitMeta('jira');
|
||||
|
||||
describe('<ComposioConnectModal>', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(authorize).mockResolvedValue({
|
||||
connectUrl: 'https://composio.example/jira/consent',
|
||||
connectionId: 'conn-123',
|
||||
});
|
||||
vi.mocked(openUrl).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('hides raw connection ID and "id:" label in connected phase', () => {
|
||||
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
|
||||
|
||||
@@ -97,4 +112,79 @@ describe('<ComposioConnectModal>', () => {
|
||||
expect(screen.queryByText('(Acme)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps default toolkit authorization free of empty extra params', async () => {
|
||||
render(
|
||||
<ComposioConnectModal toolkit={mockToolkit} connection={undefined} onClose={() => {}} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect Gmail' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authorize).toHaveBeenCalledWith('gmail', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes pasted Atlassian URLs to the Jira subdomain', () => {
|
||||
expect(normalizeAtlassianSubdomain('https://Acme.atlassian.net/jira/software')).toBe('acme');
|
||||
expect(normalizeAtlassianSubdomain('acme.atlassian.net')).toBe('acme');
|
||||
});
|
||||
|
||||
it('detects Composio missing-subdomain errors without exposing raw payloads', () => {
|
||||
expect(
|
||||
isMissingAtlassianSubdomainError(
|
||||
'Composio authorization failed: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","message":"Missing required fields: Your Subdomain"}}'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires an Atlassian subdomain before Jira authorization', async () => {
|
||||
render(
|
||||
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
|
||||
|
||||
expect(await screen.findByText(/Enter your Atlassian subdomain/i)).toBeInTheDocument();
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the normalized Jira subdomain as an authorize extra param', async () => {
|
||||
render(
|
||||
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Atlassian subdomain/i), {
|
||||
target: { value: 'https://Acme.atlassian.net/jira/software' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authorize).toHaveBeenCalledWith('jira', { subdomain: 'acme' });
|
||||
});
|
||||
expect(openUrl).toHaveBeenCalledWith('https://composio.example/jira/consent');
|
||||
});
|
||||
|
||||
it('maps Jira missing-field backend errors back to the inline subdomain form', async () => {
|
||||
vi.mocked(authorize).mockRejectedValueOnce(
|
||||
new Error(
|
||||
'Composio authorization failed: 400 {"error":{"slug":"ConnectedAccount_MissingRequiredFields","message":"Missing required fields: Your Subdomain"}}'
|
||||
)
|
||||
);
|
||||
|
||||
render(
|
||||
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Atlassian subdomain/i), { target: { value: 'acme' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Jira needs your Atlassian subdomain before authorization/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Atlassian subdomain/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ConnectedAccount_MissingRequiredFields/i)).not.toBeInTheDocument();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,33 @@ function deriveConnectionLabel(c: ComposioConnection): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ATLASSIAN_DOMAIN_SUFFIX = '.atlassian.net';
|
||||
|
||||
export function normalizeAtlassianSubdomain(value: string): string {
|
||||
let normalized = value.trim().toLowerCase();
|
||||
normalized = normalized.replace(/^https?:\/\//, '');
|
||||
normalized = normalized.replace(/\/.*$/, '');
|
||||
normalized = normalized.replace(/:\d+$/, '');
|
||||
if (normalized.endsWith(ATLASSIAN_DOMAIN_SUFFIX)) {
|
||||
normalized = normalized.slice(0, -ATLASSIAN_DOMAIN_SUFFIX.length);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateAtlassianSubdomain(value: string): string | null {
|
||||
const subdomain = normalizeAtlassianSubdomain(value);
|
||||
if (!subdomain) return null;
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) return null;
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
export function isMissingAtlassianSubdomainError(message: string): boolean {
|
||||
return (
|
||||
/ConnectedAccount_MissingRequiredFields/i.test(message) &&
|
||||
/subdomain|Your Subdomain/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'authorizing' | 'waiting' | 'connected' | 'disconnecting' | 'error';
|
||||
|
||||
interface ComposioConnectModalProps {
|
||||
@@ -76,6 +103,8 @@ export default function ComposioConnectModal({
|
||||
// WhatsApp Business requires a WABA ID before the OAuth flow can start.
|
||||
const [wabaId, setWabaId] = useState('');
|
||||
const needsWabaId = toolkit.slug === 'whatsapp';
|
||||
const [atlassianSubdomain, setAtlassianSubdomain] = useState('');
|
||||
const needsAtlassianSubdomain = toolkit.slug === 'jira';
|
||||
const [activeConnection, setActiveConnection] = useState<ComposioConnection | undefined>(
|
||||
connection
|
||||
);
|
||||
@@ -195,22 +224,54 @@ export default function ComposioConnectModal({
|
||||
setError('Please enter your WhatsApp Business Account ID (WABA ID) to continue.');
|
||||
return;
|
||||
}
|
||||
const jiraSubdomain = needsAtlassianSubdomain
|
||||
? validateAtlassianSubdomain(atlassianSubdomain)
|
||||
: null;
|
||||
if (needsAtlassianSubdomain && !jiraSubdomain) {
|
||||
setError(
|
||||
'Enter your Atlassian subdomain, such as "acme" from "acme.atlassian.net", to continue.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPhase('authorizing');
|
||||
setError(null);
|
||||
setConnectUrl(null);
|
||||
try {
|
||||
const extraParams = needsWabaId ? { waba_id: wabaId.trim() } : undefined;
|
||||
const resp = await authorize(toolkit.slug, extraParams);
|
||||
const extraParams: Record<string, string> = {};
|
||||
if (needsWabaId) extraParams.waba_id = wabaId.trim();
|
||||
if (jiraSubdomain) extraParams.subdomain = jiraSubdomain;
|
||||
const resp = await authorize(
|
||||
toolkit.slug,
|
||||
Object.keys(extraParams).length > 0 ? extraParams : undefined
|
||||
);
|
||||
setConnectUrl(resp.connectUrl);
|
||||
await openUrl(resp.connectUrl);
|
||||
setPhase('waiting');
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (needsAtlassianSubdomain && isMissingAtlassianSubdomainError(msg)) {
|
||||
console.warn('[composio] authorize missing required Jira subdomain', {
|
||||
toolkit: toolkit.slug,
|
||||
errorSlug: 'ConnectedAccount_MissingRequiredFields',
|
||||
});
|
||||
setPhase('idle');
|
||||
setError(
|
||||
'Jira needs your Atlassian subdomain before authorization. Enter the subdomain and retry.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPhase('error');
|
||||
setError(`Authorization failed: ${msg}`);
|
||||
}
|
||||
}, [needsWabaId, startPolling, toolkit.slug, wabaId]);
|
||||
}, [
|
||||
atlassianSubdomain,
|
||||
needsAtlassianSubdomain,
|
||||
needsWabaId,
|
||||
startPolling,
|
||||
toolkit.slug,
|
||||
wabaId,
|
||||
]);
|
||||
|
||||
// Fetch the stored scope pref whenever the modal lands in the
|
||||
// 'connected' phase. Re-fetching each time we transition (rather
|
||||
@@ -396,6 +457,32 @@ export default function ComposioConnectModal({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{needsAtlassianSubdomain && (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="atlassian-subdomain-input"
|
||||
className="block text-xs font-medium text-stone-700">
|
||||
Atlassian subdomain
|
||||
<span className="ml-1 text-coral-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="atlassian-subdomain-input"
|
||||
type="text"
|
||||
value={atlassianSubdomain}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setAtlassianSubdomain(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder="e.g. acme"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-stone-400">
|
||||
Use only the subdomain from your Jira Cloud URL, for example{' '}
|
||||
<span className="font-mono">acme</span> from{' '}
|
||||
<span className="font-mono">acme.atlassian.net</span>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user