Skip to content

Commit c84480e

Browse files
authored
Merge pull request #2545 from trycompai/fix/cloud-reconnect-oauth-clear
[dev] [tofikwest] fix/cloud-reconnect-oauth-clear
2 parents 3d66c52 + faaf422 commit c84480e

File tree

4 files changed

+204
-36
lines changed

4 files changed

+204
-36
lines changed

apps/api/src/cloud-security/providers/gcp-security.service.ts

Lines changed: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -245,22 +245,39 @@ export class GCPSecurityService {
245245
const { accessToken, organizationId, projectId } = params;
246246
const steps: GcpSetupStep[] = [];
247247
const email = await this.detectEmail(accessToken);
248+
const hasFindingsAccess = organizationId
249+
? await this.canReadFindings(accessToken, organizationId)
250+
: false;
248251

249252
for (const stepDef of REQUIRED_GCP_API_STEPS) {
250-
steps.push(
251-
await this.runEnableApiSetupStep({
252-
stepDef,
253-
accessToken,
254-
projectId,
255-
}),
256-
);
253+
let step = await this.runEnableApiSetupStep({
254+
stepDef,
255+
accessToken,
256+
projectId,
257+
});
258+
259+
// If findings are already readable, SCC API access is effectively working for this org.
260+
if (
261+
stepDef.id === 'enable_security_command_center_api' &&
262+
!step.success &&
263+
hasFindingsAccess
264+
) {
265+
step = {
266+
...step,
267+
success: true,
268+
error: undefined,
269+
};
270+
}
271+
272+
steps.push(step);
257273
}
258274

259275
steps.push(
260276
await this.runGrantFindingsViewerSetupStep({
261277
accessToken,
262278
organizationId,
263279
email,
280+
hasFindingsAccess,
264281
}),
265282
);
266283

@@ -279,6 +296,9 @@ export class GCPSecurityService {
279296
}): Promise<{ email: string | null; step: GcpSetupStep }> {
280297
const { stepId, accessToken, organizationId, projectId } = params;
281298
const email = params.email ?? (await this.detectEmail(accessToken));
299+
const hasFindingsAccess = organizationId
300+
? await this.canReadFindings(accessToken, organizationId)
301+
: false;
282302

283303
if (stepId === 'grant_findings_viewer_role') {
284304
return {
@@ -287,6 +307,7 @@ export class GCPSecurityService {
287307
accessToken,
288308
organizationId,
289309
email,
310+
hasFindingsAccess,
290311
}),
291312
};
292313
}
@@ -296,14 +317,25 @@ export class GCPSecurityService {
296317
throw new Error(`Unsupported GCP setup step: ${stepId}`);
297318
}
298319

299-
return {
300-
email,
301-
step: await this.runEnableApiSetupStep({
302-
stepDef,
303-
accessToken,
304-
projectId,
305-
}),
306-
};
320+
let step = await this.runEnableApiSetupStep({
321+
stepDef,
322+
accessToken,
323+
projectId,
324+
});
325+
326+
if (
327+
stepDef.id === 'enable_security_command_center_api' &&
328+
!step.success &&
329+
hasFindingsAccess
330+
) {
331+
step = {
332+
...step,
333+
success: true,
334+
error: undefined,
335+
};
336+
}
337+
338+
return { email, step };
307339
}
308340

309341
private async detectEmail(accessToken: string): Promise<string | null> {
@@ -408,8 +440,20 @@ export class GCPSecurityService {
408440
accessToken: string;
409441
organizationId: string;
410442
email: string | null;
443+
hasFindingsAccess?: boolean;
411444
}): Promise<GcpSetupStep> {
412-
const { accessToken, organizationId, email } = params;
445+
const { accessToken, organizationId, email, hasFindingsAccess } = params;
446+
447+
// If we can already read findings, required scan permission exists.
448+
// Don't fail setup just because this user cannot grant IAM roles.
449+
if (hasFindingsAccess) {
450+
return {
451+
id: 'grant_findings_viewer_role',
452+
name: 'Grant Findings Viewer role',
453+
success: true,
454+
...FINDINGS_VIEWER_ACTION,
455+
};
456+
}
413457

414458
if (!email) {
415459
return {
@@ -600,6 +644,30 @@ export class GCPSecurityService {
600644
}
601645
}
602646

647+
private async canReadFindings(
648+
accessToken: string,
649+
organizationId: string,
650+
): Promise<boolean> {
651+
try {
652+
const url = new URL(
653+
`https://securitycenter.googleapis.com/v2/organizations/${organizationId}/sources/-/findings`,
654+
);
655+
url.searchParams.set('pageSize', '1');
656+
url.searchParams.set('filter', 'state="ACTIVE"');
657+
658+
const response = await fetch(url.toString(), {
659+
headers: {
660+
Authorization: `Bearer ${accessToken}`,
661+
'Content-Type': 'application/json',
662+
},
663+
});
664+
665+
return response.ok;
666+
} catch {
667+
return false;
668+
}
669+
}
670+
603671
private buildFindingsViewerAdminActions(params: {
604672
organizationId: string;
605673
email: string;

apps/api/src/integration-platform/controllers/services.controller.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,15 @@ export class ServicesController {
8989
} else if (detectedServices) {
9090
enabled = detectedServices.includes(s.id) && !disabledServices.has(s.id);
9191
} else {
92-
// Default: use enabledByDefault from manifest, otherwise enabled
93-
enabled = (s.enabledByDefault ?? true) && !disabledServices.has(s.id);
92+
// No detection data yet:
93+
// - GCP should default to enabled (scan already runs across SCC categories by default),
94+
// so UI doesn't misleadingly show everything as OFF immediately after OAuth connect.
95+
// - Others keep manifest defaults.
96+
if (providerSlug === 'gcp') {
97+
enabled = !disabledServices.has(s.id);
98+
} else {
99+
enabled = (s.enabledByDefault ?? true) && !disabledServices.has(s.id);
100+
}
94101
}
95102

96103
return {

apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.test.tsx

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,23 @@ vi.mock('@/hooks/use-permissions', () => ({
1717
}));
1818

1919
// Mock integration platform hooks
20-
const mockStartOAuth = vi.fn();
21-
const mockUseIntegrationProviders = vi.fn();
22-
const mockUseIntegrationConnections = vi.fn();
20+
const {
21+
mockStartOAuth,
22+
mockUseIntegrationProviders,
23+
mockUseIntegrationConnections,
24+
mockUseVendors,
25+
} = vi.hoisted(() => ({
26+
mockStartOAuth: vi.fn(),
27+
mockUseIntegrationProviders: vi.fn(),
28+
mockUseIntegrationConnections: vi.fn(),
29+
mockUseVendors: vi.fn(),
30+
}));
31+
32+
const { mockRouterPush, mockUseSearchParams } = vi.hoisted(() => ({
33+
mockRouterPush: vi.fn(),
34+
mockUseSearchParams: vi.fn(() => new URLSearchParams()),
35+
}));
36+
2337
vi.mock('@/hooks/use-integration-platform', () => ({
2438
useIntegrationProviders: mockUseIntegrationProviders,
2539
useIntegrationConnections: mockUseIntegrationConnections,
@@ -28,7 +42,6 @@ vi.mock('@/hooks/use-integration-platform', () => ({
2842
}),
2943
}));
3044

31-
const mockUseVendors = vi.fn();
3245
vi.mock('@/hooks/use-vendors', () => ({
3346
useVendors: mockUseVendors,
3447
}));
@@ -78,8 +91,8 @@ vi.mock('next/link', () => ({
7891
// Mock next/navigation
7992
vi.mock('next/navigation', () => ({
8093
useParams: () => ({ orgId: 'org-1' }),
81-
useRouter: () => ({ push: vi.fn() }),
82-
useSearchParams: () => new URLSearchParams(),
94+
useRouter: () => ({ push: mockRouterPush }),
95+
useSearchParams: mockUseSearchParams,
8396
}));
8497

8598
// Mock @trycompai/ui components
@@ -145,6 +158,7 @@ const defaultProps = {
145158
describe('PlatformIntegrations', () => {
146159
beforeEach(() => {
147160
vi.clearAllMocks();
161+
mockUseSearchParams.mockReturnValue(new URLSearchParams() as any);
148162
mockUseIntegrationProviders.mockReturnValue({
149163
providers: [
150164
{
@@ -221,6 +235,53 @@ describe('PlatformIntegrations', () => {
221235
expect(screen.getByText('GitHub')).toBeInTheDocument();
222236
expect(screen.getByTestId('search-input')).toBeInTheDocument();
223237
});
238+
239+
it('treats provider as connected when an active connection exists alongside older disconnected rows', () => {
240+
setMockPermissions(ADMIN_PERMISSIONS);
241+
mockUseIntegrationProviders.mockReturnValue({
242+
providers: [
243+
{
244+
id: 'gcp',
245+
name: 'Google Cloud Platform',
246+
description: 'Cloud security',
247+
category: 'Cloud',
248+
logoUrl: '/gcp.png',
249+
authType: 'oauth2',
250+
oauthConfigured: true,
251+
isActive: true,
252+
requiredVariables: [],
253+
mappedTasks: [],
254+
supportsMultipleConnections: true,
255+
},
256+
],
257+
isLoading: false,
258+
});
259+
mockUseIntegrationConnections.mockReturnValue({
260+
connections: [
261+
// Newest row returned first by API
262+
{
263+
id: 'conn-new-active',
264+
providerSlug: 'gcp',
265+
status: 'active',
266+
variables: {},
267+
createdAt: '2026-04-14T00:00:00.000Z',
268+
},
269+
{
270+
id: 'conn-old-disconnected',
271+
providerSlug: 'gcp',
272+
status: 'disconnected',
273+
variables: {},
274+
createdAt: '2026-04-01T00:00:00.000Z',
275+
},
276+
] as any,
277+
isLoading: false,
278+
refresh: vi.fn(),
279+
});
280+
281+
render(<PlatformIntegrations {...defaultProps} />);
282+
283+
expect(screen.queryByText('Connect')).not.toBeInTheDocument();
284+
});
224285
});
225286

226287
describe('Employee sync import prompt', () => {
@@ -266,10 +327,7 @@ describe('PlatformIntegrations', () => {
266327
});
267328

268329
// Mock useSearchParams to simulate OAuth callback
269-
const { useSearchParams: mockUseSearchParams } = vi.mocked(
270-
await import('next/navigation'),
271-
);
272-
vi.mocked(mockUseSearchParams).mockReturnValue(
330+
mockUseSearchParams.mockReturnValue(
273331
new URLSearchParams('success=true&provider=google-workspace') as any,
274332
);
275333

@@ -330,10 +388,7 @@ describe('PlatformIntegrations', () => {
330388
refresh: vi.fn(),
331389
});
332390

333-
const { useSearchParams: mockUseSearchParams } = vi.mocked(
334-
await import('next/navigation'),
335-
);
336-
vi.mocked(mockUseSearchParams).mockReturnValue(
391+
mockUseSearchParams.mockReturnValue(
337392
new URLSearchParams('success=true&provider=github') as any,
338393
);
339394

apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,38 @@ interface PlatformIntegrationsProps {
9494
taskTemplates: Array<{ id: string; taskId: string; name: string; description: string }>;
9595
}
9696

97+
const CONNECTION_STATUS_PRIORITY: Record<string, number> = {
98+
active: 5,
99+
pending: 4,
100+
error: 3,
101+
paused: 2,
102+
disconnected: 1,
103+
};
104+
105+
const getConnectionPriority = (connection: ConnectionListItem): number => {
106+
return CONNECTION_STATUS_PRIORITY[connection.status] ?? 0;
107+
};
108+
109+
const getConnectionCreatedAtMs = (connection: ConnectionListItem): number => {
110+
const date = new Date(connection.createdAt);
111+
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
112+
};
113+
114+
const shouldReplaceProviderConnection = (
115+
current: ConnectionListItem | undefined,
116+
candidate: ConnectionListItem,
117+
): boolean => {
118+
if (!current) return true;
119+
120+
const currentPriority = getConnectionPriority(current);
121+
const candidatePriority = getConnectionPriority(candidate);
122+
if (candidatePriority !== currentPriority) {
123+
return candidatePriority > currentPriority;
124+
}
125+
126+
return getConnectionCreatedAtMs(candidate) > getConnectionCreatedAtMs(current);
127+
};
128+
97129
export function PlatformIntegrations({ className, taskTemplates }: PlatformIntegrationsProps) {
98130
const { orgId } = useParams<{ orgId: string }>();
99131
const router = useRouter();
@@ -190,10 +222,16 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg
190222
};
191223

192224
// Map connections by provider slug
193-
const connectionsByProvider = useMemo(
194-
() => new Map(connections?.map((c) => [c.providerSlug, c]) || []),
195-
[connections],
196-
);
225+
const connectionsByProvider = useMemo(() => {
226+
const map = new Map<string, ConnectionListItem>();
227+
for (const connection of connections ?? []) {
228+
const current = map.get(connection.providerSlug);
229+
if (shouldReplaceProviderConnection(current, connection)) {
230+
map.set(connection.providerSlug, connection);
231+
}
232+
}
233+
return map;
234+
}, [connections]);
197235

198236
const vendorNames = useMemo(() => {
199237
const vendors = vendorsResponse?.data?.data;

0 commit comments

Comments
 (0)