diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
index 18e0292e0..36a0412f0 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
@@ -243,6 +243,15 @@ export const vendorRiskAssessmentTask: Task<
);
}
+ // Mark vendor as in-progress immediately so UI can show "generating" state
+ // This happens at the start before any processing, so the UI updates right away
+ if (vendor.status !== VendorStatus.in_progress) {
+ await db.vendor.update({
+ where: { id: vendor.id },
+ data: { status: VendorStatus.in_progress },
+ });
+ }
+
if (!vendor.website) {
logger.info('⏭️ SKIP (no website)', { vendor: payload.vendorName });
// Mark vendor as assessed even without website (no risk assessment possible)
@@ -424,13 +433,7 @@ export const vendorRiskAssessmentTask: Task<
};
}
- // Mark vendor as in-progress immediately so UI can show "generating"
- await db.vendor.update({
- where: { id: vendor.id },
- data: {
- status: VendorStatus.in_progress,
- },
- });
+ // Note: status is already set to in_progress at the start of the task
const { creatorMemberId, assigneeMemberId } =
await resolveTaskCreatorAndAssignee({
diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts
index 95b853fc6..0dd829860 100644
--- a/apps/api/src/trust-portal/trust-access.service.ts
+++ b/apps/api/src/trust-portal/trust-access.service.ts
@@ -465,6 +465,35 @@ export class TrustAccessService {
return request;
}
+ /**
+ * Extract domain from email address
+ */
+ private extractEmailDomain(email: string): string {
+ const parts = email.split('@');
+ return (parts[1] ?? '').toLowerCase().trim();
+ }
+
+ /**
+ * Check if email domain is in the allow list (bypasses NDA requirement)
+ */
+ private isDomainInAllowList(
+ email: string,
+ allowedDomains: string[],
+ ): boolean {
+ if (!allowedDomains || allowedDomains.length === 0) {
+ return false;
+ }
+
+ const emailDomain = this.extractEmailDomain(email);
+ if (!emailDomain) {
+ return false;
+ }
+
+ return allowedDomains.some(
+ (allowed) => allowed.toLowerCase().trim() === emailDomain,
+ );
+ }
+
async approveRequest(
organizationId: string,
requestId: string,
@@ -505,6 +534,29 @@ export class TrustAccessService {
throw new BadRequestException('Invalid member ID');
}
+ // Check if email domain is in the allow list
+ const trust = await db.trust.findUnique({
+ where: { organizationId },
+ select: { allowedDomains: true },
+ });
+
+ const isAllowedDomain = this.isDomainInAllowList(
+ request.email,
+ trust?.allowedDomains ?? [],
+ );
+
+ // If domain is in allow list, skip NDA and grant access directly
+ if (isAllowedDomain) {
+ return this.approveWithoutNda({
+ organizationId,
+ requestId,
+ request,
+ member,
+ durationDays,
+ });
+ }
+
+ // Standard flow: require NDA signing
const signToken = this.generateToken(32);
const signTokenExpiresAt = new Date();
signTokenExpiresAt.setDate(signTokenExpiresAt.getDate() + 7);
@@ -565,6 +617,96 @@ export class TrustAccessService {
};
}
+ /**
+ * Approve request without NDA for allowed domains - grants immediate access
+ */
+ private async approveWithoutNda({
+ organizationId,
+ requestId,
+ request,
+ member,
+ durationDays,
+ }: {
+ organizationId: string;
+ requestId: string;
+ request: {
+ email: string;
+ name: string;
+ organization: { name: string };
+ };
+ member: { id: string; userId: string };
+ durationDays: number;
+ }) {
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + durationDays);
+
+ const accessToken = this.generateToken(32);
+ const accessTokenExpiresAt = new Date();
+ accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24);
+
+ const result = await db.$transaction(async (tx) => {
+ const updatedRequest = await tx.trustAccessRequest.update({
+ where: { id: requestId },
+ data: {
+ status: 'approved',
+ reviewerMemberId: member.id,
+ reviewedAt: new Date(),
+ requestedDurationDays: durationDays,
+ },
+ });
+
+ const grant = await tx.trustAccessGrant.create({
+ data: {
+ accessRequestId: requestId,
+ subjectEmail: request.email,
+ expiresAt,
+ accessToken,
+ accessTokenExpiresAt,
+ issuedByMemberId: member.id,
+ },
+ });
+
+ await tx.auditLog.create({
+ data: {
+ organizationId,
+ userId: member.userId,
+ memberId: member.id,
+ entityType: 'trust',
+ entityId: requestId,
+ description: `Access request approved for ${request.email} (allowed domain - NDA bypassed)`,
+ data: {
+ requestId,
+ grantId: grant.id,
+ durationDays,
+ ndaBypassed: true,
+ },
+ },
+ });
+
+ return { request: updatedRequest, grant };
+ });
+
+ const portalUrl = await this.buildPortalAccessUrl({
+ organizationId,
+ organizationName: request.organization.name,
+ accessToken,
+ });
+
+ await this.emailService.sendAccessGrantedEmail({
+ toEmail: request.email,
+ toName: request.name,
+ organizationName: request.organization.name,
+ expiresAt: result.grant.expiresAt,
+ portalUrl,
+ });
+
+ return {
+ request: result.request,
+ grant: result.grant,
+ message: 'Access granted', // NDA bypassed for allowed domain
+ };
+ }
+
async denyRequest(
organizationId: string,
requestId: string,
diff --git a/apps/app/src/actions/safe-action.ts b/apps/app/src/actions/safe-action.ts
index e64730d3e..02b87b9b3 100644
--- a/apps/app/src/actions/safe-action.ts
+++ b/apps/app/src/actions/safe-action.ts
@@ -69,12 +69,13 @@ export const authActionClient = actionClientWithMeta
});
const { fileData: _, ...inputForLog } = (clientInput || {}) as any;
- logger.info('Input ->', JSON.stringify(inputForLog, null, 2));
- logger.info('Result ->', JSON.stringify(result.data, null, 2));
+ // Logger will automatically skip GCP logs to avoid credential exposure
+ logger.info('Input ->', inputForLog);
+ logger.info('Result ->', result.data);
// Also log validation errors if they exist
if (result.validationErrors) {
- logger.warn('Validation Errors ->', JSON.stringify(result.validationErrors, null, 2));
+ logger.warn('Validation Errors ->', result.validationErrors);
}
return result;
@@ -280,12 +281,13 @@ export const authActionClientWithoutOrg = actionClientWithMeta
});
const { fileData: _, ...inputForLog } = (clientInput || {}) as any;
- logger.info('Input ->', JSON.stringify(inputForLog, null, 2));
- logger.info('Result ->', JSON.stringify(result.data, null, 2));
+ // Logger will automatically skip GCP logs to avoid credential exposure
+ logger.info('Input ->', inputForLog);
+ logger.info('Result ->', result.data);
// Also log validation errors if they exist
if (result.validationErrors) {
- logger.warn('Validation Errors ->', JSON.stringify(result.validationErrors, null, 2));
+ logger.warn('Validation Errors ->', result.validationErrors);
}
return result;
diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
index 835dcf5f2..e78174280 100644
--- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
+++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
@@ -218,6 +218,86 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
return 'Initializing...';
}, [stepStatus.currentStep, stepStatus.policiesTotal, stepStatus.policiesCompleted, currentStep]);
+ // Normalize vendor name for deduplication - strips parenthetical suffixes
+ // e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor
+ const normalizeVendorName = useCallback((name: string): string => {
+ return name
+ .toLowerCase()
+ .replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes
+ .trim();
+ }, []);
+
+ const uniqueVendorsInfo = useMemo(() => {
+ const statusRank = (status: 'pending' | 'processing' | 'assessing' | 'completed') => {
+ switch (status) {
+ case 'completed':
+ return 3;
+ case 'assessing':
+ case 'processing':
+ return 2;
+ case 'pending':
+ default:
+ return 1;
+ }
+ };
+
+ const map = new Map<
+ string,
+ { vendor: { id: string; name: string }; rank: number; status: 'pending' | 'processing' | 'assessing' | 'completed' }
+ >();
+
+ stepStatus.vendorsInfo.forEach((vendor) => {
+ const status = stepStatus.vendorsStatus[vendor.id] || 'pending';
+ const nameKey = normalizeVendorName(vendor.name);
+ const rank = statusRank(status);
+ const existing = map.get(nameKey);
+
+ if (!existing || rank > existing.rank) {
+ map.set(nameKey, { vendor, rank, status });
+ }
+ });
+
+ return Array.from(map.values()).map(({ vendor }) => vendor);
+ }, [stepStatus.vendorsInfo, stepStatus.vendorsStatus, normalizeVendorName]);
+
+ // Calculate unique completed count for the counter (to match deduplicated list)
+ const uniqueVendorsCounts = useMemo(() => {
+ const statusRank = (status: 'pending' | 'processing' | 'assessing' | 'completed') => {
+ switch (status) {
+ case 'completed':
+ return 3;
+ case 'assessing':
+ case 'processing':
+ return 2;
+ case 'pending':
+ default:
+ return 1;
+ }
+ };
+
+ const map = new Map<
+ string,
+ { status: 'pending' | 'processing' | 'assessing' | 'completed'; rank: number }
+ >();
+
+ stepStatus.vendorsInfo.forEach((vendor) => {
+ const status = stepStatus.vendorsStatus[vendor.id] || 'pending';
+ const nameKey = normalizeVendorName(vendor.name);
+ const rank = statusRank(status);
+ const existing = map.get(nameKey);
+
+ if (!existing || rank > existing.rank) {
+ map.set(nameKey, { status, rank });
+ }
+ });
+
+ const entries = Array.from(map.values());
+ return {
+ total: entries.length,
+ completed: entries.filter((e) => e.status === 'completed').length,
+ };
+ }, [stepStatus.vendorsInfo, stepStatus.vendorsStatus, normalizeVendorName]);
+
if (!triggerJobId || !mounted) {
return null;
}
@@ -361,10 +441,10 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
const isRisksStep = step.key === 'risk';
const isPoliciesStep = step.key === 'policies';
- // Determine completion based on actual counts, not boolean flags
+ // Determine completion based on unique counts, not raw metadata totals
const vendorsCompleted =
- stepStatus.vendorsTotal > 0 &&
- stepStatus.vendorsCompleted >= stepStatus.vendorsTotal;
+ uniqueVendorsCounts.total > 0 &&
+ uniqueVendorsCounts.completed >= uniqueVendorsCounts.total;
const risksCompleted =
stepStatus.risksTotal > 0 && stepStatus.risksCompleted >= stepStatus.risksTotal;
const policiesCompleted =
@@ -437,7 +517,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
- {stepStatus.vendorsCompleted}/{stepStatus.vendorsTotal}
+ {uniqueVendorsCounts.completed}/{uniqueVendorsCounts.total}
{isVendorsExpanded ? (
@@ -449,7 +529,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
{/* Expanded vendor list */}
- {isVendorsExpanded && stepStatus.vendorsInfo.length > 0 && (
+ {isVendorsExpanded && uniqueVendorsInfo.length > 0 && (
className="overflow-hidden"
>
- {stepStatus.vendorsInfo.map((vendor) => {
+ {uniqueVendorsInfo.map((vendor) => {
const vendorStatus = stepStatus.vendorsStatus[vendor.id] || 'pending';
const isVendorCompleted = vendorStatus === 'completed';
const isVendorProcessing = vendorStatus === 'processing';
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx
index b699564c0..eed05ca91 100644
--- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx
+++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx
@@ -849,6 +849,12 @@ function CheckRunItem({
{run.failedCount} failed
>
)}
+ {run.passedCount > 0 && (
+ <>
+ •
+ {run.passedCount} passed
+ >
+ )}
@@ -909,38 +915,45 @@ function CheckRunItem({
)}
- {/* Passing Results */}
- {passing.length > 0 && findings.length === 0 && (
-
- {passing.slice(0, 2).map((result) => (
-
-
-
{result.title}
- {result.description && (
-
{result.description}
+ {/* Passing Results - always show when there are passing results */}
+ {passing.length > 0 && (
+
+
+ ✓ {passing.length} passed
+
+
+ {passing.slice(0, 3).map((result) => (
+
+
+
{result.title}
+ {result.description && (
+
{result.description}
+ )}
+
+ {result.resourceId}
+
+
+ {result.evidence && Object.keys(result.evidence).length > 0 && (
+
+
+ View Evidence
+
+
+
)}
-
- {result.resourceId}
-
- {result.evidence && Object.keys(result.evidence).length > 0 && (
-
-
- View Evidence
-
-
-
- )}
-
- ))}
- {passing.length > 2 && (
- +{passing.length - 2} more passed
- )}
-
+ ))}
+ {passing.length > 3 && (
+
+ +{passing.length - 3} more passed
+
+ )}
+
+
)}
{/* Logs */}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/approve-dialog.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/approve-dialog.tsx
index 8e5cb50b2..49ac439e9 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/components/approve-dialog.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/components/approve-dialog.tsx
@@ -37,9 +37,9 @@ export function ApproveDialog({
}),
{
loading: 'Approving...',
- success: () => {
+ success: (response) => {
onClose();
- return 'Request approved. NDA email sent.';
+ return response?.message ?? 'Request approved';
},
error: 'Failed to approve request',
},
diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/update-allowed-domains.ts b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/update-allowed-domains.ts
new file mode 100644
index 000000000..50f2d232a
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/update-allowed-domains.ts
@@ -0,0 +1,60 @@
+'use server';
+
+import { authActionClient } from '@/actions/safe-action';
+import { db } from '@db';
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+
+const domainRegex = /^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i;
+
+const updateAllowedDomainsSchema = z.object({
+ allowedDomains: z.array(
+ z
+ .string()
+ .min(1, 'Domain cannot be empty')
+ .regex(domainRegex, 'Invalid domain format')
+ .transform((d) => d.toLowerCase().trim()),
+ ),
+});
+
+export const updateAllowedDomainsAction = authActionClient
+ .inputSchema(updateAllowedDomainsSchema)
+ .metadata({
+ name: 'update-allowed-domains',
+ track: {
+ event: 'update-allowed-domains',
+ channel: 'server',
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { allowedDomains } = parsedInput;
+ const { activeOrganizationId } = ctx.session;
+
+ if (!activeOrganizationId) {
+ throw new Error('No active organization');
+ }
+
+ // Remove duplicates
+ const uniqueDomains = [...new Set(allowedDomains)];
+
+ await db.trust.upsert({
+ where: {
+ organizationId: activeOrganizationId,
+ },
+ update: {
+ allowedDomains: uniqueDomains,
+ },
+ create: {
+ organizationId: activeOrganizationId,
+ allowedDomains: uniqueDomains,
+ },
+ });
+
+ revalidatePath(`/${activeOrganizationId}/trust`);
+ revalidatePath(`/${activeOrganizationId}/trust/portal-settings`);
+
+ return {
+ success: true,
+ allowedDomains: uniqueDomains,
+ };
+ });
diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/AllowedDomainsManager.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/AllowedDomainsManager.tsx
new file mode 100644
index 000000000..dd653dc97
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/AllowedDomainsManager.tsx
@@ -0,0 +1,220 @@
+'use client';
+
+import { useState } from 'react';
+import { useAction } from 'next-safe-action/hooks';
+import { toast } from 'sonner';
+import { Plus, X, Info } from 'lucide-react';
+import { Button } from '@comp/ui/button';
+import { Input } from '@comp/ui/input';
+import { Badge } from '@comp/ui/badge';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@comp/ui/card';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@comp/ui/tooltip';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@comp/ui/alert-dialog';
+import { updateAllowedDomainsAction } from '../actions/update-allowed-domains';
+
+interface AllowedDomainsManagerProps {
+ initialDomains: string[];
+ orgId: string;
+}
+
+const domainRegex = /^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i;
+
+export function AllowedDomainsManager({
+ initialDomains,
+ orgId,
+}: AllowedDomainsManagerProps) {
+ const [domains, setDomains] = useState
(initialDomains);
+ const [lastSavedDomains, setLastSavedDomains] =
+ useState(initialDomains);
+ const [newDomain, setNewDomain] = useState('');
+ const [error, setError] = useState(null);
+ const [domainToDelete, setDomainToDelete] = useState(null);
+
+ const updateDomains = useAction(updateAllowedDomainsAction, {
+ onSuccess: ({ data }) => {
+ toast.success('Allowed domains updated');
+ // Update last saved state from server response
+ if (data?.allowedDomains) {
+ setLastSavedDomains(data.allowedDomains);
+ }
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? 'Failed to update allowed domains');
+ // Revert to last successfully saved state
+ setDomains(lastSavedDomains);
+ },
+ });
+
+ const normalizeDomain = (domain: string): string => {
+ let normalized = domain.toLowerCase().trim();
+ // Remove protocol if present
+ normalized = normalized.replace(/^https?:\/\//i, '');
+ // Remove path
+ normalized = normalized.split('/')[0] ?? normalized;
+ // Remove www prefix
+ normalized = normalized.replace(/^www\./i, '');
+ return normalized;
+ };
+
+ const handleAddDomain = () => {
+ setError(null);
+ const normalized = normalizeDomain(newDomain);
+
+ if (!normalized) {
+ setError('Please enter a domain');
+ return;
+ }
+
+ if (!domainRegex.test(normalized)) {
+ setError('Invalid domain format (e.g., example.com)');
+ return;
+ }
+
+ if (domains.includes(normalized)) {
+ setError('Domain already in list');
+ return;
+ }
+
+ const updatedDomains = [...domains, normalized];
+ setDomains(updatedDomains);
+ setNewDomain('');
+ updateDomains.execute({ allowedDomains: updatedDomains });
+ };
+
+ const handleRemoveDomain = (domainToRemove: string) => {
+ const updatedDomains = domains.filter((d) => d !== domainToRemove);
+ setDomains(updatedDomains);
+ updateDomains.execute({ allowedDomains: updatedDomains });
+ setDomainToDelete(null);
+ };
+
+ const handleConfirmDelete = () => {
+ if (domainToDelete) {
+ handleRemoveDomain(domainToDelete);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddDomain();
+ }
+ };
+
+ return (
+
+
+
+
NDA Bypass - Allowed Domains
+
+
+
+
+
+
+
+ Users with email addresses from these domains will receive
+ direct access to the trust portal without needing to sign an
+ NDA when their request is approved.
+
+
+
+
+
+
+ Email domains that bypass NDA signing for trust portal access
+
+
+
+
+
+
{
+ setNewDomain(e.target.value);
+ setError(null);
+ }}
+ onKeyDown={handleKeyDown}
+ disabled={updateDomains.status === 'executing'}
+ />
+ {error &&
{error}
}
+
+
+
+
+
+
+ {domains.length > 0 && (
+
+ {domains.map((domain) => (
+
+ {domain}
+ setDomainToDelete(domain)}
+ disabled={updateDomains.status === 'executing'}
+ className="ml-1 rounded-full p-0.5 hover:bg-muted-foreground/20 transition-colors"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ !open && setDomainToDelete(null)}
+ >
+
+
+ Remove Domain
+
+ Are you sure you want to remove {domainToDelete} {' '}
+ from the allowed domains list? Users from this domain will need to
+ sign an NDA when requesting access.
+
+
+
+ Cancel
+
+ Remove
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx
index a35b9ecdf..e01e5329e 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx
@@ -25,6 +25,7 @@ import {
type TrustPortalDocument,
} from './TrustPortalAdditionalDocumentsSection';
import { TrustPortalDomain } from './TrustPortalDomain';
+import { AllowedDomainsManager } from './AllowedDomainsManager';
import {
GDPR,
HIPAA,
@@ -134,6 +135,7 @@ export function TrustPortalSwitch({
nen7510FileName,
iso9001FileName,
additionalDocuments,
+ allowedDomains,
}: {
enabled: boolean;
slug: string;
@@ -173,6 +175,7 @@ export function TrustPortalSwitch({
nen7510FileName?: string | null;
iso9001FileName?: string | null;
additionalDocuments: TrustPortalDocument[];
+ allowedDomains: string[];
}) {
const [certificateFiles, setCertificateFiles] = useState>({
iso27001: iso27001FileName ?? null,
@@ -564,6 +567,15 @@ export function TrustPortalSwitch({
)}
+ {form.watch('enabled') && (
+
+ {/* NDA Bypass - Allowed Domains Section */}
+
+
+ )}
{form.watch('enabled') && (
{/* Compliance Frameworks Section */}
diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx
index 1168f998c..f128368ba 100644
--- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx
@@ -76,6 +76,7 @@ export default async function PortalSettingsPage({ params }: { params: Promise<{
createdAt: doc.createdAt.toISOString(),
updatedAt: doc.updatedAt.toISOString(),
}))}
+ allowedDomains={trustPortal?.allowedDomains ?? []}
/>
@@ -127,6 +128,7 @@ const getTrustPortal = async (orgId: string) => {
isVercelDomain: trustPortal?.isVercelDomain,
vercelVerification: trustPortal?.vercelVerification,
friendlyUrl: trustPortal?.friendlyUrl,
+ allowedDomains: trustPortal?.allowedDomains ?? [],
};
};
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
index 89127ebe3..e96dd6887 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
@@ -244,10 +244,53 @@ export function VendorsTable({
return [...vendorsWithStatus, ...pendingVendors, ...tempVendors];
}, [vendors, itemsInfo, itemStatuses, orgId, isActive, onboardingRunId]);
+ const dedupedVendors = useMemo(() => {
+ // SAFE deduplication strategy:
+ // 1. Show ALL real DB vendors (no deduplication) - user data must not be hidden
+ // 2. Hide placeholders if a real vendor with same normalized name exists
+ // 3. Deduplicate placeholders against each other (show only one per name)
+
+ // Normalize vendor name for deduplication - strips parenthetical suffixes
+ const normalizeVendorName = (name: string): string => {
+ return name
+ .toLowerCase()
+ .replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes
+ .trim();
+ };
+
+ // Separate real DB vendors from placeholders
+ const realVendors = mergedVendors.filter((v) => !v.isPending);
+ const placeholders = mergedVendors.filter((v) => v.isPending);
+
+ // Build a set of normalized names from real vendors
+ const realVendorNames = new Set(realVendors.map((v) => normalizeVendorName(v.name)));
+
+ // Deduplicate placeholders: keep only one per name, and only if no real vendor exists
+ const placeholderMap = new Map();
+ placeholders.forEach((placeholder) => {
+ const nameKey = normalizeVendorName(placeholder.name);
+
+ // Skip if a real vendor with this name already exists
+ if (realVendorNames.has(nameKey)) {
+ return;
+ }
+
+ // Keep the first placeholder for each name (or replace if needed)
+ const existing = placeholderMap.get(nameKey);
+ if (!existing) {
+ placeholderMap.set(nameKey, placeholder);
+ }
+ // If multiple placeholders with same name, keep the first one (no ranking needed)
+ });
+
+ // Return all real vendors + deduplicated placeholders
+ return [...realVendors, ...Array.from(placeholderMap.values())];
+ }, [mergedVendors]);
+
const columns = useMemo[]>(() => getColumns(orgId), [orgId]);
const { table } = useDataTable({
- data: mergedVendors,
+ data: dedupedVendors,
columns,
pageCount,
getRowId: (row) => row.id,
@@ -285,33 +328,55 @@ export function VendorsTable({
[itemStatuses],
);
- // Calculate actual assessment progress
+ // Calculate actual assessment progress (using deduplicated counts to match the table)
const assessmentProgress = useMemo(() => {
if (!progress || !itemsInfo.length) {
return null;
}
- // Count vendors that are completed (either 'completed' in metadata or 'assessed' in DB)
- const completedCount = vendors.filter((vendor) => {
- const metadataStatus = itemStatuses[vendor.id];
- return metadataStatus === 'completed' || vendor.status === 'assessed';
- }).length;
+ // Normalize vendor name for deduplication (same as dedupedVendors)
+ const normalizeVendorName = (name: string): string => {
+ return name
+ .toLowerCase()
+ .replace(/\s*\([^)]*\)\s*$/, '')
+ .trim();
+ };
- // Also count vendors in metadata that are completed but not yet in DB
- const completedInMetadata = Object.values(itemStatuses).filter(
- (status) => status === 'completed',
- ).length;
+ // Build a map of unique vendor names with their best status
+ // This mirrors the deduplication logic used for the table
+ const uniqueVendorStatuses = new Map();
+
+ // First, add all vendors from metadata
+ itemsInfo.forEach((item) => {
+ const nameKey = normalizeVendorName(item.name);
+ const metadataStatus = itemStatuses[item.id];
+ const isCompleted = metadataStatus === 'completed';
+
+ const existing = uniqueVendorStatuses.get(nameKey);
+ if (!existing || (isCompleted && !existing.isCompleted)) {
+ uniqueVendorStatuses.set(nameKey, { isCompleted });
+ }
+ });
- // Total is the max of progress.total, itemsInfo.length, or actual vendors created
- const total = Math.max(progress.total, itemsInfo.length, vendors.length);
+ // Then, update with DB vendor statuses (which may be more accurate)
+ vendors.forEach((vendor) => {
+ const nameKey = normalizeVendorName(vendor.name);
+ const metadataStatus = itemStatuses[vendor.id];
+ const isCompleted = metadataStatus === 'completed' || vendor.status === 'assessed';
+
+ const existing = uniqueVendorStatuses.get(nameKey);
+ if (!existing || (isCompleted && !existing.isCompleted)) {
+ uniqueVendorStatuses.set(nameKey, { isCompleted });
+ }
+ });
- // Completed is the max of DB assessed vendors or metadata completed
- const completed = Math.max(completedCount, completedInMetadata);
+ const total = uniqueVendorStatuses.size;
+ const completed = Array.from(uniqueVendorStatuses.values()).filter((v) => v.isCompleted).length;
return { total, completed };
}, [progress, itemsInfo, vendors, itemStatuses]);
- const isEmpty = mergedVendors.length === 0;
+ const isEmpty = dedupedVendors.length === 0;
// Show empty state if onboarding is active (even if progress metadata isn't set yet)
const showEmptyState = isEmpty && onboardingRunId && isActive;
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx
new file mode 100644
index 000000000..bc52b80f0
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView';
+import { useTaskItems } from '@/hooks/use-task-items';
+import { useVendor, type VendorResponse } from '@/hooks/use-vendors';
+import { useEffect, useMemo } from 'react';
+
+interface VendorReviewClientProps {
+ vendorId: string;
+ orgId: string;
+ initialVendor: VendorResponse;
+}
+
+/**
+ * Client component for vendor risk assessment review
+ * Uses SWR with polling to auto-refresh when risk assessment completes
+ */
+export function VendorReviewClient({
+ vendorId,
+ orgId,
+ initialVendor,
+}: VendorReviewClientProps) {
+ // Use SWR for real-time updates with polling (5s default)
+ const { vendor: swrVendor } = useVendor(vendorId, {
+ organizationId: orgId,
+ initialData: initialVendor,
+ });
+
+ const {
+ data: taskItemsResponse,
+ mutate: refreshTaskItems,
+ } = useTaskItems(
+ vendorId,
+ 'vendor',
+ 1,
+ 50,
+ 'createdAt',
+ 'desc',
+ {},
+ {
+ organizationId: orgId,
+ // Avoid always-on polling; we only poll aggressively while generating
+ refreshInterval: 0,
+ revalidateOnFocus: true,
+ },
+ );
+
+ // Use SWR data when available, fall back to initial data
+ const vendor = useMemo(() => {
+ return swrVendor ?? initialVendor;
+ }, [swrVendor, initialVendor]);
+
+ const riskAssessmentData = vendor.riskAssessmentData;
+ const riskAssessmentUpdatedAt = vendor.riskAssessmentUpdatedAt ?? null;
+
+ // Mirror the Tasks section behavior:
+ // If the "Verify risk assessment" task is in progress, the assessment is still generating.
+ const hasGeneratingVerifyRiskAssessmentTask = useMemo(() => {
+ const allTaskItems = taskItemsResponse?.data?.data ?? [];
+ return allTaskItems.some(
+ (t) => t.title === 'Verify risk assessment' && t.status === 'in_progress',
+ );
+ }, [taskItemsResponse]);
+
+ useEffect(() => {
+ if (!hasGeneratingVerifyRiskAssessmentTask) return;
+
+ const interval = setInterval(() => {
+ void refreshTaskItems();
+ }, 3000);
+
+ return () => clearInterval(interval);
+ }, [hasGeneratingVerifyRiskAssessmentTask, refreshTaskItems]);
+
+ // Show risk assessment data if available
+ if (riskAssessmentData) {
+ return (
+
+ );
+ }
+
+ // Show loading state if still processing
+ if (vendor.status === 'in_progress' || hasGeneratingVerifyRiskAssessmentTask) {
+ return (
+
+
+
+
+
+ Analyzing vendor risk profile
+
+
+ We're researching this vendor and generating a comprehensive risk
+ assessment. This typically takes 3-8 minutes.
+
+
+
+
+ );
+ }
+
+ // Show "not available" for assessed vendors without data
+ return (
+
+
+
+ No risk assessment available for this vendor.
+
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx
index 1f368bb96..4dfcb706a 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx
@@ -1,7 +1,5 @@
-'use server';
-
import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
-import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView';
+import type { VendorResponse } from '@/hooks/use-vendors';
import { auth } from '@/utils/auth';
import { extractDomain } from '@/utils/normalize-website';
import { db } from '@db';
@@ -12,6 +10,7 @@ import { cache } from 'react';
import { VendorActions } from '../components/VendorActions';
import { VendorHeader } from '../components/VendorHeader';
import { VendorTabs } from '../components/VendorTabs';
+import { VendorReviewClient } from './components/VendorReviewClient';
interface ReviewPageProps {
params: Promise<{ vendorId: string; locale: string; orgId: string }>;
@@ -26,16 +25,13 @@ export default async function ReviewPage({ params, searchParams }: ReviewPagePro
const vendorResult = await getVendor({ vendorId, organizationId: orgId });
- if (!vendorResult || !vendorResult.vendor) {
+ if (!vendorResult || !vendorResult.vendor || !vendorResult.vendorForClient) {
redirect('/');
}
// Hide tabs when viewing a task in focus mode
const isViewingTask = Boolean(taskItemId);
- const vendor = vendorResult.vendor;
-
- const riskAssessmentData = vendor.riskAssessmentData;
- const riskAssessmentUpdatedAt = vendor.riskAssessmentUpdatedAt ?? null;
+ const { vendor, vendorForClient } = vendorResult;
return (
}
{!isViewingTask && }
- {riskAssessmentData ? (
-
- ) : (
-
-
- {vendor.status === 'in_progress'
- ? 'Risk assessment is being generated. Please check back soon.'
- : 'No risk assessment found yet.'}
-
-
- )}
+
);
@@ -143,14 +124,28 @@ const getVendor = cache(async (params: { vendorId: string; organizationId: strin
globalVendor = duplicates.find((gv) => gv.riskAssessmentData !== null) ?? duplicates[0] ?? null;
}
+ // Return vendor with Date objects for VendorHeader (server component compatible)
+ const vendorWithRiskAssessment = {
+ ...vendor,
+ riskAssessmentData: globalVendor?.riskAssessmentData ?? null,
+ riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null,
+ riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt ?? null,
+ };
+
+ // Serialize dates to strings for VendorReviewClient (client component)
+ const vendorForClient: VendorResponse = {
+ ...vendor,
+ description: vendor.description ?? '',
+ createdAt: vendor.createdAt.toISOString(),
+ updatedAt: vendor.updatedAt.toISOString(),
+ riskAssessmentData: globalVendor?.riskAssessmentData ?? null,
+ riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null,
+ riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt?.toISOString() ?? null,
+ };
+
return {
- vendor: {
- ...vendor,
- // Use GlobalVendors risk assessment data if available, fallback to Vendor (for migration)
- riskAssessmentData: globalVendor?.riskAssessmentData ?? null,
- riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null,
- riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt ?? null,
- },
+ vendor: vendorWithRiskAssessment,
+ vendorForClient,
};
});
diff --git a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
index 35c7bf51b..a2c295a27 100644
--- a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
+++ b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
@@ -31,6 +31,14 @@ const onboardingCompletionSchema = z.object({
devices: z.string().min(1),
authentication: z.string().min(1),
software: z.string().optional(),
+ customVendors: z
+ .array(
+ z.object({
+ name: z.string(),
+ website: z.string().optional(),
+ }),
+ )
+ .optional(),
workLocation: z.string().min(1),
infrastructure: z.string().min(1),
dataTypes: z.string().min(1),
@@ -97,6 +105,50 @@ export const completeOnboarding = authActionClientWithoutOrg
tags: ['onboarding'],
organizationId: parsedInput.organizationId,
}));
+
+ // Add customVendors to context if present (for vendor risk assessment with URLs)
+ if (parsedInput.customVendors && parsedInput.customVendors.length > 0) {
+ contextData.push({
+ question: 'What are your custom vendors and their websites?',
+ answer: JSON.stringify(parsedInput.customVendors),
+ tags: ['onboarding'],
+ organizationId: parsedInput.organizationId,
+ });
+
+ // Add custom vendors to GlobalVendors immediately (if they have URLs and don't exist)
+ // This allows other organizations to benefit from user-contributed vendor data
+ for (const vendor of parsedInput.customVendors) {
+ if (vendor.website && vendor.website.trim()) {
+ try {
+ // Check if vendor with same name already exists in GlobalVendors
+ const existingGlobalVendor = await db.globalVendors.findFirst({
+ where: {
+ company_name: {
+ equals: vendor.name,
+ mode: 'insensitive',
+ },
+ },
+ });
+
+ if (!existingGlobalVendor) {
+ // Create new GlobalVendor entry (approved: false for review)
+ await db.globalVendors.create({
+ data: {
+ website: vendor.website,
+ company_name: vendor.name,
+ approved: false,
+ },
+ });
+ console.log(`Added custom vendor to GlobalVendors: ${vendor.name}`);
+ }
+ } catch (error) {
+ // Log but don't fail - GlobalVendors is a nice-to-have
+ console.warn(`Failed to add vendor ${vendor.name} to GlobalVendors:`, error);
+ }
+ }
+ }
+ }
+
await db.context.createMany({ data: contextData });
// Update organization to mark onboarding as complete
diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx
index 6927886c3..35b2e1f6b 100644
--- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx
+++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx
@@ -7,8 +7,8 @@ import { Button } from '@comp/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@comp/ui/form';
import type { Organization } from '@db';
import { AnimatePresence, motion } from 'framer-motion';
-import { Loader2 } from 'lucide-react';
-import { useEffect, useMemo } from 'react';
+import { AlertCircle, Loader2 } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import Balancer from 'react-wrap-balancer';
import { usePostPaymentOnboarding } from '../hooks/usePostPaymentOnboarding';
@@ -55,13 +55,56 @@ export function PostPaymentOnboarding({
return userEmail.endsWith('@trycomp.ai');
}, [userEmail]);
+ // Track if there are any invalid URLs (from OnboardingStepInput callback)
+ const [hasInvalidUrl, setHasInvalidUrl] = useState(false);
+ // Track if user has attempted to submit with invalid URLs
+ const [showUrlError, setShowUrlError] = useState(false);
+
+ const handleTouchedInvalidUrlChange = useCallback((hasInvalid: boolean) => {
+ setHasInvalidUrl(hasInvalid);
+ // Clear error if URLs are now valid
+ if (!hasInvalid) {
+ setShowUrlError(false);
+ }
+ }, []);
+
+ // Handle Continue button click - show error if there are invalid URLs
+ const handleContinueClick = useCallback(() => {
+ if (step?.key === 'software' && hasInvalidUrl) {
+ setShowUrlError(true);
+ }
+ }, [step?.key, hasInvalidUrl]);
+
+ // Reset error state when step changes
+ useEffect(() => {
+ setShowUrlError(false);
+ }, [stepIndex]);
+
+ // Auto-hide error after 2 seconds
+ useEffect(() => {
+ if (showUrlError) {
+ const timer = setTimeout(() => {
+ setShowUrlError(false);
+ }, 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [showUrlError]);
+
// Check if current step has valid input
const currentStepValue = form.watch(step?.key);
+ const customVendorsValue = form.watch('customVendors');
+
const isCurrentStepValid = (() => {
if (!step) return false;
if (step.key === 'frameworkIds') {
return Array.isArray(currentStepValue) && currentStepValue.length > 0;
}
+ // For software step, check if there's a value in software OR customVendors
+ if (step.key === 'software') {
+ const hasSoftwareValue = Boolean(currentStepValue) && String(currentStepValue).trim().length > 0;
+ const hasCustomVendors = Array.isArray(customVendorsValue) && customVendorsValue.length > 0;
+ return hasSoftwareValue || hasCustomVendors;
+ }
// For other fields, check if they have a value
return Boolean(currentStepValue) && String(currentStepValue).trim().length > 0;
})();
@@ -143,6 +186,7 @@ export function PostPaymentOnboarding({
currentStep={step}
form={form}
savedAnswers={savedAnswers}
+ onTouchedInvalidUrlChange={handleTouchedInvalidUrlChange}
/>
) : (
@@ -170,6 +215,29 @@ export function PostPaymentOnboarding({
{/* Action Buttons - Fixed at bottom */}
+
+
+ {step?.key === 'software' && showUrlError && (
+
+
+
+
+
+ Please fix the invalid URL format
+
+
+ )}
+
{stepIndex > 0 && (
@@ -261,11 +329,12 @@ export function PostPaymentOnboarding({
) : (
+
);
diff --git a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts
index 785a96883..f518523b0 100644
--- a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts
+++ b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts
@@ -97,7 +97,13 @@ export function usePostPaymentOnboarding({
const form = useForm({
resolver: zodResolver(stepSchema),
mode: 'onSubmit',
- defaultValues: { [step.key]: savedAnswers[step.key] || '' },
+ defaultValues: {
+ [step.key]: savedAnswers[step.key] || '',
+ // Include customVendors in default values so they persist across step navigation
+ ...(step.key === 'software' && savedAnswers.customVendors
+ ? { customVendors: savedAnswers.customVendors }
+ : {}),
+ },
});
// Track onboarding start
@@ -160,6 +166,7 @@ export function usePostPaymentOnboarding({
devices: allAnswers.devices || '',
authentication: allAnswers.authentication || '',
software: allAnswers.software || '',
+ customVendors: allAnswers.customVendors || [],
workLocation: allAnswers.workLocation || '',
infrastructure: allAnswers.infrastructure || '',
dataTypes: allAnswers.dataTypes || '',
@@ -186,6 +193,13 @@ export function usePostPaymentOnboarding({
const onSubmit = (data: OnboardingFormFields) => {
const newAnswers: OnboardingFormFields = { ...savedAnswers, ...data };
+ // Capture customVendors from form state (not included in schema-validated data)
+ // Always set customVendors when on software step - including empty array to allow clearing
+ if (step.key === 'software') {
+ const customVendors = form.getValues('customVendors');
+ newAnswers.customVendors = Array.isArray(customVendors) ? customVendors : [];
+ }
+
// Handle multi-select fields with "Other" option
for (const key of Object.keys(newAnswers)) {
// Only process multi-select string fields (exclude objects/arrays)
@@ -195,7 +209,8 @@ export function usePostPaymentOnboarding({
key !== 'frameworkIds' &&
key !== 'shipping' &&
key !== 'cSuite' &&
- key !== 'reportSignatory'
+ key !== 'reportSignatory' &&
+ key !== 'customVendors'
) {
const customValue = newAnswers[`${key}Other`] || '';
const rawValue = newAnswers[key];
@@ -236,9 +251,24 @@ export function usePostPaymentOnboarding({
if (stepIndex > 0) {
// Save current form values before going back
const currentValues = form.getValues();
+
+ // Build updated answers, preserving customVendors when on software step
+ let updatedAnswers = { ...savedAnswers, organizationName };
+
if (currentValues[step.key]) {
- setSavedAnswers({ ...savedAnswers, [step.key]: currentValues[step.key], organizationName });
+ updatedAnswers = { ...updatedAnswers, [step.key]: currentValues[step.key] };
+ }
+
+ // Also save customVendors when on software step (same as onSubmit)
+ if (step.key === 'software') {
+ const customVendors = form.getValues('customVendors');
+ updatedAnswers = {
+ ...updatedAnswers,
+ customVendors: Array.isArray(customVendors) ? customVendors : []
+ };
}
+
+ setSavedAnswers(updatedAnswers);
// Clear form errors
form.clearErrors();
diff --git a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx
index 48dad46db..41134eac9 100644
--- a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx
+++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx
@@ -1,15 +1,19 @@
import { AnimatedWrapper } from '@/components/animated-wrapper';
import { SelectablePill } from '@/components/selectable-pill';
+import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
import { Button } from '@comp/ui/button';
-import { FormLabel } from '@comp/ui/form';
import { Input } from '@comp/ui/input';
import { Label } from '@comp/ui/label';
import { Textarea } from '@comp/ui/textarea';
-import { ChevronDown, ChevronUp, Plus, Trash2, X } from 'lucide-react';
-import { useRef, useState } from 'react';
+import type { GlobalVendors } from '@db';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip';
+import { AlertCircle, ChevronDown, ChevronUp, HelpCircle, Loader2, Plus, Search, Trash2, X } from 'lucide-react';
+import { useAction } from 'next-safe-action/hooks';
+import { useEffect, useRef, useState } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { Controller, useFieldArray } from 'react-hook-form';
-import type { CompanyDetails, CSuiteEntry, Step } from '../lib/types';
+import { searchGlobalVendorsAction } from '../../[orgId]/vendors/actions/search-global-vendors-action';
+import type { CompanyDetails, CSuiteEntry, CustomVendor, Step } from '../lib/types';
import { FrameworkSelection } from './FrameworkSelection';
import { WebsiteInput } from './WebsiteInput';
@@ -22,6 +26,7 @@ interface OnboardingStepInputProps {
form: UseFormReturn;
savedAnswers: Partial;
onLoadingChange?: (loading: boolean) => void;
+ onTouchedInvalidUrlChange?: (hasTouchedInvalidUrl: boolean) => void;
}
export function OnboardingStepInput({
@@ -29,6 +34,7 @@ export function OnboardingStepInput({
form,
savedAnswers,
onLoadingChange,
+ onTouchedInvalidUrlChange,
}: OnboardingStepInputProps) {
// Hooks must be called at the top level
const [customValue, setCustomValue] = useState('');
@@ -219,6 +225,21 @@ export function OnboardingStepInput({
);
}
+ // Special handling for software step with custom vendor URL support
+ if (currentStep.key === 'software' && currentStep.options) {
+ return (
+
+ );
+ }
+
if (currentStep.options) {
// Single-select fields
if (currentStep.key === 'industry' || currentStep.key === 'workLocation') {
@@ -290,7 +311,7 @@ export function OnboardingStepInput({
inputRef.current?.focus()}
- className="flex flex-wrap items-center gap-2 p-3 border border-input rounded-md min-h-[3.25rem] bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 cursor-text transition-all duration-200 ease-in-out"
+ className="flex flex-wrap items-center gap-2 p-3 border border-input rounded-md min-h-[3.25rem] bg-background focus-within:border-ring/50 focus-within:ring-1 focus-within:ring-ring/20 cursor-text transition-all duration-200 ease-in-out"
>
{selectedValues.map((value) => (
);
}
+
+// Helper to get display name from GlobalVendor
+const getVendorDisplayName = (vendor: GlobalVendors): string => {
+ return vendor.company_name ?? vendor.legal_name ?? vendor.website ?? '';
+};
+
+// Helper to normalize vendor name for deduplication
+// Strips parenthetical suffixes like "(cool)", trims whitespace, and lowercases
+const normalizeVendorName = (name: string): string => {
+ return name
+ .toLowerCase()
+ .replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes
+ .trim();
+};
+
+// Helper to validate domain/URL format
+const isValidDomain = (domain: string): boolean => {
+ if (!domain || domain.trim() === '') return true; // Empty is valid (optional field)
+
+ // Clean the input
+ const cleaned = domain.trim().toLowerCase();
+
+ // Domain regex: allows subdomains, requires at least one dot and valid TLD
+ const domainRegex = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
+
+ return domainRegex.test(cleaned);
+};
+
+// Software vendor input with custom vendor URL support and GlobalVendors autocomplete
+function SoftwareVendorInput({
+ form,
+ currentStep,
+ customValue,
+ setCustomValue,
+ inputRef,
+ containerRef,
+ onTouchedInvalidUrlChange,
+}: {
+ form: UseFormReturn;
+ currentStep: Step;
+ customValue: string;
+ setCustomValue: (value: string) => void;
+ inputRef: React.RefObject;
+ containerRef: React.RefObject;
+ onTouchedInvalidUrlChange?: (hasTouchedInvalidUrl: boolean) => void;
+}) {
+ const predefinedOptions = currentStep.options || [];
+
+ // Search state
+ const [searchResults, setSearchResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+
+ // URL validation state - track which fields have been touched/blurred
+ const [touchedUrls, setTouchedUrls] = useState>(new Set());
+ // Timers for debounced validation (3 seconds)
+ const validationTimersRef = useRef>(new Map());
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return () => {
+ validationTimersRef.current.forEach((timer) => clearTimeout(timer));
+ validationTimersRef.current.clear();
+ };
+ }, []);
+
+ // Get custom vendors from form
+ const customVendorsForCallback = (form.watch('customVendors') as CustomVendor[] | undefined) || [];
+
+ // Notify parent about touched invalid URLs
+ useEffect(() => {
+ if (onTouchedInvalidUrlChange) {
+ const hasTouchedInvalid = customVendorsForCallback.some((vendor) => {
+ if (!touchedUrls.has(vendor.name)) return false;
+ const url = (vendor.website || '').replace(/^https?:\/\//, '').replace(/^www\./, '');
+ return url.length > 0 && !isValidDomain(url);
+ });
+ onTouchedInvalidUrlChange(hasTouchedInvalid);
+ }
+ }, [touchedUrls, customVendorsForCallback, onTouchedInvalidUrlChange]);
+
+ // Get predefined vendors from software field (comma-separated)
+ const rawSoftware = form.watch('software') as string | undefined;
+ const selectedPredefined = (rawSoftware || '').split(',').filter(Boolean);
+
+ // Get custom vendors from customVendors field
+ const customVendors = (form.watch('customVendors') as CustomVendor[] | undefined) || [];
+
+ // Search GlobalVendors action
+ const searchVendors = useAction(searchGlobalVendorsAction, {
+ onExecute: () => setIsSearching(true),
+ onSuccess: (result) => {
+ if (result.data?.success && result.data.data?.vendors) {
+ setSearchResults(result.data.data.vendors);
+ } else {
+ setSearchResults([]);
+ }
+ setIsSearching(false);
+ },
+ onError: () => {
+ setSearchResults([]);
+ setIsSearching(false);
+ },
+ });
+
+ const debouncedSearch = useDebouncedCallback((query: string) => {
+ if (query.trim().length >= 1) {
+ searchVendors.execute({ name: query });
+ setShowSuggestions(true);
+ } else {
+ setSearchResults([]);
+ setShowSuggestions(false);
+ }
+ }, 300);
+
+ const handlePredefinedToggle = (option: string) => {
+ const isSelected = selectedPredefined.includes(option);
+ let newValues: string[];
+
+ if (isSelected) {
+ newValues = selectedPredefined.filter((v) => v !== option);
+ } else {
+ newValues = [...selectedPredefined, option];
+ }
+
+ form.setValue('software', newValues.join(','));
+ };
+
+ const handleSelectGlobalVendor = (vendor: GlobalVendors) => {
+ const name = getVendorDisplayName(vendor);
+ const normalizedName = normalizeVendorName(name);
+
+ // Check if already selected (using normalized names)
+ const alreadyInPredefined = selectedPredefined.some(
+ (v) => normalizeVendorName(v) === normalizedName,
+ );
+ const alreadyInCustom = customVendors.some(
+ (v) => normalizeVendorName(v.name) === normalizedName,
+ );
+ if (alreadyInPredefined || alreadyInCustom) {
+ setCustomValue('');
+ setShowSuggestions(false);
+ setSearchResults([]);
+ return;
+ }
+
+ // Add as known vendor (to software field)
+ const newValues = [...selectedPredefined, name];
+ form.setValue('software', newValues.join(','));
+
+ setCustomValue('');
+ setShowSuggestions(false);
+ setSearchResults([]);
+ };
+
+ const handleAddCustomVendor = () => {
+ const trimmedValue = customValue.trim();
+ if (!trimmedValue) return;
+
+ const normalizedInput = normalizeVendorName(trimmedValue);
+
+ // Check if already exists in selected predefined or custom (using normalized names)
+ const alreadyInPredefined = selectedPredefined.some(
+ (v) => normalizeVendorName(v) === normalizedInput,
+ );
+ if (alreadyInPredefined) {
+ setCustomValue('');
+ setShowSuggestions(false);
+ return;
+ }
+ if (customVendors.some((v) => normalizeVendorName(v.name) === normalizedInput)) {
+ setCustomValue('');
+ setShowSuggestions(false);
+ return;
+ }
+
+ // Check if the typed value matches a predefined option (using normalized names)
+ const matchedPredefined = predefinedOptions.find(
+ (option) => normalizeVendorName(option) === normalizedInput,
+ );
+
+ // Check if there's a matching GlobalVendor in search results (using normalized names)
+ const matchedGlobal = searchResults.find(
+ (v) => normalizeVendorName(getVendorDisplayName(v)) === normalizedInput,
+ );
+
+ if (matchedPredefined) {
+ // Add as predefined vendor (use the correct casing from predefinedOptions)
+ const newValues = [...selectedPredefined, matchedPredefined];
+ form.setValue('software', newValues.join(','));
+ } else if (matchedGlobal) {
+ // Add as known vendor from GlobalVendors
+ const newValues = [...selectedPredefined, getVendorDisplayName(matchedGlobal)];
+ form.setValue('software', newValues.join(','));
+ } else {
+ // Add to custom vendors
+ const newCustomVendors: CustomVendor[] = [...customVendors, { name: trimmedValue }];
+ form.setValue('customVendors', newCustomVendors);
+ }
+
+ setCustomValue('');
+ setShowSuggestions(false);
+ setSearchResults([]);
+ };
+
+ const handleRemoveCustomVendor = (vendorName: string) => {
+ const newCustomVendors = customVendors.filter((v) => v.name !== vendorName);
+ form.setValue('customVendors', newCustomVendors);
+ };
+
+ const handleCustomVendorWebsiteChange = (vendorName: string, website: string) => {
+ const newCustomVendors = customVendors.map((v) =>
+ v.name === vendorName ? { ...v, website } : v,
+ );
+ form.setValue('customVendors', newCustomVendors);
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ setCustomValue(value);
+ debouncedSearch(value);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddCustomVendor();
+ } else if (e.key === 'Escape') {
+ setShowSuggestions(false);
+ } else if (e.key === 'Backspace' && customValue === '') {
+ e.preventDefault();
+ // Remove last custom vendor first, then predefined
+ if (customVendors.length > 0) {
+ const newCustomVendors = customVendors.slice(0, -1);
+ form.setValue('customVendors', newCustomVendors);
+ } else if (selectedPredefined.length > 0) {
+ const newValues = selectedPredefined.slice(0, -1);
+ form.setValue('software', newValues.join(','));
+ }
+ }
+ };
+
+ // Deduplicate search results by normalized name (strips parenthetical suffixes)
+ // e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor
+ const uniqueSearchResults = Array.from(
+ searchResults.reduce((map, vendor) => {
+ const normalizedName = normalizeVendorName(getVendorDisplayName(vendor));
+ if (!map.has(normalizedName)) {
+ map.set(normalizedName, vendor);
+ }
+ return map;
+ }, new Map()),
+ ).map(([, vendor]) => vendor);
+
+ // Filter out already selected vendors from search results (using normalized names)
+ const filteredSearchResults = uniqueSearchResults.filter((vendor) => {
+ const normalizedName = normalizeVendorName(getVendorDisplayName(vendor));
+ return (
+ !selectedPredefined.some((v) => normalizeVendorName(v) === normalizedName) &&
+ !customVendors.some((v) => normalizeVendorName(v.name) === normalizedName)
+ );
+ });
+
+ // All selected values for display in the tag input
+ const allSelectedValues = [
+ ...selectedPredefined.map((name) => ({ name, isCustom: false })),
+ ...customVendors.map((v) => ({ name: v.name, isCustom: true })),
+ ];
+
+ return (
+
+
+ {/* Tag input container with autocomplete */}
+
+
inputRef.current?.focus()}
+ className="flex flex-wrap items-center gap-2 p-3 border border-input rounded-md min-h-[3.25rem] bg-background focus-within:border-ring/50 focus-within:ring-1 focus-within:ring-ring/20 cursor-text transition-all duration-200 ease-in-out"
+ >
+
+ {allSelectedValues.map(({ name, isCustom }) => (
+
+ {name}
+ {
+ e.stopPropagation();
+ if (isCustom) {
+ handleRemoveCustomVendor(name);
+ } else {
+ handlePredefinedToggle(name);
+ }
+ }}
+ className={`rounded-full p-0.5 transition-colors ${
+ isCustom ? 'hover:bg-foreground/10' : 'hover:bg-primary/20'
+ }`}
+ aria-label={`Remove ${name}`}
+ >
+
+
+
+ ))}
+ setTimeout(() => setShowSuggestions(false), 150)}
+ placeholder={
+ allSelectedValues.length === 0 ? 'Search or add custom (press Enter)' : ''
+ }
+ className="flex-1 min-w-[120px] outline-none bg-transparent text-sm placeholder:text-muted-foreground"
+ autoFocus
+ />
+
+
+ {/* Autocomplete suggestions dropdown */}
+ {showSuggestions && customValue.trim().length >= 1 && (
+
+
+ {/* Always show "Add as custom" option first for consistent height */}
+
handleAddCustomVendor()}
+ >
+ {isSearching ? (
+
+
+ Searching for "{customValue.trim()}"...
+
+ ) : (
+ <>Add "{customValue.trim()}" as custom vendor>
+ )}
+
+
+ {/* Animated results section using CSS Grid for smooth height transition */}
+
0 ? '1fr' : '0fr',
+ }}
+ >
+
+
+
+ Suggestions
+
+ {filteredSearchResults.map((vendor) => (
+
handleSelectGlobalVendor(vendor)}
+ >
+ {getVendorDisplayName(vendor)}
+
+ ))}
+
+
+
+
+ )}
+
+
+ {/* Custom vendor URL inputs */}
+ {customVendors.length > 0 && (
+
+
+ Vendor websites
+
+
+ {customVendors.map((vendor) => {
+ // Strip protocol for display, we'll add it back on save
+ const displayValue = (vendor.website || '')
+ .replace(/^https?:\/\//, '')
+ .replace(/^www\./, '');
+
+ const isTouched = touchedUrls.has(vendor.name);
+ const isValid = isValidDomain(displayValue);
+ const showError = isTouched && !isValid && displayValue.length > 0;
+
+ return (
+
+
+
+ {vendor.name}
+
+
+
+
+ https://
+
+
{
+ // Clean input: remove any protocol, www, and trim
+ let value = e.target.value
+ .replace(/^(https?:\/\/)+/gi, '') // Remove one or more https://
+ .replace(/^(www\.)+/gi, '') // Remove one or more www.
+ .trim();
+ const fullUrl = value ? `https://${value}` : '';
+ handleCustomVendorWebsiteChange(vendor.name, fullUrl);
+
+ // Clear touched state when user starts typing again
+ if (touchedUrls.has(vendor.name)) {
+ setTouchedUrls((prev) => {
+ const next = new Set(prev);
+ next.delete(vendor.name);
+ return next;
+ });
+ }
+
+ // Clear existing timer for this field
+ const existingTimer = validationTimersRef.current.get(vendor.name);
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ validationTimersRef.current.delete(vendor.name);
+ }
+
+ // Set new timer for 3 seconds - validate if value is invalid
+ if (value.length > 0) {
+ const timer = setTimeout(() => {
+ // Get current value from form state
+ const currentVendors = (form.watch('customVendors') as CustomVendor[] | undefined) || [];
+ const currentVendor = currentVendors.find((v) => v.name === vendor.name);
+ const currentValue = (currentVendor?.website || '')
+ .replace(/^https?:\/\//, '')
+ .replace(/^www\./, '');
+ const isValid = isValidDomain(currentValue);
+ // Only mark as touched if invalid (to show error)
+ if (!isValid && currentValue.length > 0) {
+ setTouchedUrls((prev) => new Set(prev).add(vendor.name));
+ }
+ validationTimersRef.current.delete(vendor.name);
+ }, 3000);
+ validationTimersRef.current.set(vendor.name, timer);
+ }
+ }}
+ onBlur={() => {
+ // Clear any pending timer
+ const existingTimer = validationTimersRef.current.get(vendor.name);
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ validationTimersRef.current.delete(vendor.name);
+ }
+
+ // Check validity immediately on blur
+ const isValid = isValidDomain(displayValue);
+ // Only mark as touched if invalid (to show error)
+ if (!isValid && displayValue.length > 0) {
+ setTouchedUrls((prev) => new Set(prev).add(vendor.name));
+ }
+ }}
+ placeholder="example.com"
+ className="flex-1 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground"
+ />
+ {showError ? (
+
+ ) : !displayValue ? (
+
+
+
+
+
+
+
+
+
+ Without a URL, we can't perform automatic risk assessment for this vendor.
+
+
+
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts
index e3e483f6a..6cc0cf28b 100644
--- a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts
+++ b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts
@@ -163,7 +163,8 @@ export function useOnboardingForm({
key !== 'frameworkIds' &&
key !== 'shipping' &&
key !== 'cSuite' &&
- key !== 'reportSignatory'
+ key !== 'reportSignatory' &&
+ key !== 'customVendors'
) {
const customValue = newAnswers[`${key}Other`] || '';
const rawValue = newAnswers[key];
diff --git a/apps/app/src/app/(app)/setup/lib/constants.ts b/apps/app/src/app/(app)/setup/lib/constants.ts
index 3c9988a22..b4abf9fd9 100644
--- a/apps/app/src/app/(app)/setup/lib/constants.ts
+++ b/apps/app/src/app/(app)/setup/lib/constants.ts
@@ -27,6 +27,14 @@ export const companyDetailsSchema = z.object({
email: z.string().email('Please enter a valid email'),
}),
software: z.string().optional(),
+ customVendors: z
+ .array(
+ z.object({
+ name: z.string().min(1, 'Vendor name is required'),
+ website: z.string().optional(),
+ }),
+ )
+ .optional(),
infrastructure: z.string().min(1, 'Please select your infrastructure'),
dataTypes: z.string().min(1, 'Please select types of data you handle'),
devices: z.string().min(1, 'Please select device types'),
@@ -112,23 +120,7 @@ export const steps: Step[] = [
question: 'What software do you use?',
placeholder: 'e.g., Rippling',
skippable: true,
- options: [
- 'Rippling',
- 'Gusto',
- 'Salesforce',
- 'HubSpot',
- 'Slack',
- 'Zoom',
- 'Notion',
- 'Linear',
- 'Jira',
- 'Confluence',
- 'GitHub',
- 'GitLab',
- 'Figma',
- 'Stripe',
- 'Other',
- ],
+ options: [],
},
{
key: 'workLocation',
diff --git a/apps/app/src/app/(app)/setup/lib/types.ts b/apps/app/src/app/(app)/setup/lib/types.ts
index 683a67feb..5c73dc815 100644
--- a/apps/app/src/app/(app)/setup/lib/types.ts
+++ b/apps/app/src/app/(app)/setup/lib/types.ts
@@ -9,6 +9,11 @@ export type ReportSignatory = {
email: string;
};
+export type CustomVendor = {
+ name: string;
+ website?: string;
+};
+
export type CompanyDetails = {
frameworkIds: string[];
organizationName: string;
@@ -24,6 +29,7 @@ export type CompanyDetails = {
infrastructure: string;
dataTypes: string;
software?: string;
+ customVendors?: CustomVendor[];
geo: string;
shipping: {
fullName: string;
diff --git a/apps/app/src/components/integrations/ManageIntegrationDialog.tsx b/apps/app/src/components/integrations/ManageIntegrationDialog.tsx
index e1371bf1e..18dfa50ae 100644
--- a/apps/app/src/components/integrations/ManageIntegrationDialog.tsx
+++ b/apps/app/src/components/integrations/ManageIntegrationDialog.tsx
@@ -20,7 +20,7 @@ import { Label } from '@comp/ui/label';
import MultipleSelector from '@comp/ui/multiple-selector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs';
-import { Key, Loader2, Settings, Trash2, Unplug } from 'lucide-react';
+import { Key, Loader2, Settings, Trash2, Unplug, X } from 'lucide-react';
import Image from 'next/image';
import { useParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -462,6 +462,29 @@ function ConfigurationContent({
const hasCredentials = authStrategy === 'custom' && credentialFields.length > 0;
const showTabs = hasVariables && hasCredentials;
+ // Validate target_repos - each repo must have at least one branch
+ const validateTargetRepos = (): boolean => {
+ const targetReposValue = variableValues.target_repos;
+ if (!Array.isArray(targetReposValue) || targetReposValue.length === 0) {
+ return true; // No repos selected is OK (will be caught by required check)
+ }
+ // Check that each repo has a branch specified
+ for (const value of targetReposValue) {
+ const colonIndex = String(value).lastIndexOf(':');
+ if (colonIndex <= 0) {
+ // No colon means no branch specified
+ return false;
+ }
+ const branch = String(value).substring(colonIndex + 1).trim();
+ if (!branch) {
+ return false;
+ }
+ }
+ return true;
+ };
+
+ const isTargetReposValid = validateTargetRepos();
+
// If neither available, show empty state
if (!hasVariables && !hasCredentials) {
return (
@@ -495,7 +518,7 @@ function ConfigurationContent({
)}
{variable.type === 'multi-select' ? (
-
+
{savingVariables ? (
<>
@@ -641,10 +668,10 @@ function ConfigurationContent({
const currentValue = credentialValues[field.id];
// Find existing item or create synthetic one for custom values
const selectedItem = currentValue
- ? items.find((item) => item.id === currentValue) ?? {
+ ? (items.find((item) => item.id === currentValue) ?? {
id: currentValue,
label: currentValue,
- }
+ })
: undefined;
return (
{variablesContent || credentialsContent} ;
}
-// Helper component for multi-select variables with lazy loading
-function MultiSelectVariable({
+/**
+ * Parse a stored value like "owner/repo:branch" into parts.
+ * Handles trailing colons, empty branches, and non-string values.
+ */
+const parseRepoBranch = (value: unknown): { repo: string; branch: string } => {
+ // Safely convert to string to handle corrupted/migrated data
+ const stringValue = String(value ?? '');
+ // Remove trailing colon if present
+ const cleanValue = stringValue.endsWith(':') ? stringValue.slice(0, -1) : stringValue;
+ const colonIndex = cleanValue.lastIndexOf(':');
+
+ if (colonIndex > 0 && colonIndex < cleanValue.length - 1) {
+ return {
+ repo: cleanValue.substring(0, colonIndex),
+ branch: cleanValue.substring(colonIndex + 1),
+ };
+ }
+ // No branch specified - return empty string so user can type
+ return { repo: cleanValue, branch: '' };
+};
+
+/**
+ * Format repo and branch into stored format.
+ * If branch is empty, just store the repo (will default to main on parse).
+ */
+const formatRepoBranch = (repo: string, branch: string): string => {
+ const trimmedBranch = branch.trim();
+ if (!trimmedBranch) {
+ return repo; // No colon when branch is empty
+ }
+ return `${repo}:${trimmedBranch}`;
+};
+
+/**
+ * Multi-select with optional branch inputs for GitHub repos.
+ * When variable.id is 'target_repos', shows branch input for each selected repo.
+ */
+function MultiSelectWithBranches({
variable,
options,
isLoadingOptions,
@@ -757,6 +820,10 @@ function MultiSelectVariable({
const selectedValues = Array.isArray(value) ? value : [];
const hasLoadedRef = useRef(false);
+ // For target_repos, parse values to extract repos and branches
+ const isGitHubRepos = variable.id === 'target_repos';
+ const parsedConfigs = isGitHubRepos ? selectedValues.map(parseRepoBranch) : [];
+
useEffect(() => {
if (
variable.hasDynamicOptions &&
@@ -767,28 +834,109 @@ function MultiSelectVariable({
hasLoadedRef.current = true;
onLoadOptions();
}
- }, []);
+ }, [variable.hasDynamicOptions, options.length, isLoadingOptions, onLoadOptions]);
- return (
- ({
- value: v,
- label: options.find((o) => o.value === v)?.label || v,
- }))}
- onChange={(selected) => onChange(selected.map((s) => s.value))}
- defaultOptions={options.map((o) => ({ value: o.value, label: o.label }))}
- options={options.map((o) => ({ value: o.value, label: o.label }))}
- placeholder={`Select ${variable.label.toLowerCase()}...`}
- emptyIndicator={
- isLoadingOptions ? (
-
-
- Loading options...
-
- ) : (
- No options available
- )
+ // Handle repo selection change
+ const handleRepoSelectionChange = (selectedRepos: string[]) => {
+ if (!isGitHubRepos) {
+ onChange(selectedRepos);
+ return;
+ }
+
+ // For GitHub repos, preserve existing branches when repos are reselected
+ const newValues = selectedRepos.map((repo) => {
+ // Check if this repo already exists in current values
+ const existing = parsedConfigs.find((c) => c.repo === repo);
+ // Use existing branch, or empty string for new repos (user will type it)
+ return formatRepoBranch(repo, existing?.branch || '');
+ });
+ onChange(newValues);
+ };
+
+ // Handle branch change for a specific repo
+ const handleBranchChange = (repo: string, branch: string) => {
+ const newValues = selectedValues.map((v) => {
+ const parsed = parseRepoBranch(v);
+ if (parsed.repo === repo) {
+ // Allow empty string during editing - will default to main on save if empty
+ return formatRepoBranch(repo, branch);
}
- />
+ return v;
+ });
+ onChange(newValues);
+ };
+
+ // Handle removing a repo
+ const handleRemoveRepo = (repo: string) => {
+ const newValues = selectedValues.filter((v) => parseRepoBranch(v).repo !== repo);
+ onChange(newValues);
+ };
+
+ // Get repos from values for display in multi-select
+ const reposForSelector = isGitHubRepos ? parsedConfigs.map((c) => c.repo) : selectedValues;
+
+ return (
+
+
({
+ value: v,
+ label: options.find((o) => o.value === v)?.label || v,
+ }))}
+ onChange={(selected) => handleRepoSelectionChange(selected.map((s) => s.value))}
+ defaultOptions={options.map((o) => ({ value: o.value, label: o.label }))}
+ options={options.map((o) => ({ value: o.value, label: o.label }))}
+ placeholder={`Select ${variable.label.toLowerCase()}...`}
+ emptyIndicator={
+ isLoadingOptions ? (
+
+
+ Loading options...
+
+ ) : (
+ No options available
+ )
+ }
+ />
+
+ {/* Branch inputs for GitHub repos */}
+ {isGitHubRepos && parsedConfigs.length > 0 && (
+
+
+ Specify branches for each repository (comma-separated for multiple):
+
+ {parsedConfigs.map((config) => {
+ const isEmpty = !config.branch.trim();
+ return (
+
+
+ {config.repo}
+
+ :
+ handleBranchChange(config.repo, e.target.value)}
+ placeholder="main, develop"
+ className={`h-8 flex-1 font-mono text-sm ${
+ isEmpty ? 'border-destructive bg-destructive/5 focus-visible:ring-destructive' : ''
+ }`}
+ />
+ handleRemoveRepo(config.repo)}
+ className="shrink-0 rounded p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
+ >
+
+
+
+ );
+ })}
+ {parsedConfigs.some((c) => !c.branch.trim()) && (
+
+ Each repository must have at least one branch specified.
+
+ )}
+
+ )}
+
);
}
diff --git a/apps/app/src/hooks/use-access-requests.ts b/apps/app/src/hooks/use-access-requests.ts
index c4dbd68d8..16980a6b5 100644
--- a/apps/app/src/hooks/use-access-requests.ts
+++ b/apps/app/src/hooks/use-access-requests.ts
@@ -37,6 +37,13 @@ export type AccessGrant = {
createdAt: string;
};
+export type ApproveAccessRequestResponse = {
+ message: string;
+ request?: AccessRequest;
+ grant?: AccessGrant;
+ ndaAgreement?: unknown;
+};
+
export function useAccessRequests(orgId: string) {
const api = useApi();
@@ -63,7 +70,7 @@ export function useApproveAccessRequest(orgId: string) {
return useMutation({
mutationFn: async ({ requestId, durationDays }: { requestId: string; durationDays: number }) => {
- const response = await api.post(
+ const response = await api.post(
`/v1/trust-access/admin/requests/${requestId}/approve`,
{ durationDays },
orgId,
diff --git a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts
index 2ba9a0e43..c3c997654 100644
--- a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts
+++ b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts
@@ -11,6 +11,7 @@ import {
RiskStatus,
RiskTreatmentType,
VendorCategory,
+ VendorStatus,
} from '@db';
import { logger, metadata, tasks } from '@trigger.dev/sdk';
import { generateObject, generateText, jsonSchema } from 'ai';
@@ -182,12 +183,78 @@ export async function getOrganizationContext(
return { organization, questionsAndAnswers, policies: typedPolicies };
}
+type CustomVendorEntry = {
+ name: string;
+ website?: string;
+};
+
+/**
+ * Parses all selected vendors from context
+ * Returns the full list of all vendors (from software field) and custom vendor URL map
+ */
+function parseAllSelectedVendors(
+ questionsAndAnswers: ContextItem[],
+): {
+ allVendorNames: string[];
+ customVendors: CustomVendorEntry[];
+ urlMap: Map;
+} {
+ const allVendorNames: string[] = [];
+ const customVendors: CustomVendorEntry[] = [];
+ const urlMap = new Map();
+
+ // Find the software answer (contains ALL selected vendor names as comma-separated)
+ const softwareEntry = questionsAndAnswers.find(
+ (qa) => qa.question === 'What software do you use?',
+ );
+
+ if (softwareEntry && softwareEntry.answer) {
+ // Parse comma-separated vendor names
+ const names = softwareEntry.answer.split(',').map((n) => n.trim()).filter(Boolean);
+ allVendorNames.push(...names);
+ }
+
+ // Find the custom vendors context entry (contains URLs for custom vendors)
+ const customVendorsEntry = questionsAndAnswers.find(
+ (qa) => qa.question === 'What are your custom vendors and their websites?',
+ );
+
+ if (customVendorsEntry) {
+ try {
+ const parsed = JSON.parse(customVendorsEntry.answer) as CustomVendorEntry[];
+
+ for (const vendor of parsed) {
+ customVendors.push(vendor);
+ // Also add custom vendor names to allVendorNames so they're included in the fallback loop
+ // This ensures custom vendors are created even if AI fails to extract them
+ if (!allVendorNames.some((n) => n.toLowerCase() === vendor.name.toLowerCase())) {
+ allVendorNames.push(vendor.name);
+ }
+ if (vendor.website && vendor.website.trim()) {
+ // Store lowercase name for case-insensitive matching
+ urlMap.set(vendor.name.toLowerCase(), vendor.website.trim());
+ }
+ }
+ } catch (e) {
+ logger.warn('Failed to parse custom vendors from context', { error: e });
+ }
+ }
+
+ return { allVendorNames, customVendors, urlMap };
+}
+
/**
* Extracts vendors from context using AI
*/
export async function extractVendorsFromContext(
questionsAndAnswers: ContextItem[],
): Promise {
+ // Parse all selected vendors from context
+ const { allVendorNames, customVendors, urlMap: customVendorUrls } = parseAllSelectedVendors(questionsAndAnswers);
+
+ // Create a set of custom vendor names for quick lookup
+ const customVendorNameSet = new Set(customVendors.map((v) => v.name.toLowerCase()));
+
const { object } = await generateObject({
model: openai('gpt-4.1-mini'),
schema: jsonSchema({
@@ -227,7 +294,49 @@ export async function extractVendorsFromContext(
prompt: questionsAndAnswers.map((q) => `${q.question}\n${q.answer}`).join('\n'),
});
- return (object as { vendors: VendorData[] }).vendors;
+ const vendors = (object as { vendors: VendorData[] }).vendors;
+
+ // Merge custom vendor URLs - user-provided URLs take precedence
+ for (const vendor of vendors) {
+ const customUrl = customVendorUrls.get(vendor.vendor_name.toLowerCase());
+ if (customUrl) {
+ logger.info(`Using custom URL for vendor ${vendor.vendor_name}: ${customUrl}`);
+ vendor.vendor_website = customUrl;
+ }
+ }
+
+ // Track which vendors were extracted by AI
+ const extractedVendorNames = new Set(vendors.map((v) => v.vendor_name.toLowerCase()));
+
+ // Ensure ALL vendors from the software field are added (not just custom ones)
+ // This catches any vendors the AI failed to extract
+ for (const vendorName of allVendorNames) {
+ if (!extractedVendorNames.has(vendorName.toLowerCase())) {
+ const isCustom = customVendorNameSet.has(vendorName.toLowerCase());
+ const customUrl = customVendorUrls.get(vendorName.toLowerCase());
+
+ logger.info(`Adding vendor not extracted by AI: ${vendorName} (custom: ${isCustom})`);
+
+ // Create a vendor entry with default risk values
+ vendors.push({
+ vendor_name: vendorName,
+ vendor_website: customUrl || '',
+ vendor_description: isCustom
+ ? `Custom vendor added during onboarding`
+ : `Vendor selected during onboarding`,
+ category: VendorCategory.other,
+ inherent_probability: Likelihood.possible,
+ inherent_impact: Impact.moderate,
+ residual_probability: Likelihood.possible,
+ residual_impact: Impact.moderate,
+ });
+
+ // Add to extracted set to avoid duplicates
+ extractedVendorNames.add(vendorName.toLowerCase());
+ }
+ }
+
+ return vendors;
}
/**
@@ -320,10 +429,29 @@ export async function createVendorsFromData(
return existing;
}
+ // If vendor has no website, try to find it in GlobalVendors
+ let websiteToUse = vendor.vendor_website;
+ if (!websiteToUse || !websiteToUse.trim()) {
+ const globalVendor = await db.globalVendors.findFirst({
+ where: {
+ company_name: {
+ equals: vendor.vendor_name,
+ mode: 'insensitive',
+ },
+ },
+ select: { website: true },
+ });
+
+ if (globalVendor?.website) {
+ logger.info(`Enriched vendor ${vendor.vendor_name} with website from GlobalVendors: ${globalVendor.website}`);
+ websiteToUse = globalVendor.website;
+ }
+ }
+
const createdVendor = await db.vendor.create({
data: {
name: vendor.vendor_name,
- website: vendor.vendor_website,
+ website: websiteToUse,
description: vendor.vendor_description,
category: vendor.category,
inherentProbability: vendor.inherent_probability,
@@ -331,6 +459,8 @@ export async function createVendorsFromData(
residualProbability: vendor.residual_probability,
residualImpact: vendor.residual_impact,
organizationId,
+ // Set to in_progress immediately so UI shows "generating" state
+ status: VendorStatus.in_progress,
},
});
@@ -465,10 +595,7 @@ async function triggerVendorRiskAssessmentsViaApi(params: {
}
logger.error('Failed to trigger vendor risk assessments via API', errorDetails);
- // Re-throw so we can see it in Trigger.dev dashboard
- throw new Error(
- `Failed to trigger vendor risk assessments: ${error instanceof Error ? error.message : String(error)}`,
- );
+ // Don't re-throw - vendor risk assessment failure should not block onboarding
}
}
diff --git a/apps/app/src/utils/logger.ts b/apps/app/src/utils/logger.ts
index 8d8bd883c..35db13553 100644
--- a/apps/app/src/utils/logger.ts
+++ b/apps/app/src/utils/logger.ts
@@ -1,11 +1,160 @@
+/**
+ * Skip rule configuration for sensitive logs
+ */
+type SkipRule = {
+ field: string;
+ value: unknown;
+ reason?: string; // Optional reason for documentation
+};
+
+/**
+ * Default skip rules for sensitive log filtering.
+ * Add or modify rules here to control which logs are skipped.
+ *
+ * Rules:
+ * - Use exact value match: { field: 'cloudProvider', value: 'gcp' }
+ * - Use wildcard '*' to skip if field exists regardless of value: { field: 'apiKey', value: '*' }
+ */
+const DEFAULT_SKIP_RULES: SkipRule[] = [
+ {
+ field: 'cloudProvider',
+ value: 'gcp',
+ reason: 'May contain credentials',
+ },
+ // Add more skip rules here as needed:
+ // {
+ // field: 'apiKey',
+ // value: '*', // Use '*' to skip if field exists regardless of value
+ // reason: 'Contains sensitive API key',
+ // },
+ // {
+ // field: 'password',
+ // value: '*',
+ // reason: 'Contains password',
+ // },
+];
+
+/**
+ * Validator layer that checks if logs should be skipped based on configured rules.
+ * Optimized to avoid unnecessary JSON stringification - uses direct property access.
+ */
+class LoggerValidatorLayer {
+ private skipRules: SkipRule[];
+
+ constructor(skipRules: SkipRule[] = []) {
+ this.skipRules = skipRules;
+ }
+
+ /**
+ * Add a new skip rule to the validator
+ */
+ addRule(rule: SkipRule): void {
+ this.skipRules.push(rule);
+ }
+
+ /**
+ * Add multiple skip rules at once
+ */
+ addRules(rules: SkipRule[]): void {
+ this.skipRules.push(...rules);
+ }
+
+ /**
+ * Remove a skip rule by field name
+ */
+ removeRule(field: string): void {
+ this.skipRules = this.skipRules.filter((rule) => rule.field !== field);
+ }
+
+ /**
+ * Get all configured skip rules
+ */
+ getRules(): ReadonlyArray {
+ return this.skipRules;
+ }
+
+ /**
+ * Checks if logging should be skipped based on configured rules
+ * Optimized to avoid unnecessary JSON stringification - uses direct property access
+ */
+ shouldSkip(params: unknown): boolean {
+ // Fast path: if not an object, don't skip
+ if (!params || typeof params !== 'object') {
+ return false;
+ }
+
+ const obj = params as Record;
+
+ // Check each skip rule
+ for (const rule of this.skipRules) {
+ // Fast path: check if the field exists using 'in' operator (O(1))
+ if (rule.field in obj) {
+ // If value is '*', skip if field exists regardless of value
+ if (rule.value === '*') {
+ return true;
+ }
+ // Otherwise, check if the value matches
+ if (obj[rule.field] === rule.value) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
+
+// Initialize validator with default skip rules
+const loggerValidator = new LoggerValidatorLayer(DEFAULT_SKIP_RULES);
+
+/**
+ * Safely formats params for logging.
+ * Handles edge cases like circular references and BigInt that would throw in JSON.stringify.
+ */
+const formatParams = (params: unknown): unknown => {
+ if (!params) {
+ return '';
+ }
+
+ if (typeof params !== 'object') {
+ return params;
+ }
+
+ try {
+ return JSON.stringify(params, null, 2);
+ } catch {
+ // Fallback for circular references, BigInt, or other non-serializable values
+ // Return the raw object - console.log handles these natively
+ return params;
+ }
+};
+
export const logger = {
info: (message: string, params?: unknown) => {
- console.log(`[INFO] ${message}`, params || '');
+ // Skip logging if it matches any skip rule
+ if (loggerValidator.shouldSkip(params)) {
+ return;
+ }
+ // Pass message as separate argument to avoid format string injection
+ console.log('[INFO]', message, formatParams(params));
},
warn: (message: string, params?: unknown) => {
- console.warn(`[WARN] ${message}`, params || '');
+ // Skip logging if it matches any skip rule
+ if (loggerValidator.shouldSkip(params)) {
+ return;
+ }
+ // Pass message as separate argument to avoid format string injection
+ console.warn('[WARN]', message, formatParams(params));
},
error: (message: string, params?: unknown) => {
- console.error(`[ERROR] ${message}`, params || '');
+ // Skip logging if it matches any skip rule
+ if (loggerValidator.shouldSkip(params)) {
+ return;
+ }
+ // Pass message as separate argument to avoid format string injection
+ console.error('[ERROR]', message, formatParams(params));
},
};
+
+// Export the validator class for advanced usage if needed
+export { LoggerValidatorLayer };
diff --git a/packages/db/prisma/migrations/20260119192847_add_trust_allowed_domains/migration.sql b/packages/db/prisma/migrations/20260119192847_add_trust_allowed_domains/migration.sql
new file mode 100644
index 000000000..66708c59a
--- /dev/null
+++ b/packages/db/prisma/migrations/20260119192847_add_trust_allowed_domains/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Trust" ADD COLUMN "allowedDomains" TEXT[] DEFAULT ARRAY[]::TEXT[];
diff --git a/packages/db/prisma/schema/trust.prisma b/packages/db/prisma/schema/trust.prisma
index cfbdb047c..ecb84168f 100644
--- a/packages/db/prisma/schema/trust.prisma
+++ b/packages/db/prisma/schema/trust.prisma
@@ -9,6 +9,9 @@ model Trust {
status TrustStatus @default(draft)
contactEmail String?
+ /// Domains that bypass NDA signing when requesting trust portal access
+ allowedDomains String[] @default([])
+
email String?
privacyPolicy String?
soc2 Boolean @default(false)
diff --git a/packages/integration-platform/src/manifests/github/checks/branch-protection.ts b/packages/integration-platform/src/manifests/github/checks/branch-protection.ts
index 2b1696c7b..51c2b9cdd 100644
--- a/packages/integration-platform/src/manifests/github/checks/branch-protection.ts
+++ b/packages/integration-platform/src/manifests/github/checks/branch-protection.ts
@@ -10,13 +10,12 @@ import type { IntegrationCheck } from '../../../types';
import type {
GitHubBranchProtection,
GitHubBranchRule,
- GitHubOrg,
GitHubPullRequest,
GitHubRepo,
GitHubRuleset,
} from '../types';
import {
- protectedBranchVariable,
+ parseRepoBranches,
recentPullRequestDaysVariable,
targetReposVariable,
} from '../variables';
@@ -62,20 +61,44 @@ export const branchProtectionCheck: IntegrationCheck = {
taskMapping: TASK_TEMPLATES.codeChanges,
defaultSeverity: 'high',
- variables: [targetReposVariable, protectedBranchVariable, recentPullRequestDaysVariable],
+ variables: [targetReposVariable, recentPullRequestDaysVariable],
run: async (ctx) => {
const targetRepos = ctx.variables.target_repos as string[] | undefined;
- const protectedBranch = ctx.variables.protected_branch as string | undefined;
const recentDaysRaw = toSafeNumber(ctx.variables.recent_pr_days);
const recentWindowDays =
recentDaysRaw && recentDaysRaw > 0 ? recentDaysRaw : DEFAULT_RECENT_WINDOW_DAYS;
const cutoff = new Date(Date.now() - recentWindowDays * 24 * 60 * 60 * 1000);
+ // Parse repo:branches from each selected value, then flatten to individual repo+branch pairs
+ const parsedConfigs = (targetRepos || []).map(parseRepoBranches);
+ const repoBranchConfigs: { repo: string; branch: string }[] = [];
+ for (const config of parsedConfigs) {
+ for (const branch of config.branches) {
+ repoBranchConfigs.push({ repo: config.repo, branch });
+ }
+ }
+
ctx.log(
- `Config: branch="${protectedBranch}", recentWindowDays=${recentWindowDays}, cutoff=${cutoff.toISOString()}`,
+ `Config: ${repoBranchConfigs.length} repo/branch pairs from ${parsedConfigs.length} repos, recentWindowDays=${recentWindowDays}, cutoff=${cutoff.toISOString()}`,
);
+ // ───────────────────────────────────────────────────────────────────────
+ // Validate configuration
+ // ───────────────────────────────────────────────────────────────────────
+ if (repoBranchConfigs.length === 0) {
+ ctx.fail({
+ title: 'No repositories configured',
+ description:
+ 'No repositories are configured for branch protection checking. Please select at least one repository.',
+ resourceType: 'integration',
+ resourceId: 'github',
+ severity: 'low',
+ remediation: 'Open the integration settings and select repositories to monitor.',
+ });
+ return;
+ }
+
// ───────────────────────────────────────────────────────────────────────
// Helper: fetch recent PRs targeting the protected branch
// ───────────────────────────────────────────────────────────────────────
@@ -102,36 +125,52 @@ export const branchProtectionCheck: IntegrationCheck = {
}
};
- ctx.log('Fetching repositories...');
+ ctx.log(`Checking ${repoBranchConfigs.length} repository/branch configurations...`);
- let repos: GitHubRepo[];
+ // Group configs by repo for combined evidence
+ const repoGroups = new Map();
+ for (const config of repoBranchConfigs) {
+ const branches = repoGroups.get(config.repo) || [];
+ branches.push(config.branch);
+ repoGroups.set(config.repo, branches);
+ }
- if (targetRepos && targetRepos.length > 0) {
- repos = [];
- for (const repoName of targetRepos) {
- try {
- const repo = await ctx.fetch(`/repos/${repoName}`);
- repos.push(repo);
- } catch {
- ctx.warn(`Could not fetch repo ${repoName}`);
- }
- }
- } else {
- const orgs = await ctx.fetch('/user/orgs');
- repos = [];
- for (const org of orgs) {
- const orgRepos = await ctx.fetchAllPages(`/orgs/${org.login}/repos`);
- repos.push(...orgRepos);
+ // ───────────────────────────────────────────────────────────────────────
+ // Check each repository (with all its branches)
+ // ───────────────────────────────────────────────────────────────────────
+ for (const [repoName, branchesToCheck] of repoGroups) {
+ // Fetch repository info
+ let repo: GitHubRepo;
+ try {
+ repo = await ctx.fetch(`/repos/${repoName}`);
+ } catch {
+ ctx.warn(`Could not fetch repo ${repoName}`);
+ ctx.fail({
+ title: `Repository not found: ${repoName}`,
+ description: `Could not access repository "${repoName}". It may not exist or the integration lacks permission.`,
+ resourceType: 'repository',
+ resourceId: repoName,
+ severity: 'medium',
+ remediation: `Verify the repository name is correct (format: owner/repo) and that the GitHub integration has access to it.`,
+ });
+ continue;
}
- }
- ctx.log(`Checking ${repos.length} repositories`);
+ ctx.log(`Checking ${branchesToCheck.length} branches on ${repo.full_name}: ${branchesToCheck.join(', ')}`);
- for (const repo of repos) {
- const branchToCheck = protectedBranch || repo.default_branch;
- if (!branchToCheck) continue;
+ // Collect results for all branches in this repo
+ const branchResults: Record<
+ string,
+ {
+ protected: boolean;
+ evidence: Record;
+ description: string;
+ }
+ > = {};
- ctx.log(`Checking branch "${branchToCheck}" on ${repo.full_name}`);
+ // Check each branch
+ for (const branchToCheck of branchesToCheck) {
+ ctx.log(`Checking branch "${branchToCheck}" on ${repo.full_name}`);
// Fetch recent PRs in parallel while we check protection
const pullRequestsPromise = fetchRecentPullRequests({
@@ -282,35 +321,76 @@ export const branchProtectionCheck: IntegrationCheck = {
}
}
- // Wait for PR fetch to complete
- const pullRequests = await pullRequestsPromise;
+ // Wait for PR fetch to complete
+ const pullRequests = await pullRequestsPromise;
+
+ // Build evidence for this branch
+ const branchEvidence: Record = {
+ protected: isProtected,
+ ...protectionEvidence,
+ pull_requests: pullRequests,
+ pull_requests_window_days: recentWindowDays,
+ checked_at: new Date().toISOString(),
+ };
+
+ branchResults[branchToCheck] = {
+ protected: isProtected,
+ evidence: branchEvidence,
+ description: isProtected
+ ? protectionDescription
+ : `Branch "${branchToCheck}" has no protection rules configured.`,
+ };
+ } // End of branch loop
+
+ // Emit combined result for this repo
+ const protectedBranches = Object.entries(branchResults)
+ .filter(([, r]) => r.protected)
+ .map(([b]) => b);
+ const unprotectedBranches = Object.entries(branchResults)
+ .filter(([, r]) => !r.protected)
+ .map(([b]) => b);
+
+ // Build combined evidence: { "owner/repo": { "branch1": {...}, "branch2": {...} } }
+ const combinedEvidence: Record> = {};
+ for (const [branch, result] of Object.entries(branchResults)) {
+ combinedEvidence[branch] = result.evidence;
+ }
- // Record result
- if (isProtected) {
+ if (unprotectedBranches.length === 0) {
+ // All branches protected
ctx.pass({
- title: `Branch protection enabled on ${repo.name}`,
- description: protectionDescription,
+ title: `All branches protected on ${repo.name}`,
+ description: `${protectedBranches.length} branch(es) have protection enabled: ${protectedBranches.join(', ')}`,
resourceType: 'repository',
resourceId: repo.full_name,
evidence: {
- ...protectionEvidence,
- pull_requests: pullRequests,
- pull_requests_window_days: recentWindowDays,
- checked_at: new Date().toISOString(),
+ [repo.full_name]: combinedEvidence,
},
});
- } else {
+ } else if (protectedBranches.length === 0) {
+ // No branches protected
ctx.fail({
title: `No branch protection on ${repo.name}`,
- description: `Branch "${branchToCheck}" has no protection rules configured, allowing direct pushes without review.`,
+ description: `${unprotectedBranches.length} branch(es) have no protection: ${unprotectedBranches.join(', ')}`,
+ resourceType: 'repository',
+ resourceId: repo.full_name,
+ severity: 'high',
+ remediation: `1. Go to ${repo.html_url}/settings/rules\n2. Create rulesets for branches: ${unprotectedBranches.join(', ')}\n3. Enable "Require a pull request before merging"\n4. Set required approvals to at least 1`,
+ evidence: {
+ [repo.full_name]: combinedEvidence,
+ },
+ });
+ } else {
+ // Mixed: some protected, some not
+ ctx.fail({
+ title: `Partial branch protection on ${repo.name}`,
+ description: `Protected: ${protectedBranches.join(', ')}. Unprotected: ${unprotectedBranches.join(', ')}`,
resourceType: 'repository',
resourceId: repo.full_name,
severity: 'high',
- remediation: `1. Go to ${repo.html_url}/settings/rules\n2. Create a new ruleset targeting branch "${branchToCheck}"\n3. Enable "Require a pull request before merging"\n4. Set required approvals to at least 1`,
+ remediation: `1. Go to ${repo.html_url}/settings/rules\n2. Create rulesets for unprotected branches: ${unprotectedBranches.join(', ')}\n3. Enable "Require a pull request before merging"`,
evidence: {
- pull_requests: pullRequests,
- pull_requests_window_days: recentWindowDays,
- checked_at: new Date().toISOString(),
+ [repo.full_name]: combinedEvidence,
},
});
}
diff --git a/packages/integration-platform/src/manifests/github/checks/dependabot.ts b/packages/integration-platform/src/manifests/github/checks/dependabot.ts
index 98828b9c4..bd7d963a5 100644
--- a/packages/integration-platform/src/manifests/github/checks/dependabot.ts
+++ b/packages/integration-platform/src/manifests/github/checks/dependabot.ts
@@ -7,7 +7,7 @@
import { TASK_TEMPLATES } from '../../../task-mappings';
import type { IntegrationCheck } from '../../../types';
import type { GitHubDependabotAlert, GitHubOrg, GitHubRepo } from '../types';
-import { targetReposVariable } from '../variables';
+import { parseRepoBranch, targetReposVariable } from '../variables';
interface AlertCounts {
open: number;
@@ -32,11 +32,13 @@ export const dependabotCheck: IntegrationCheck = {
variables: [targetReposVariable],
run: async (ctx) => {
- const targetRepos = ctx.variables.target_repos as string[] | undefined;
+ const targetReposRaw = ctx.variables.target_repos as string[] | undefined;
+ // Extract just the repo names (values may be in "owner/repo:branch" format)
+ const targetRepos = (targetReposRaw || []).map((v) => parseRepoBranch(v).repo);
let repos: GitHubRepo[];
- if (targetRepos && targetRepos.length > 0) {
+ if (targetRepos.length > 0) {
repos = [];
for (const repoName of targetRepos) {
try {
@@ -44,6 +46,21 @@ export const dependabotCheck: IntegrationCheck = {
repos.push(repo);
} catch {
ctx.warn(`Could not fetch repo ${repoName}`);
+ // Emit a fail result so the user knows this repo wasn't checked
+ ctx.fail({
+ title: `Repository not found: ${repoName}`,
+ description: `Could not access repository "${repoName}". It may not exist or the integration lacks permission.`,
+ resourceType: 'repository',
+ resourceId: repoName,
+ severity: 'medium',
+ remediation: `Verify the repository name is correct (format: owner/repo) and that the GitHub integration has access to it.`,
+ evidence: {
+ [repoName]: {
+ error: 'Repository not accessible',
+ checked_at: new Date().toISOString(),
+ },
+ },
+ });
}
}
} else {
@@ -147,6 +164,21 @@ export const dependabotCheck: IntegrationCheck = {
// Fetch alert counts regardless of Dependabot status
const alertCounts = await fetchAlertCounts(repo.full_name);
+ // Build hierarchical evidence: { "owner/repo": { data } }
+ const repoEvidence: Record = {
+ security_and_analysis: repo.security_and_analysis,
+ ...(alertCounts && {
+ alerts: {
+ open: alertCounts.open,
+ fixed: alertCounts.fixed,
+ dismissed: alertCounts.dismissed,
+ total: alertCounts.total,
+ open_by_severity: alertCounts.bySeverity,
+ },
+ }),
+ checked_at: new Date().toISOString(),
+ };
+
if (dependabotStatus === 'enabled') {
const alertSummary = alertCounts
? `\n\nAlert Summary: ${formatAlertSummary(alertCounts)}`
@@ -158,17 +190,7 @@ export const dependabotCheck: IntegrationCheck = {
resourceType: 'repository',
resourceId: repo.full_name,
evidence: {
- security_and_analysis: repo.security_and_analysis,
- ...(alertCounts && {
- alerts: {
- open: alertCounts.open,
- fixed: alertCounts.fixed,
- dismissed: alertCounts.dismissed,
- total: alertCounts.total,
- open_by_severity: alertCounts.bySeverity,
- },
- }),
- checked_at: new Date().toISOString(),
+ [repo.full_name]: repoEvidence,
},
});
} else {
@@ -183,17 +205,9 @@ export const dependabotCheck: IntegrationCheck = {
resourceId: repo.full_name,
severity: 'medium',
remediation: `1. Go to ${repo.html_url}/settings/security_analysis\n2. Enable "Dependabot security updates"\n3. Optionally enable "Dependabot version updates" for proactive updates`,
- evidence: alertCounts
- ? {
- alerts: {
- open: alertCounts.open,
- fixed: alertCounts.fixed,
- dismissed: alertCounts.dismissed,
- total: alertCounts.total,
- open_by_severity: alertCounts.bySeverity,
- },
- }
- : undefined,
+ evidence: {
+ [repo.full_name]: repoEvidence,
+ },
});
}
}
diff --git a/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts b/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts
index c583fa427..ac5c7f2f4 100644
--- a/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts
+++ b/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts
@@ -19,7 +19,7 @@ import type {
GitHubTreeEntry,
GitHubTreeResponse,
} from '../types';
-import { targetReposVariable } from '../variables';
+import { parseRepoBranch, targetReposVariable } from '../variables';
const JS_VALIDATION_PACKAGES = ['zod'];
const PY_VALIDATION_PACKAGES = ['pydantic'];
@@ -82,7 +82,9 @@ export const sanitizedInputsCheck: IntegrationCheck = {
variables: [targetReposVariable],
run: async (ctx) => {
- const targetRepos = (ctx.variables.target_repos as string[] | undefined) ?? [];
+ const targetReposRaw = (ctx.variables.target_repos as string[] | undefined) ?? [];
+ // Extract just the repo names (values may be in "owner/repo:branch" format)
+ const targetRepos = targetReposRaw.map((v) => parseRepoBranch(v).repo);
if (targetRepos.length === 0) {
ctx.fail({
@@ -314,9 +316,13 @@ export const sanitizedInputsCheck: IntegrationCheck = {
resourceType: 'repository',
resourceId: repo.full_name,
evidence: {
- repository: repo.full_name,
- matches: validationMatches,
- checkedAt: new Date().toISOString(),
+ [repo.full_name]: {
+ validation: {
+ status: 'enabled',
+ matches: validationMatches,
+ checked_at: new Date().toISOString(),
+ },
+ },
},
});
} else {
@@ -334,8 +340,14 @@ export const sanitizedInputsCheck: IntegrationCheck = {
remediation:
'Add Zod (JavaScript/TypeScript) or Pydantic (Python) to enforce schema validation on inbound data.',
evidence: {
- repository: repo.full_name,
- checkedFiles: checkedFiles.length > 0 ? checkedFiles : ['No dependency files found'],
+ [repo.full_name]: {
+ validation: {
+ status: 'not_found',
+ checked_files:
+ checkedFiles.length > 0 ? checkedFiles : ['No dependency files found'],
+ checked_at: new Date().toISOString(),
+ },
+ },
},
});
}
@@ -354,11 +366,15 @@ export const sanitizedInputsCheck: IntegrationCheck = {
resourceType: 'repository',
resourceId: repo.full_name,
evidence: {
- repository: repo.full_name,
- codeScanning: codeScanningStatus.method,
- ...(codeScanningStatus.languages && { languages: codeScanningStatus.languages }),
- ...(codeScanningStatus.workflow && { workflow: codeScanningStatus.workflow }),
- checkedAt: new Date().toISOString(),
+ [repo.full_name]: {
+ code_scanning: {
+ status: 'enabled',
+ method: codeScanningStatus.method,
+ ...(codeScanningStatus.languages && { languages: codeScanningStatus.languages }),
+ ...(codeScanningStatus.workflow && { workflow: codeScanningStatus.workflow }),
+ checked_at: new Date().toISOString(),
+ },
+ },
},
});
break;
@@ -374,6 +390,14 @@ export const sanitizedInputsCheck: IntegrationCheck = {
severity: 'medium',
remediation:
'Enable GitHub Advanced Security in the repository settings (Settings → Code security and analysis → GitHub Advanced Security), then enable CodeQL.',
+ evidence: {
+ [repo.full_name]: {
+ code_scanning: {
+ status: 'ghas_required',
+ checked_at: new Date().toISOString(),
+ },
+ },
+ },
});
break;
@@ -387,6 +411,14 @@ export const sanitizedInputsCheck: IntegrationCheck = {
severity: 'medium',
remediation:
'Ensure the GitHub App has "Code scanning alerts: Read" permission. If this is an organization repository, check that organization policies allow access.',
+ evidence: {
+ [repo.full_name]: {
+ code_scanning: {
+ status: 'permission_denied',
+ checked_at: new Date().toISOString(),
+ },
+ },
+ },
});
break;
@@ -401,6 +433,14 @@ export const sanitizedInputsCheck: IntegrationCheck = {
severity: 'medium',
remediation:
'In the repository Security tab, enable CodeQL default setup (or add a custom workflow) to run on every push.',
+ evidence: {
+ [repo.full_name]: {
+ code_scanning: {
+ status: 'not_configured',
+ checked_at: new Date().toISOString(),
+ },
+ },
+ },
});
break;
}
diff --git a/packages/integration-platform/src/manifests/github/variables.ts b/packages/integration-platform/src/manifests/github/variables.ts
index ec63df59f..a634190a6 100644
--- a/packages/integration-platform/src/manifests/github/variables.ts
+++ b/packages/integration-platform/src/manifests/github/variables.ts
@@ -9,14 +9,22 @@ import type { GitHubOrg, GitHubRepo } from './types';
/**
* Variable for selecting which repositories to monitor.
* Dynamically fetches all repos from user's organizations.
+ *
+ * Values are stored as `owner/repo:branch` format.
+ * If branch is omitted, defaults to `main`.
+ *
+ * Examples:
+ * - "acme/api:main"
+ * - "acme/frontend:develop"
+ * - "acme/legacy" (defaults to main)
*/
export const targetReposVariable: CheckVariable = {
id: 'target_repos',
label: 'Repositories to monitor',
type: 'multi-select',
required: true,
- placeholder: 'trycompai/comp',
- helpText: 'Format: {org}/{repo} - e.g., trycompai/comp, microsoft/vscode',
+ placeholder: 'Select repositories...',
+ helpText: 'Select repositories, then specify the branch to check for each.',
fetchOptions: async (ctx) => {
const orgs = await ctx.fetch('/user/orgs');
const allRepos: Array<{ value: string; label: string }> = [];
@@ -36,16 +44,41 @@ export const targetReposVariable: CheckVariable = {
};
/**
- * Variable for specifying which branch to check for protection.
+ * Helper to parse a target_repos value into repo and branches.
+ * Format: "owner/repo:branch1,branch2" or "owner/repo" (defaults to main)
+ * Supports multiple comma-separated branches.
+ * Handles trailing colons and edge cases.
*/
-export const protectedBranchVariable: CheckVariable = {
- id: 'protected_branch',
- label: 'Branch to check',
- type: 'text',
- required: true,
- default: 'main',
- placeholder: 'main',
- helpText: 'Branch name to check for protection - e.g., main, master, develop',
+export const parseRepoBranches = (value: string): { repo: string; branches: string[] } => {
+ // Remove trailing colon if present (handles "owner/repo:" edge case)
+ const cleanValue = value.endsWith(':') ? value.slice(0, -1) : value;
+ const colonIndex = cleanValue.lastIndexOf(':');
+
+ if (colonIndex > 0 && colonIndex < cleanValue.length - 1) {
+ const repo = cleanValue.substring(0, colonIndex);
+ const branchesStr = cleanValue.substring(colonIndex + 1);
+ const branches = branchesStr
+ .split(',')
+ .map((b) => b.trim())
+ .filter(Boolean);
+ return { repo, branches: branches.length > 0 ? branches : ['main'] };
+ }
+ return { repo: cleanValue, branches: ['main'] };
+};
+
+/**
+ * @deprecated Use parseRepoBranches instead for multi-branch support
+ */
+export const parseRepoBranch = (value: string): { repo: string; branch: string } => {
+ const parsed = parseRepoBranches(value);
+ return { repo: parsed.repo, branch: parsed.branches[0] || 'main' };
+};
+
+/**
+ * Helper to format repo and branch into the stored format.
+ */
+export const formatRepoBranch = (repo: string, branch: string): string => {
+ return `${repo}:${branch}`;
};
/**