Skip to content

Commit 7dc966d

Browse files
committed
Merge branch 'main' of https://github.com/trycompai/comp into feat/rbac-v1
2 parents 65b8068 + 1902532 commit 7dc966d

17 files changed

Lines changed: 873 additions & 240 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,5 @@ packages/*/dist
9090
# Release script
9191
scripts/sync-release-branch.sh
9292
/.vscode
93+
94+
.claude/projects/-Users-marfuen-code-comp/

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
## [1.83.6](https://github.com/trycompai/comp/compare/v1.83.5...v1.83.6) (2026-02-18)
2+
3+
4+
### Bug Fixes
5+
6+
* **app:** fix Cancel Invitation dropdown not opening dialog ([#2165](https://github.com/trycompai/comp/issues/2165)) ([78beb4e](https://github.com/trycompai/comp/commit/78beb4e39e58737521688ebc33f5b73474fde713))
7+
* **app:** restore category selection when navigating back from task details page ([#2164](https://github.com/trycompai/comp/issues/2164)) ([3b1b99b](https://github.com/trycompai/comp/commit/3b1b99b43e471e9cdc5da57fa2f713d46ee5c2ec))
8+
9+
## [1.83.5](https://github.com/trycompai/comp/compare/v1.83.4...v1.83.5) (2026-02-18)
10+
11+
12+
### Bug Fixes
13+
14+
* **api:** update Prisma version in Dockerfile to match project ([#2149](https://github.com/trycompai/comp/issues/2149)) ([c478509](https://github.com/trycompai/comp/commit/c478509ccd7a6eed72823825be9ff71bed2e6540))
15+
116
## [1.83.4](https://github.com/trycompai/comp/compare/v1.83.3...v1.83.4) (2026-02-17)
217

318

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,13 @@ export class ConnectionsController {
387387
if (typeof credentials.externalId === 'string') {
388388
metadata.externalId = credentials.externalId;
389389
}
390+
// Store Azure tenant/subscription IDs in metadata for display and pre-filling
391+
if (typeof credentials.tenantId === 'string') {
392+
metadata.tenantId = credentials.tenantId;
393+
}
394+
if (typeof credentials.subscriptionId === 'string') {
395+
metadata.subscriptionId = credentials.subscriptionId;
396+
}
390397
}
391398

392399
// Create connection (only after validation passes)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
'use server';
2+
3+
import { encrypt } from '@/lib/encryption';
4+
import { getIntegrationHandler } from '@comp/integrations';
5+
import { db } from '@db';
6+
import { Prisma } from '@prisma/client';
7+
import { revalidatePath } from 'next/cache';
8+
import { headers } from 'next/headers';
9+
import { z } from 'zod';
10+
import { authActionClient } from '../../../../../actions/safe-action';
11+
import { runTests } from './run-tests';
12+
13+
const connectCloudSchema = z.object({
14+
cloudProvider: z.enum(['aws', 'gcp', 'azure']),
15+
credentials: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
16+
});
17+
18+
export const connectCloudAction = authActionClient
19+
.inputSchema(connectCloudSchema)
20+
.metadata({
21+
name: 'connect-cloud',
22+
track: {
23+
event: 'connect-cloud',
24+
channel: 'cloud-tests',
25+
},
26+
})
27+
.action(async ({ parsedInput: { cloudProvider, credentials }, ctx: { session } }) => {
28+
try {
29+
if (!session.activeOrganizationId) {
30+
return {
31+
success: false,
32+
error: 'No active organization found',
33+
};
34+
}
35+
36+
// Validate credentials before storing
37+
try {
38+
const integrationHandler = getIntegrationHandler(cloudProvider);
39+
if (!integrationHandler) {
40+
return {
41+
success: false,
42+
error: 'Integration handler not found',
43+
};
44+
}
45+
46+
// Process credentials to the format expected by the handler
47+
const typedCredentials = await integrationHandler.processCredentials(
48+
credentials,
49+
async () => '', // Pass through without encryption for validation
50+
);
51+
52+
// Validate by attempting to fetch (this will throw if credentials are invalid)
53+
await integrationHandler.fetch(typedCredentials);
54+
} catch (error) {
55+
console.error('Credential validation failed:', error);
56+
return {
57+
success: false,
58+
error:
59+
error instanceof Error
60+
? `Invalid credentials: ${error.message}`
61+
: 'Failed to validate credentials. Please check your credentials and try again.',
62+
};
63+
}
64+
65+
// Encrypt all credential fields after validation
66+
const encryptedCredentials: Record<string, unknown> = {};
67+
for (const [key, value] of Object.entries(credentials)) {
68+
if (typeof value === 'string') {
69+
if (value.trim()) {
70+
encryptedCredentials[key] = await encrypt(value);
71+
}
72+
continue;
73+
}
74+
75+
if (Array.isArray(value)) {
76+
const encryptedItems = await Promise.all(
77+
value.filter(Boolean).map((item) => encrypt(item)),
78+
);
79+
encryptedCredentials[key] = encryptedItems;
80+
}
81+
}
82+
83+
const accountId =
84+
typeof credentials.accountId === 'string' ? credentials.accountId.trim() : undefined;
85+
const connectionName =
86+
typeof credentials.connectionName === 'string'
87+
? credentials.connectionName.trim()
88+
: undefined;
89+
const regionValues = Array.isArray(credentials.regions)
90+
? credentials.regions
91+
: typeof credentials.region === 'string'
92+
? [credentials.region]
93+
: [];
94+
95+
const tenantId =
96+
typeof credentials.tenantId === 'string' ? credentials.tenantId.trim() : undefined;
97+
const subscriptionId =
98+
typeof credentials.subscriptionId === 'string'
99+
? credentials.subscriptionId.trim()
100+
: undefined;
101+
102+
const settings =
103+
cloudProvider === 'aws'
104+
? {
105+
accountId,
106+
connectionName,
107+
regions: regionValues,
108+
}
109+
: cloudProvider === 'azure'
110+
? {
111+
connectionName,
112+
tenantId,
113+
subscriptionId,
114+
}
115+
: {};
116+
117+
// Create new integration (allow multiple per provider)
118+
const newIntegration = await db.integration.create({
119+
data: {
120+
name: connectionName || cloudProvider.toUpperCase(),
121+
integrationId: cloudProvider,
122+
organizationId: session.activeOrganizationId,
123+
userSettings: encryptedCredentials as Prisma.JsonObject,
124+
settings: settings as Prisma.JsonObject,
125+
},
126+
});
127+
128+
// Trigger immediate scan for only this new connection
129+
// runTests now waits for completion before returning
130+
const runResult = await runTests(newIntegration.id);
131+
132+
// Revalidate the path
133+
const headersList = await headers();
134+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
135+
path = path.replace(/\/[a-z]{2}\//, '/');
136+
revalidatePath(path);
137+
138+
return {
139+
success: true,
140+
trigger: runResult.success
141+
? {
142+
taskId: runResult.taskId ?? undefined,
143+
}
144+
: undefined,
145+
runErrors: runResult.success ? undefined : (runResult.errors ?? undefined),
146+
};
147+
} catch (error) {
148+
console.error('Failed to connect cloud provider:', error);
149+
return {
150+
success: false,
151+
error: error instanceof Error ? error.message : 'Failed to connect cloud provider',
152+
};
153+
}
154+
});

apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
11
'use client';
22

33
import { ConnectIntegrationDialog } from '@/components/integrations/ConnectIntegrationDialog';
4-
import { useApi } from '@/hooks/use-api';
54
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card';
65
import { Input } from '@comp/ui/input';
6+
import { Label } from '@comp/ui/label';
77
import MultipleSelector from '@comp/ui/multiple-selector';
88
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
9-
import {
10-
Button,
11-
Label,
12-
PageHeader,
13-
PageLayout,
14-
Spinner,
15-
} from '@trycompai/design-system';
9+
import { Button, PageHeader, PageLayout, Spinner } from '@trycompai/design-system';
1610
import { ArrowLeft, CheckmarkFilled, Launch } from '@trycompai/design-system/icons';
1711
import { useEffect, useState } from 'react';
1812
import { toast } from 'sonner';
13+
import { connectCloudAction } from '../actions/connect-cloud';
14+
import { validateAwsCredentialsAction } from '../actions/validate-aws-credentials';
1915

2016
type CloudProvider = 'aws' | 'gcp' | 'azure' | null;
2117
type Step = 'choose' | 'connect' | 'validate-aws' | 'success';
@@ -146,27 +142,33 @@ export function EmptyState({
146142
onConnected,
147143
initialProvider = null,
148144
}: EmptyStateProps) {
149-
const api = useApi();
150-
const initialIsAws = initialProvider === 'aws';
151-
const [step, setStep] = useState<Step>(initialProvider && !initialIsAws ? 'connect' : 'choose');
145+
const initialUsesDialog = initialProvider === 'aws' || initialProvider === 'azure';
146+
const [step, setStep] = useState<Step>(
147+
initialProvider && !initialUsesDialog ? 'connect' : 'choose',
148+
);
152149
const [selectedProvider, setSelectedProvider] = useState<CloudProvider>(
153-
initialProvider && !initialIsAws ? initialProvider : null,
150+
initialProvider && !initialUsesDialog ? initialProvider : null,
151+
);
152+
const [showConnectDialog, setShowConnectDialog] = useState(initialUsesDialog);
153+
const [connectDialogProvider, setConnectDialogProvider] = useState<'aws' | 'azure'>(
154+
initialProvider === 'azure' ? 'azure' : 'aws',
154155
);
155-
const [showConnectDialog, setShowConnectDialog] = useState(initialIsAws);
156156
const [credentials, setCredentials] = useState<Record<string, string | string[]>>({});
157157
const [errors, setErrors] = useState<Record<string, string>>({});
158158
const [isConnecting, setIsConnecting] = useState(false);
159159
const [awsRegions, setAwsRegions] = useState<{ value: string; label: string }[]>([]);
160160
const [awsAccountId, setAwsAccountId] = useState<string>('');
161161

162162
useEffect(() => {
163-
if (initialProvider === 'aws') {
163+
if (initialProvider === 'aws' || initialProvider === 'azure') {
164+
setConnectDialogProvider(initialProvider);
164165
setShowConnectDialog(true);
165166
}
166167
}, [initialProvider]);
167168

168169
const handleProviderSelect = (providerId: CloudProvider) => {
169-
if (providerId === 'aws') {
170+
if (providerId === 'aws' || providerId === 'azure') {
171+
setConnectDialogProvider(providerId);
170172
setShowConnectDialog(true);
171173
return;
172174
}
@@ -235,11 +237,7 @@ export function EmptyState({
235237

236238
try {
237239
setIsConnecting(true);
238-
const result = await api.post<{
239-
success: boolean;
240-
accountId?: string;
241-
regions?: { value: string; label: string }[];
242-
}>('/v1/cloud-security/legacy/validate-aws', {
240+
const result = await validateAwsCredentialsAction({
243241
accessKeyId: credentials.access_key_id,
244242
secretAccessKey: credentials.secret_access_key,
245243
});
@@ -255,7 +253,7 @@ export function EmptyState({
255253
setStep('validate-aws');
256254
toast.success('Credentials validated! Now select your regions.');
257255
} else {
258-
toast.error(result?.error || 'Failed to validate credentials');
256+
toast.error(result?.data?.error || 'Failed to validate credentials');
259257
}
260258
} catch (error) {
261259
console.error('Validation error:', error);
@@ -280,25 +278,26 @@ export function EmptyState({
280278

281279
try {
282280
setIsConnecting(true);
283-
const result = await api.post<{
284-
success: boolean;
285-
integrationId?: string;
286-
error?: string;
287-
}>('/v1/cloud-security/legacy/connect', {
288-
provider: selectedProvider,
281+
const result = await connectCloudAction({
282+
cloudProvider: selectedProvider,
289283
credentials,
290284
});
291285

292286
if (result?.data?.success) {
293287
setStep('success');
294-
onConnected?.();
288+
if (result.data?.trigger) {
289+
onConnected?.(result.data.trigger);
290+
}
291+
if (result.data?.runErrors && result.data.runErrors.length > 0) {
292+
toast.error(result.data.runErrors[0] || 'Initial scan reported an issue');
293+
}
295294
if (onBack) {
296295
setTimeout(() => {
297296
onBack();
298297
}, 2000);
299298
}
300299
} else {
301-
toast.error(result?.error || 'Failed to connect cloud provider');
300+
toast.error(result?.data?.error || 'Failed to connect cloud provider');
302301
}
303302
} catch (error) {
304303
console.error('Connection error:', error);
@@ -347,7 +346,7 @@ export function EmptyState({
347346

348347
<CardContent className="space-y-5">
349348
<div className="space-y-2">
350-
<Label htmlFor="region">
349+
<Label htmlFor="region" className="text-sm font-medium">
351350
Regions
352351
</Label>
353352
<MultipleSelector
@@ -422,9 +421,15 @@ export function EmptyState({
422421
<ConnectIntegrationDialog
423422
open={showConnectDialog}
424423
onOpenChange={(open) => setShowConnectDialog(open)}
425-
integrationId="aws"
426-
integrationName="Amazon Web Services"
427-
integrationLogoUrl="https://img.logo.dev/aws.amazon.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ"
424+
integrationId={connectDialogProvider}
425+
integrationName={
426+
connectDialogProvider === 'azure' ? 'Microsoft Azure' : 'Amazon Web Services'
427+
}
428+
integrationLogoUrl={
429+
connectDialogProvider === 'azure'
430+
? 'https://img.logo.dev/azure.microsoft.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ'
431+
: 'https://img.logo.dev/aws.amazon.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ'
432+
}
428433
onConnected={() => {
429434
setShowConnectDialog(false);
430435
onConnected?.();
@@ -434,7 +439,8 @@ export function EmptyState({
434439

435440
<div className="grid w-full gap-4 md:grid-cols-3">
436441
{CLOUD_PROVIDERS.filter(
437-
(cp) => cp.id === 'aws' || !connectedProviders.includes(cp.id),
442+
(cp) =>
443+
cp.id === 'aws' || cp.id === 'azure' || !connectedProviders.includes(cp.id),
438444
).map((cloudProvider) => (
439445
<Card
440446
key={cloudProvider.id}
@@ -523,7 +529,7 @@ export function EmptyState({
523529

524530
return (
525531
<div key={field.id} className="space-y-2">
526-
<Label htmlFor={field.id}>
532+
<Label htmlFor={field.id} className="text-sm font-medium">
527533
{field.label}
528534
</Label>
529535
{field.type === 'select' && options.length > 0 ? (

apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ function ConnectionDetails({ connection }: { connection: Provider }) {
9696
details.push(`Account: ${connection.accountId}`);
9797
}
9898

99+
if (connection.tenantId) {
100+
details.push(`Tenant: ${connection.tenantId}`);
101+
}
102+
103+
if (connection.subscriptionId) {
104+
details.push(`Subscription: ${connection.subscriptionId}`);
105+
}
106+
99107
if (connection.regions?.length) {
100108
details.push(
101109
`${connection.regions.length} region${connection.regions.length !== 1 ? 's' : ''}`,

apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,13 +292,13 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL
292292
canAddConnection={canCreateIntegration}
293293
/>
294294

295-
{/* CloudSettingsModal only for providers that do NOT support multiple connections */}
296-
{/* AWS is managed via ConnectIntegrationDialog since it supports multiple connections */}
295+
{/* CloudSettingsModal for single-connection providers AND legacy connections */}
296+
{/* Legacy connections need this modal for disconnect since ConnectIntegrationDialog can't see them */}
297297
<CloudSettingsModal
298298
open={showSettings}
299299
onOpenChange={setShowSettings}
300300
connectedProviders={connectedProviders
301-
.filter((p) => !p.supportsMultipleConnections)
301+
.filter((p) => !p.supportsMultipleConnections || p.isLegacy)
302302
.map((p) => ({
303303
id: p.integrationId,
304304
connectionId: p.id,

0 commit comments

Comments
 (0)