From d0d1f27a92fe28d6b259c3cae24a9e7f4b8ae966 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:55:56 -0500 Subject: [PATCH 1/8] [dev] [tofikwest] tofik/github-multi-repo-multi-branch-support (#2009) * feat(integrations): add multi-select with branch inputs for GitHub repos * feat(integrations): enhance task integration checks with passing results display --------- Co-authored-by: Tofik Hasanov --- .../components/TaskIntegrationChecks.tsx | 73 ++++--- .../integrations/ManageIntegrationDialog.tsx | 204 +++++++++++++++--- .../github/checks/branch-protection.ts | 170 +++++++++++---- .../src/manifests/github/checks/dependabot.ts | 64 +++--- .../github/checks/sanitized-inputs.ts | 64 ++++-- .../src/manifests/github/variables.ts | 55 ++++- 6 files changed, 479 insertions(+), 151 deletions(-) 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/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' ? ( - +
; } -// 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' : '' + }`} + /> + +
+ ); + })} + {parsedConfigs.some((c) => !c.branch.trim()) && ( +

+ Each repository must have at least one branch specified. +

+ )} +
+ )} +
); } 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}`; }; /** From 72d07072c20c96c3a7392670323e9e19686141ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:36:01 -0500 Subject: [PATCH 2/8] [dev] [tofikwest] tofik/onboarding-vendor-step (#2011) * feat(onboarding): add support for custom vendors with website URLs in onboarding process * feat(onboarding): enhance custom vendor handling in onboarding process --------- Co-authored-by: Tofik Hasanov --- .../vendor/vendor-risk-assessment-task.ts | 17 +- .../review/components/VendorReviewClient.tsx | 123 +++++ .../vendors/[vendorId]/review/page.tsx | 65 ++- .../onboarding/actions/complete-onboarding.ts | 52 ++ .../components/PostPaymentOnboarding.tsx | 78 ++- .../hooks/usePostPaymentOnboarding.ts | 36 +- .../setup/components/OnboardingStepInput.tsx | 505 +++++++++++++++++- .../(app)/setup/hooks/useOnboardingForm.ts | 3 +- apps/app/src/app/(app)/setup/lib/constants.ts | 26 +- apps/app/src/app/(app)/setup/lib/types.ts | 6 + .../onboard-organization-helpers.ts | 134 ++++- 11 files changed, 971 insertions(+), 74 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/components/VendorReviewClient.tsx 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/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..801c77341 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 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); + + // Check if already selected + const alreadyInPredefined = selectedPredefined.some( + (v) => v.toLowerCase() === name.toLowerCase(), + ); + if (alreadyInPredefined) { + 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; + + // Check if already exists in selected predefined or custom + const alreadyInPredefined = selectedPredefined.some( + (v) => v.toLowerCase() === trimmedValue.toLowerCase(), + ); + if (alreadyInPredefined) { + setCustomValue(''); + setShowSuggestions(false); + return; + } + if (customVendors.some((v) => v.name.toLowerCase() === trimmedValue.toLowerCase())) { + setCustomValue(''); + setShowSuggestions(false); + return; + } + + // Check if the typed value matches a predefined option (case-insensitive) + const matchedPredefined = predefinedOptions.find( + (option) => option.toLowerCase() === trimmedValue.toLowerCase(), + ); + + // Check if there's a matching GlobalVendor in search results + const matchedGlobal = searchResults.find( + (v) => getVendorDisplayName(v).toLowerCase() === trimmedValue.toLowerCase(), + ); + + 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(',')); + } + } + }; + + // Filter out already selected vendors from search results + const filteredSearchResults = searchResults.filter((vendor) => { + const name = getVendorDisplayName(vendor).toLowerCase(); + return ( + !selectedPredefined.some((v) => v.toLowerCase() === name) && + !customVendors.some((v) => v.name.toLowerCase() === name) + ); + }); + + // 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} + + + ))} + 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 && ( +
+
+ +
+
+ {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/trigger/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts index 2ba9a0e43..be0906c27 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, }, }); From a7e65430d7b4daa73585d58411909653ec1585c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:01:37 -0500 Subject: [PATCH 3/8] feat(onboarding): deduplicate search results in vendor input component (#2012) Co-authored-by: Tofik Hasanov --- .../(app)/setup/components/OnboardingStepInput.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx index 801c77341..6912b039f 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx @@ -746,8 +746,19 @@ function SoftwareVendorInput({ } }; + // Deduplicate search results by display name (case-insensitive) + const uniqueSearchResults = Array.from( + searchResults.reduce((map, vendor) => { + const name = getVendorDisplayName(vendor).toLowerCase(); + if (!map.has(name)) { + map.set(name, vendor); + } + return map; + }, new Map()), + ).map(([, vendor]) => vendor); + // Filter out already selected vendors from search results - const filteredSearchResults = searchResults.filter((vendor) => { + const filteredSearchResults = uniqueSearchResults.filter((vendor) => { const name = getVendorDisplayName(vendor).toLowerCase(); return ( !selectedPredefined.some((v) => v.toLowerCase() === name) && From b70e8d4d7716966dc2cd3025d40f1c8512ce297e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:01:28 -0500 Subject: [PATCH 4/8] [dev] [tofikwest] tofik/dedup-in-vendors-page-fix (#2013) * feat(onboarding): enhance vendor information handling in onboarding tracker * feat(onboarding): normalize vendor names for deduplication across components * feat(onboarding): improve vendor assessment progress calculation with deduplication --------- Co-authored-by: Tofik Hasanov --- .../[orgId]/components/OnboardingTracker.tsx | 92 ++++++++++++++- .../(overview)/components/VendorsTable.tsx | 109 +++++++++++++++--- .../setup/components/OnboardingStepInput.tsx | 52 ++++++--- 3 files changed, 213 insertions(+), 40 deletions(-) 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]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx index 89127ebe3..dcadbd67f 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,65 @@ export function VendorsTable({ return [...vendorsWithStatus, ...pendingVendors, ...tempVendors]; }, [vendors, itemsInfo, itemStatuses, orgId, isActive, onboardingRunId]); + const dedupedVendors = useMemo(() => { + // Normalize vendor name for deduplication - strips parenthetical suffixes + // e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor + const normalizeVendorName = (name: string): string => { + return name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes + .trim(); + }; + + // Rank vendors for deduplication - higher rank wins + // Rank 3: assessed (completed) + // Rank 2: actively being assessed (via isAssessing flag OR metadata status) + // Rank 1: pending placeholder + // Rank 0: not assessed and not processing + const getRank = (vendor: VendorRow) => { + if (vendor.status === 'assessed') return 3; + // Check both isAssessing flag and metadata status for active assessment + const metadataStatus = itemStatuses[vendor.id]; + if (vendor.isAssessing || metadataStatus === 'assessing' || metadataStatus === 'processing') { + return 2; + } + if (vendor.isPending) return 1; + return 0; + }; + + const map = new Map(); + mergedVendors.forEach((vendor) => { + const nameKey = normalizeVendorName(vendor.name); + const existing = map.get(nameKey); + if (!existing) { + map.set(nameKey, vendor); + return; + } + + const currentRank = getRank(vendor); + const existingRank = getRank(existing); + + if (currentRank > existingRank) { + map.set(nameKey, vendor); + return; + } + + if (currentRank === existingRank) { + const existingUpdatedAt = new Date(existing.updatedAt).getTime(); + const currentUpdatedAt = new Date(vendor.updatedAt).getTime(); + if (currentUpdatedAt > existingUpdatedAt) { + map.set(nameKey, vendor); + } + } + }); + + return Array.from(map.values()); + }, [mergedVendors, itemStatuses]); + const columns = useMemo[]>(() => getColumns(orgId), [orgId]); const { table } = useDataTable({ - data: mergedVendors, + data: dedupedVendors, columns, pageCount, getRowId: (row) => row.id, @@ -285,33 +340,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)/setup/components/OnboardingStepInput.tsx b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx index 6912b039f..41134eac9 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx @@ -525,6 +525,15 @@ 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) @@ -640,12 +649,16 @@ function SoftwareVendorInput({ const handleSelectGlobalVendor = (vendor: GlobalVendors) => { const name = getVendorDisplayName(vendor); + const normalizedName = normalizeVendorName(name); - // Check if already selected + // Check if already selected (using normalized names) const alreadyInPredefined = selectedPredefined.some( - (v) => v.toLowerCase() === name.toLowerCase(), + (v) => normalizeVendorName(v) === normalizedName, ); - if (alreadyInPredefined) { + const alreadyInCustom = customVendors.some( + (v) => normalizeVendorName(v.name) === normalizedName, + ); + if (alreadyInPredefined || alreadyInCustom) { setCustomValue(''); setShowSuggestions(false); setSearchResults([]); @@ -665,29 +678,31 @@ function SoftwareVendorInput({ const trimmedValue = customValue.trim(); if (!trimmedValue) return; - // Check if already exists in selected predefined or custom + const normalizedInput = normalizeVendorName(trimmedValue); + + // Check if already exists in selected predefined or custom (using normalized names) const alreadyInPredefined = selectedPredefined.some( - (v) => v.toLowerCase() === trimmedValue.toLowerCase(), + (v) => normalizeVendorName(v) === normalizedInput, ); if (alreadyInPredefined) { setCustomValue(''); setShowSuggestions(false); return; } - if (customVendors.some((v) => v.name.toLowerCase() === trimmedValue.toLowerCase())) { + if (customVendors.some((v) => normalizeVendorName(v.name) === normalizedInput)) { setCustomValue(''); setShowSuggestions(false); return; } - // Check if the typed value matches a predefined option (case-insensitive) + // Check if the typed value matches a predefined option (using normalized names) const matchedPredefined = predefinedOptions.find( - (option) => option.toLowerCase() === trimmedValue.toLowerCase(), + (option) => normalizeVendorName(option) === normalizedInput, ); - // Check if there's a matching GlobalVendor in search results + // Check if there's a matching GlobalVendor in search results (using normalized names) const matchedGlobal = searchResults.find( - (v) => getVendorDisplayName(v).toLowerCase() === trimmedValue.toLowerCase(), + (v) => normalizeVendorName(getVendorDisplayName(v)) === normalizedInput, ); if (matchedPredefined) { @@ -746,23 +761,24 @@ function SoftwareVendorInput({ } }; - // Deduplicate search results by display name (case-insensitive) + // 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 name = getVendorDisplayName(vendor).toLowerCase(); - if (!map.has(name)) { - map.set(name, 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 + // Filter out already selected vendors from search results (using normalized names) const filteredSearchResults = uniqueSearchResults.filter((vendor) => { - const name = getVendorDisplayName(vendor).toLowerCase(); + const normalizedName = normalizeVendorName(getVendorDisplayName(vendor)); return ( - !selectedPredefined.some((v) => v.toLowerCase() === name) && - !customVendors.some((v) => v.name.toLowerCase() === name) + !selectedPredefined.some((v) => normalizeVendorName(v) === normalizedName) && + !customVendors.some((v) => normalizeVendorName(v.name) === normalizedName) ); }); From 2b0aba4db6372d7ed399aff41063e13b1d6867b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:31:17 -0500 Subject: [PATCH 5/8] refactor(onboarding): prevent re-throwing errors in vendor risk assessment (#2014) Co-authored-by: Tofik Hasanov --- .../trigger/tasks/onboarding/onboard-organization-helpers.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 be0906c27..c3c997654 100644 --- a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts +++ b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts @@ -595,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 } } From 25895331c122e08a6ea73c6b9cfbfdfb99b17f08 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:43:58 -0500 Subject: [PATCH 6/8] refactor(vendors): improve vendor deduplication strategy in VendorsTable (#2015) Co-authored-by: Tofik Hasanov --- .../(overview)/components/VendorsTable.tsx | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) 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 dcadbd67f..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 @@ -245,8 +245,12 @@ export function VendorsTable({ }, [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 - // e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor const normalizeVendorName = (name: string): string => { return name .toLowerCase() @@ -254,50 +258,34 @@ export function VendorsTable({ .trim(); }; - // Rank vendors for deduplication - higher rank wins - // Rank 3: assessed (completed) - // Rank 2: actively being assessed (via isAssessing flag OR metadata status) - // Rank 1: pending placeholder - // Rank 0: not assessed and not processing - const getRank = (vendor: VendorRow) => { - if (vendor.status === 'assessed') return 3; - // Check both isAssessing flag and metadata status for active assessment - const metadataStatus = itemStatuses[vendor.id]; - if (vendor.isAssessing || metadataStatus === 'assessing' || metadataStatus === 'processing') { - return 2; - } - if (vendor.isPending) return 1; - return 0; - }; + // Separate real DB vendors from placeholders + const realVendors = mergedVendors.filter((v) => !v.isPending); + const placeholders = mergedVendors.filter((v) => v.isPending); - const map = new Map(); - mergedVendors.forEach((vendor) => { - const nameKey = normalizeVendorName(vendor.name); - const existing = map.get(nameKey); - if (!existing) { - map.set(nameKey, vendor); - return; - } + // Build a set of normalized names from real vendors + const realVendorNames = new Set(realVendors.map((v) => normalizeVendorName(v.name))); - const currentRank = getRank(vendor); - const existingRank = getRank(existing); + // 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); - if (currentRank > existingRank) { - map.set(nameKey, vendor); + // Skip if a real vendor with this name already exists + if (realVendorNames.has(nameKey)) { return; } - if (currentRank === existingRank) { - const existingUpdatedAt = new Date(existing.updatedAt).getTime(); - const currentUpdatedAt = new Date(vendor.updatedAt).getTime(); - if (currentUpdatedAt > existingUpdatedAt) { - map.set(nameKey, vendor); - } + // 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 Array.from(map.values()); - }, [mergedVendors, itemStatuses]); + // Return all real vendors + deduplicated placeholders + return [...realVendors, ...Array.from(placeholderMap.values())]; + }, [mergedVendors]); const columns = useMemo[]>(() => getColumns(orgId), [orgId]); From 147ec732e7ab1a1f891a1f0f2bb10c1e50b33fb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:33:36 -0500 Subject: [PATCH 7/8] [dev] [tofikwest] tofik/logger-credential-filtering (#2016) * refactor(logger): enhance logging to skip sensitive data and improve formatting * refactor(logger): pass message as separate argument to avoid format string injection --------- Co-authored-by: Tofik Hasanov --- apps/app/src/actions/safe-action.ts | 14 +-- apps/app/src/utils/logger.ts | 155 +++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 9 deletions(-) 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/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 }; From 04311995fd58a5132eb6503b0102ab665d555e51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:43:53 -0500 Subject: [PATCH 8/8] [dev] [tofikwest] tofik/nda-bypass (#2017) * feat(trust): add allowed domains management for NDA bypass * feat(trust): update allowed domains management to track last saved state * refactor(trust): simplify success message handling in approval dialog --------- Co-authored-by: Tofik Hasanov --- .../src/trust-portal/trust-access.service.ts | 142 +++++++++++ .../trust/components/approve-dialog.tsx | 4 +- .../actions/update-allowed-domains.ts | 60 +++++ .../components/AllowedDomainsManager.tsx | 220 ++++++++++++++++++ .../components/TrustPortalSwitch.tsx | 12 + .../[orgId]/trust/portal-settings/page.tsx | 2 + apps/app/src/hooks/use-access-requests.ts | 9 +- .../migration.sql | 2 + packages/db/prisma/schema/trust.prisma | 3 + 9 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/update-allowed-domains.ts create mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/AllowedDomainsManager.tsx create mode 100644 packages/db/prisma/migrations/20260119192847_add_trust_allowed_domains/migration.sql 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/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} + + + ))} +
+ )} +
+ + !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/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/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)