From eab60fedd0239e038066a3dcbb16713b26e7b275 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:27:40 -0400 Subject: [PATCH 1/4] fix: fix missing DNS records coming from Vercel [dev] [Marfuen] mariano/fix-dns-vercel-missing-record --- .../trust-portal/domain-verification.spec.ts | 204 ++++++++++++++++++ .../src/trust-portal/domain-verification.ts | 131 +++++++++++ .../src/trust-portal/dto/domain-status.dto.ts | 10 +- .../src/trust-portal/trust-portal.service.ts | 167 ++++++++------ .../components/TrustPortalDomain.test.tsx | 4 +- .../components/TrustPortalDomain.tsx | 72 +++++-- .../components/TrustSettingsClient.test.tsx | 4 +- apps/app/src/hooks/use-dns-status.ts | 4 + apps/app/src/hooks/use-domain.ts | 4 +- .../src/hooks/use-trust-portal-settings.ts | 2 + 10 files changed, 516 insertions(+), 86 deletions(-) create mode 100644 apps/api/src/trust-portal/domain-verification.spec.ts create mode 100644 apps/api/src/trust-portal/domain-verification.ts diff --git a/apps/api/src/trust-portal/domain-verification.spec.ts b/apps/api/src/trust-portal/domain-verification.spec.ts new file mode 100644 index 0000000000..4fd8e10546 --- /dev/null +++ b/apps/api/src/trust-portal/domain-verification.spec.ts @@ -0,0 +1,204 @@ +import { decideDomainVerification, deriveDnsVerified } from './domain-verification'; + +describe('decideDomainVerification', () => { + const baseInputs = { + isCnameVerified: true, + isTxtVerified: true, + isVercelTxtVerified: true, + requiresVercelTxt: false, + vercelAvailable: true, + vercelMisconfigured: false as boolean | null, + vercelVerifiedAfterTrigger: true as boolean | null, + }; + + it('returns success=true when all DNS passes and Vercel reports not misconfigured', () => { + expect(decideDomainVerification(baseInputs).success).toBe(true); + }); + + it('returns success=false when Vercel reports misconfigured, even if DNS regex passes', () => { + const result = decideDomainVerification({ + ...baseInputs, + vercelMisconfigured: true, + }); + expect(result.success).toBe(false); + expect(result.error?.toLowerCase()).toContain('misconfigured'); + }); + + it('returns success=false when Vercel config fetch could not confirm status (null)', () => { + const result = decideDomainVerification({ + ...baseInputs, + vercelMisconfigured: null, + }); + expect(result.success).toBe(false); + expect(result.error?.toLowerCase()).toMatch(/vercel|try again/); + }); + + it('returns success=false when the CNAME record is missing or wrong', () => { + const result = decideDomainVerification({ + ...baseInputs, + isCnameVerified: false, + }); + expect(result.success).toBe(false); + }); + + it('returns success=false when the domain TXT record is missing or wrong', () => { + const result = decideDomainVerification({ + ...baseInputs, + isTxtVerified: false, + }); + expect(result.success).toBe(false); + }); + + it('does not require _vercel TXT verification when cross-account verification is not required', () => { + expect( + decideDomainVerification({ + ...baseInputs, + isVercelTxtVerified: false, + requiresVercelTxt: false, + }).success, + ).toBe(true); + }); + + it('requires _vercel TXT verification when cross-account verification is required', () => { + expect( + decideDomainVerification({ + ...baseInputs, + requiresVercelTxt: true, + isVercelTxtVerified: false, + }).success, + ).toBe(false); + }); + + it('requires Vercel verify response when cross-account verification is required', () => { + expect( + decideDomainVerification({ + ...baseInputs, + requiresVercelTxt: true, + isVercelTxtVerified: true, + vercelVerifiedAfterTrigger: false, + }).success, + ).toBe(false); + }); + + it('prioritizes DNS error over Vercel misconfiguration error when both fail', () => { + const result = decideDomainVerification({ + ...baseInputs, + isCnameVerified: false, + vercelMisconfigured: true, + }); + expect(result.success).toBe(false); + expect(result.error?.toLowerCase()).toMatch(/dns|record/); + }); + + it('when Vercel is not configured on the server, trusts DNS alone (dev/self-host scenario)', () => { + const result = decideDomainVerification({ + ...baseInputs, + vercelAvailable: false, + vercelMisconfigured: null, + vercelVerifiedAfterTrigger: null, + }); + expect(result.success).toBe(true); + }); + + it('when Vercel is not configured, still requires DNS records to match', () => { + const result = decideDomainVerification({ + ...baseInputs, + vercelAvailable: false, + vercelMisconfigured: null, + vercelVerifiedAfterTrigger: null, + isCnameVerified: false, + }); + expect(result.success).toBe(false); + }); + + describe('transient flag (to avoid de-verifying working domains)', () => { + it('marks failure as transient when Vercel is reachable but config fetch failed', () => { + const result = decideDomainVerification({ + ...baseInputs, + vercelAvailable: true, + vercelMisconfigured: null, + }); + expect(result.success).toBe(false); + expect(result.transient).toBe(true); + }); + + it('marks failure as NOT transient when Vercel explicitly reports misconfigured', () => { + const result = decideDomainVerification({ + ...baseInputs, + vercelMisconfigured: true, + }); + expect(result.success).toBe(false); + expect(result.transient).toBe(false); + }); + + it('marks failure as NOT transient when DNS records are clearly wrong', () => { + const result = decideDomainVerification({ + ...baseInputs, + isCnameVerified: false, + }); + expect(result.success).toBe(false); + expect(result.transient).toBe(false); + }); + + it('marks failure as transient when cross-account verify returns null (not explicitly false)', () => { + const result = decideDomainVerification({ + ...baseInputs, + requiresVercelTxt: true, + vercelVerifiedAfterTrigger: null, + }); + expect(result.success).toBe(false); + expect(result.transient).toBe(true); + }); + + it('does not mark success as transient', () => { + const result = decideDomainVerification(baseInputs); + expect(result.success).toBe(true); + expect(result.transient).toBeFalsy(); + }); + }); +}); + +describe('deriveDnsVerified', () => { + it('returns true when Vercel reports the domain is not misconfigured (CNAME)', () => { + expect( + deriveDnsVerified({ + dnsRegexMatches: true, + vercelMisconfigured: false, + }), + ).toBe(true); + }); + + it('returns true when Vercel reports the domain is not misconfigured (A record / apex)', () => { + expect( + deriveDnsVerified({ + dnsRegexMatches: false, + vercelMisconfigured: false, + }), + ).toBe(true); + }); + + it('returns false when Vercel reports misconfigured, even if our DNS regex matches', () => { + expect( + deriveDnsVerified({ + dnsRegexMatches: true, + vercelMisconfigured: true, + }), + ).toBe(false); + }); + + it('falls back to DNS regex when Vercel data is not available', () => { + expect( + deriveDnsVerified({ + dnsRegexMatches: true, + vercelMisconfigured: null, + }), + ).toBe(true); + + expect( + deriveDnsVerified({ + dnsRegexMatches: false, + vercelMisconfigured: null, + }), + ).toBe(false); + }); +}); diff --git a/apps/api/src/trust-portal/domain-verification.ts b/apps/api/src/trust-portal/domain-verification.ts new file mode 100644 index 0000000000..c2d7bcc449 --- /dev/null +++ b/apps/api/src/trust-portal/domain-verification.ts @@ -0,0 +1,131 @@ +export interface DomainVerificationInputs { + /** Did the DNS CNAME record resolve and match a known Vercel target? */ + isCnameVerified: boolean; + /** Did the DNS TXT record at the root match `compai-domain-verification=`? */ + isTxtVerified: boolean; + /** Did the DNS TXT at `_vercel` match the token Vercel gave us? */ + isVercelTxtVerified: boolean; + /** + * Whether Vercel requires the `_vercel` TXT record for ownership verification. + * True for cross-account domains where Vercel cannot infer ownership from DNS alone. + */ + requiresVercelTxt: boolean; + /** + * Whether the Vercel integration is configured on the server. False in dev or + * self-hosted setups without VERCEL_TEAM_ID / TRUST_PORTAL_PROJECT_ID — in + * that case we fall back to DNS-only verification. + */ + vercelAvailable: boolean; + /** + * `misconfigured` flag from Vercel's `/v6/domains/{d}/config` endpoint. + * `null` means the call failed — in that case we cannot confirm Vercel's verdict. + */ + vercelMisconfigured: boolean | null; + /** + * Response from triggering Vercel's `/verify` endpoint. Only meaningful for + * cross-account domains. `null` means the call was skipped or errored. + */ + vercelVerifiedAfterTrigger: boolean | null; +} + +export interface DomainVerificationResult { + success: boolean; + error?: string; + /** + * True when the failure is due to a transient/indeterminate condition + * (Vercel API unreachable, no verdict available yet). Callers should avoid + * writing `domainVerified=false` on transient failures — doing so can + * de-verify a previously working domain on a temporary outage. + */ + transient?: boolean; +} + +export interface DnsVerifiedInputs { + /** Whether the resolved CNAME target matched a known Vercel DNS pattern. */ + dnsRegexMatches: boolean; + /** + * `misconfigured` from Vercel's `/v6/domains/{d}/config`. `null` when the + * call failed or Vercel isn't configured on the server. + */ + vercelMisconfigured: boolean | null; +} + +/** + * Vercel hosts these domains, so Vercel is the source of truth for whether the + * DNS points at the right project. We accept any configuration method Vercel + * accepts (CNAME for subdomains, A for apex, ALIAS, etc.) — `misconfigured` + * is Vercel's single "works / doesn't work" verdict. Our regex is only a + * fallback for when Vercel's API is unreachable. + */ +export function deriveDnsVerified(inputs: DnsVerifiedInputs): boolean { + if (inputs.vercelMisconfigured !== null) { + return !inputs.vercelMisconfigured; + } + return inputs.dnsRegexMatches; +} + +export function decideDomainVerification( + inputs: DomainVerificationInputs, +): DomainVerificationResult { + const { + isCnameVerified, + isTxtVerified, + isVercelTxtVerified, + requiresVercelTxt, + vercelAvailable, + vercelMisconfigured, + vercelVerifiedAfterTrigger, + } = inputs; + + // DNS-level checks — user is responsible for these + const dnsOk = + isCnameVerified && + isTxtVerified && + (!requiresVercelTxt || isVercelTxtVerified); + + if (!dnsOk) { + return { + success: false, + transient: false, + error: + 'Some DNS records are not configured correctly. Please check the records marked as unverified above and try again.', + }; + } + + // Vercel not configured on this server — trust DNS alone (dev/self-host). + if (!vercelAvailable) { + return { success: true }; + } + + // Vercel-level checks — DNS may look right to us but Vercel must agree + if (vercelMisconfigured === null) { + return { + success: false, + transient: true, + error: + 'Could not confirm configuration with Vercel. Please try again in a few minutes.', + }; + } + + if (vercelMisconfigured === true) { + return { + success: false, + transient: false, + error: + 'Vercel reports this domain is still misconfigured. The CNAME value must exactly match the recommended target shown above.', + }; + } + + if (requiresVercelTxt && vercelVerifiedAfterTrigger !== true) { + // `null` means the /verify call failed (transient); `false` means Vercel + // explicitly said the ownership record is not yet present. + return { + success: false, + transient: vercelVerifiedAfterTrigger === null, + error: + 'DNS records verified but Vercel has not yet confirmed domain ownership. Please ensure the _vercel TXT record is correctly configured and try again.', + }; + } + + return { success: true }; +} diff --git a/apps/api/src/trust-portal/dto/domain-status.dto.ts b/apps/api/src/trust-portal/dto/domain-status.dto.ts index eb27528c68..5691023b1f 100644 --- a/apps/api/src/trust-portal/dto/domain-status.dto.ts +++ b/apps/api/src/trust-portal/dto/domain-status.dto.ts @@ -47,8 +47,16 @@ export class DomainStatusResponseDto { @ApiProperty({ description: 'The recommended CNAME target for this domain from Vercel', - example: 'cname.vercel-dns.com', + example: '3a69a5bb27875189.vercel-dns-016.com', required: false, }) cnameTarget?: string; + + @ApiProperty({ + description: + "Whether Vercel's /v6/domains/{d}/config call reports the domain as misconfigured. Null when Vercel could not be reached.", + required: false, + nullable: true, + }) + misconfigured?: boolean | null; } diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index fc63fc07d8..22952031a6 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -24,6 +24,10 @@ import { UploadComplianceResourceDto, } from './dto/compliance-resource.dto'; import * as dns from 'node:dns'; +import { + decideDomainVerification, + deriveDnsVerified, +} from './domain-verification'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client, getSignedUrl } from '../app/s3'; import { DeleteTrustDocumentDto, @@ -248,11 +252,18 @@ export class TrustPortalService { recommendedCNAMEs?.find((c) => c.rank === 1)?.value || recommendedCNAMEs?.[0]?.value; + // Null when config fetch failed (silent catch returns null above) — the UI + // uses this to distinguish "Vercel says misconfigured" from "we don't know". + const misconfigured: boolean | null = configInfo + ? configInfo.misconfigured === true + : null; + return { domain: domainInfo.name, verified: domainInfo.verified ?? false, verification, cnameTarget, + misconfigured, }; } catch (error) { this.logger.error( @@ -1012,19 +1023,43 @@ export class TrustPortalService { // potentially stale DB values (tokens change if domain was re-added). let liveIsVercelDomain = false; let liveVercelVerification: string | null = null; + let vercelMisconfigured: boolean | null = null; + let recommendedCNAME: string | null = null; + const hasVercelConfig = + !!process.env.TRUST_PORTAL_PROJECT_ID && !!process.env.VERCEL_TEAM_ID; - if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { - try { - const vercelStatusResp = await this.vercelFetch({ + if (hasVercelConfig) { + const teamId = process.env.VERCEL_TEAM_ID!; + const projectId = process.env.TRUST_PORTAL_PROJECT_ID!; + + const [statusResult, configResult] = await Promise.all([ + this.vercelFetch({ method: 'GET', - path: `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`, - params: { teamId: process.env.VERCEL_TEAM_ID }, - }); - const vercelData = vercelStatusResp.data; + path: `/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(domain)}`, + params: { teamId }, + }).catch((error: unknown) => { + this.logger.warn( + `Failed to fetch live Vercel status for ${domain}: ${error instanceof Error ? error.message : error}`, + ); + return null; + }), + this.vercelFetch({ + method: 'GET', + path: `/v6/domains/${TrustPortalService.safeDomainPath(domain)}/config`, + params: { teamId }, + }).catch((error: unknown) => { + this.logger.warn( + `Failed to fetch Vercel domain config for ${domain}: ${error instanceof Error ? error.message : error}`, + ); + return null; + }), + ]); + + if (statusResult) { + const vercelData = statusResult.data; liveIsVercelDomain = vercelData.verified === false; liveVercelVerification = vercelData.verification?.[0]?.value || null; - // Sync DB with live Vercel state await db.trust.update({ where: { organizationId }, data: { @@ -1032,16 +1067,22 @@ export class TrustPortalService { vercelVerification: liveVercelVerification, }, }); - } catch (error) { - this.logger.warn( - `Failed to fetch live Vercel status for ${domain}, falling back to DB: ${error}`, - ); - const trustRecord = await db.trust.findUnique({ + } else { + const fallbackRecord = await db.trust.findUnique({ where: { organizationId, domain }, select: { isVercelDomain: true, vercelVerification: true }, }); - liveIsVercelDomain = trustRecord?.isVercelDomain === true; - liveVercelVerification = trustRecord?.vercelVerification ?? null; + liveIsVercelDomain = fallbackRecord?.isVercelDomain === true; + liveVercelVerification = fallbackRecord?.vercelVerification ?? null; + } + + if (configResult) { + vercelMisconfigured = configResult.data.misconfigured === true; + recommendedCNAME = + configResult.data.recommendedCNAME?.find((c) => c.rank === 1) + ?.value || + configResult.data.recommendedCNAME?.[0]?.value || + null; } } @@ -1053,94 +1094,88 @@ export class TrustPortalService { expected != null && records.some((segments) => segments.some((s) => s === expected)); - // Check CNAME — Node DNS resolve returns string[] of CNAME targets - let isCnameVerified = cnameRecords.some((address) => + // Regex check is only a fallback for when Vercel's config call fails — + // Vercel is the source of truth for whether the CNAME is correct. + let dnsRegexMatches = cnameRecords.some((address) => TrustPortalService.VERCEL_DNS_CNAME_PATTERN.test(address), ); - if (!isCnameVerified) { + if (!dnsRegexMatches) { const fallback = cnameRecords.find((address) => TrustPortalService.VERCEL_DNS_FALLBACK_PATTERN.test(address), ); if (fallback) { this.logger.warn(`CNAME matched fallback pattern: ${fallback}`); - isCnameVerified = true; + dnsRegexMatches = true; } } - // Check TXT - const isTxtVerified = txtRecordMatches(txtRecords, expectedTxtValue); + const isCnameVerified = deriveDnsVerified({ + dnsRegexMatches, + vercelMisconfigured, + }); - // Check Vercel TXT + const isTxtVerified = txtRecordMatches(txtRecords, expectedTxtValue); const isVercelTxtVerified = txtRecordMatches( vercelTxtRecords, expectedVercelTxtValue, ); - const requiresVercelTxt = liveIsVercelDomain; - const isVerified = - isCnameVerified && - isTxtVerified && - (!requiresVercelTxt || isVercelTxtVerified); - - if (!isVerified) { - return { - success: false, - isCnameVerified, - isTxtVerified, - isVercelTxtVerified, - error: - 'Some DNS records are not configured correctly. Please check the records marked as unverified above and try again.', - }; - } - // Trigger Vercel to re-verify the domain so it provisions SSL and starts serving. - let vercelVerified = false; - if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { + // Trigger Vercel re-verification up front so we can factor the result into + // the final verdict rather than optimistically trusting our own DNS regex. + let vercelVerifiedAfterTrigger: boolean | null = null; + if (hasVercelConfig) { try { const verifyResp = await this.vercelFetch<{ verified: boolean }>({ method: 'POST', path: `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}/verify`, - params: { teamId: process.env.VERCEL_TEAM_ID }, + params: { teamId: process.env.VERCEL_TEAM_ID! }, body: {}, }); - vercelVerified = verifyResp.data?.verified === true; + vercelVerifiedAfterTrigger = verifyResp.data?.verified === true; } catch (error) { this.logger.warn( - `Failed to trigger Vercel domain verification for ${domain}: ${error}`, + `Failed to trigger Vercel domain verification for ${domain}: ${error instanceof Error ? error.message : error}`, ); } } - // For cross-account domains (liveIsVercelDomain=true), Vercel must confirm - // the _vercel TXT record before the domain will serve traffic. - // For same-account domains, DNS verification is sufficient — Vercel will - // pick up the CNAME on its own, so don't block on the verify response. - const domainFullyVerified = requiresVercelTxt ? vercelVerified : true; - - await db.trust.update({ - where: { organizationId }, - data: { - domainVerified: domainFullyVerified, - ...(domainFullyVerified ? { status: 'published' as const } : {}), - }, + const verdict = decideDomainVerification({ + isCnameVerified, + isTxtVerified, + isVercelTxtVerified, + requiresVercelTxt, + vercelAvailable: hasVercelConfig, + vercelMisconfigured, + vercelVerifiedAfterTrigger, }); - if (!domainFullyVerified) { - return { - success: false, - isCnameVerified, - isTxtVerified, - isVercelTxtVerified, - error: - 'DNS records verified but Vercel has not yet confirmed domain ownership. Please ensure the _vercel TXT record is correctly configured and try again.', - }; + // Only write `domainVerified` when we have a confident answer. A transient + // Vercel outage that returns `success=false` should NOT de-verify a + // previously working custom domain. + if (verdict.success) { + await db.trust.update({ + where: { organizationId }, + data: { + domainVerified: true, + status: 'published' as const, + }, + }); + } else if (!verdict.transient) { + await db.trust.update({ + where: { organizationId }, + data: { domainVerified: false }, + }); } return { - success: true, + success: verdict.success, isCnameVerified, isTxtVerified, isVercelTxtVerified, + vercelMisconfigured, + recommendedCNAME, + ...(verdict.error ? { error: verdict.error } : {}), }; } diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.test.tsx index 3d069cc201..684b584b90 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.test.tsx @@ -22,14 +22,14 @@ vi.mock('@/hooks/use-trust-portal-settings', () => ({ })); vi.mock('@/hooks/use-domain', () => ({ - DEFAULT_CNAME_TARGET: 'cname.vercel-dns.com', useDomain: () => ({ data: { data: { domain: 'trust.example.com', verified: false, verification: [], - cnameTarget: 'cname.vercel-dns.com', + cnameTarget: '3a69a5bb27875189.vercel-dns-016.com', + misconfigured: false, }, }, }), diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx index 628561b2de..3292f48775 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx @@ -1,7 +1,7 @@ 'use client'; import { useDnsStatus } from '@/hooks/use-dns-status'; -import { DEFAULT_CNAME_TARGET, useDomain } from '@/hooks/use-domain'; +import { useDomain } from '@/hooks/use-domain'; import { Button } from '@trycompai/ui/button'; import { Card, @@ -72,23 +72,34 @@ export function TrustPortalDomain({ // Prefer live Vercel verification value over stale DB value const effectiveVercelTxtValue = verificationInfo?.value ?? vercelVerification; - // Get the actual CNAME target from Vercel, with fallback - // Normalize to include trailing dot for DNS record display - const cnameTarget = useMemo(() => { - const target = domainStatus?.data?.cnameTarget || DEFAULT_CNAME_TARGET; - return target.endsWith('.') ? target : `${target}.`; - }, [domainStatus?.data?.cnameTarget]); - const { isCnameVerified, isTxtVerified, isVercelTxtVerified, + vercelMisconfigured, + recommendedCNAME: checkRecommendedCNAME, mutate: recheckDns, } = useDnsStatus({ domain: initialDomain, enabled: !!initialDomain && !isEffectivelyVerified, }); + // CNAME target must come from Vercel — never guess a default. The status + // endpoint's `cnameTarget` is the primary source; the check-dns response can + // refresh it after the user clicks "Check DNS record". + // Normalize to include trailing dot for DNS record display. + const cnameTarget = useMemo(() => { + const target = checkRecommendedCNAME ?? domainStatus?.data?.cnameTarget; + if (!target) return null; + return target.endsWith('.') ? target : `${target}.`; + }, [checkRecommendedCNAME, domainStatus?.data?.cnameTarget]); + + const domainStatusLoading = !!initialDomain && !domainStatus; + const vercelReportsMisconfigured = + vercelMisconfigured === true || + (domainStatus?.data?.misconfigured === true && + vercelMisconfigured !== false); + const { hasPermission } = usePermissions(); const canUpdate = hasPermission('trust', 'update'); const { submitCustomDomain, checkDns } = useTrustPortalSettings(); @@ -235,6 +246,23 @@ export function TrustPortalDomain({ )} + {vercelReportsMisconfigured && ( + + + Vercel reports this domain is still misconfigured. The CNAME value must + match exactly: {cnameTarget ?? 'unknown'}. Update it at + your DNS provider and try again. + + + )} + {!cnameTarget && !domainStatusLoading && ( + + + Could not fetch the recommended CNAME target from Vercel. Please refresh + in a moment — do not guess the value. + + + )}
@@ -272,12 +300,23 @@ export function TrustPortalDomain({
- {cnameTarget} + + {cnameTarget ?? ( + + {domainStatusLoading + ? 'Loading from Vercel…' + : 'Unavailable — refresh to retry'} + + )} +
- {finding.task?.title ?? - (finding.evidenceFormType - ? `Document: ${finding.evidenceFormType.replace(/-/g, ' ')}` - : finding.evidenceSubmission - ? `Document: ${finding.evidenceSubmission.formType.replace(/-/g, ' ')}` - : 'Finding')} + {findingListTitle(finding)}

{finding.content} @@ -53,17 +98,7 @@ function FindingsList({

diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx new file mode 100644 index 0000000000..ed87e59bef --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx @@ -0,0 +1,48 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import { useState } from 'react'; + +import { FindingScope } from '@db'; + +import { FindingHistoryPanel } from '../../../tasks/[taskId]/components/findings/FindingHistoryPanel'; +import { PeopleFindingsList } from './PeopleFindingsList'; + +type PeopleFindingsScope = ComponentProps['scope']; + +export interface PeopleFindingsProps { + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; + /** Defaults to the main People directory scope */ + scope?: PeopleFindingsScope; +} + +export function PeopleFindings({ + isAuditor, + isPlatformAdmin, + isAdminOrOwner, + scope = FindingScope.people, +}: PeopleFindingsProps) { + const [selectedFindingIdForHistory, setSelectedFindingIdForHistory] = useState( + null, + ); + + return ( + <> + + {selectedFindingIdForHistory && ( + setSelectedFindingIdForHistory(null)} + /> + )} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx new file mode 100644 index 0000000000..792149a489 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { UserPermissions } from '@/lib/permissions'; + +let mockPermissionsState: UserPermissions = {}; + +const mockHasPermission = vi.fn((resource: string, action: string): boolean => { + return mockPermissionsState[resource]?.includes(action) ?? false; +}); + +function setMockPermissions(permissions: UserPermissions): void { + mockPermissionsState = permissions; + mockHasPermission.mockImplementation((resource: string, action: string) => { + return mockPermissionsState[resource]?.includes(action) ?? false; + }); +} + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ + permissions: {}, + hasPermission: mockHasPermission, + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const mockFindings = [ + { + id: 'finding_1', + status: 'open', + type: 'soc2', + content: 'People finding', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }, +]; + +const mockMutate = vi.fn(); + +vi.mock('@/hooks/use-findings-api', () => ({ + useScopeFindings: () => ({ + data: { data: mockFindings }, + isLoading: false, + error: null, + mutate: mockMutate, + }), + useFindingActions: () => ({ + updateFinding: vi.fn(), + deleteFinding: vi.fn(), + }), +})); + +vi.mock('@db', () => ({ + FindingStatus: { + open: 'open', + needs_revision: 'needs_revision', + ready_for_review: 'ready_for_review', + closed: 'closed', + }, +})); + + +vi.mock('../../../tasks/[taskId]/components/findings/CreateFindingButton', () => ({ + CreateFindingButton: () => , +})); + +vi.mock('../../../tasks/[taskId]/components/findings/FindingItem', () => ({ + FindingItem: ({ finding }: { finding: { id: string; content: string } }) => ( +
{finding.content}
+ ), +})); + +import { PeopleFindingsList } from './PeopleFindingsList'; + +const defaultProps = { + scope: 'people' as const, + isAuditor: false, + isPlatformAdmin: false, + isAdminOrOwner: false, + onViewHistory: vi.fn(), +}; + +describe('PeopleFindingsList permission gating', () => { + beforeEach(() => { + setMockPermissions({}); + vi.clearAllMocks(); + }); + + it('shows Create Finding for platform admin with finding:create', () => { + setMockPermissions({ finding: ['create', 'read'] }); + + render(); + + expect(screen.getByTestId('create-finding-btn')).toBeInTheDocument(); + }); + + it('shows Create Finding for auditor with finding:create', () => { + setMockPermissions({ finding: ['create', 'read'] }); + + render(); + + expect(screen.getByTestId('create-finding-btn')).toBeInTheDocument(); + }); + + it('hides Create Finding when user lacks finding:create even if auditor', () => { + setMockPermissions({ finding: ['read', 'update'] }); + + render(); + + expect(screen.queryByTestId('create-finding-btn')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx new file mode 100644 index 0000000000..bd398a815f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { + useFindingActions, + useScopeFindings, + type Finding, +} from '@/hooks/use-findings-api'; +import { usePermissions } from '@/hooks/use-permissions'; +import { Button } from '@trycompai/design-system'; +import type { FindingScope } from '@db'; +import { FindingStatus } from '@db'; +import { + ChevronDown, + ChevronUp, + WarningAlt, + WarningAltFilled, +} from '@trycompai/design-system/icons'; +import { useCallback, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { CreateFindingButton } from '../../../tasks/[taskId]/components/findings/CreateFindingButton'; +import { FindingItem } from '../../../tasks/[taskId]/components/findings/FindingItem'; + +const INITIAL_DISPLAY_COUNT = 5; + +interface PeopleFindingsListProps { + scope: FindingScope; + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; + onViewHistory?: (findingId: string) => void; +} + +const STATUS_ORDER: Record = { + [FindingStatus.open]: 0, + [FindingStatus.needs_revision]: 1, + [FindingStatus.ready_for_review]: 2, + [FindingStatus.closed]: 3, +}; + +export function PeopleFindingsList({ + scope, + isAuditor, + isPlatformAdmin, + isAdminOrOwner, + onViewHistory, +}: PeopleFindingsListProps) { + const { data, isLoading, error, mutate } = useScopeFindings(scope); + const { updateFinding, deleteFinding } = useFindingActions(); + const { hasPermission } = usePermissions(); + const [expandedId, setExpandedId] = useState(null); + const [showAll, setShowAll] = useState(false); + + const rawFindings = data?.data || []; + + const sortedFindings = useMemo(() => { + return [...rawFindings].sort((a: Finding, b: Finding) => { + const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; + if (statusDiff !== 0) return statusDiff; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + }, [rawFindings]); + + const visibleFindings = showAll ? sortedFindings : sortedFindings.slice(0, INITIAL_DISPLAY_COUNT); + const hiddenCount = sortedFindings.length - visibleFindings.length; + + const canCreateFinding = hasPermission('finding', 'create') && (isAuditor || isPlatformAdmin); + const canUpdateFinding = hasPermission('finding', 'update'); + const canDeleteFinding = hasPermission('finding', 'delete'); + const canChangeStatus = canUpdateFinding || isAuditor || isPlatformAdmin || isAdminOrOwner; + const canSetRestrictedStatus = isAuditor || isPlatformAdmin; + + const handleStatusChange = useCallback( + async (findingId: string, status: FindingStatus, revisionNote?: string) => { + try { + const updateData: { status: FindingStatus; revisionNote?: string | null } = { status }; + + if (status === FindingStatus.needs_revision && revisionNote) { + updateData.revisionNote = revisionNote; + } + + await updateFinding(findingId, updateData); + toast.success( + revisionNote ? 'Finding marked for revision with note' : 'Finding status updated', + ); + mutate(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update status'); + } + }, + [updateFinding, mutate], + ); + + const handleDelete = useCallback( + async (findingId: string) => { + if (!confirm('Are you sure you want to delete this finding?')) { + return; + } + + try { + await deleteFinding(findingId); + toast.success('Finding deleted'); + mutate(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete finding'); + } + }, + [deleteFinding, mutate], + ); + + const openFindingsCount = sortedFindings.filter( + (f: Finding) => f.status === FindingStatus.open || f.status === FindingStatus.needs_revision, + ).length; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ + Failed to load findings +
+ ); + } + + return ( +
+
+
+
+
+ +
+
+

Findings

+

+ Audit findings and issues requiring attention +

+
+
+ +
+ {openFindingsCount > 0 && ( +
+
+ + {openFindingsCount}{' '} + {openFindingsCount === 1 ? 'requires' : 'require'} action + +
+ )} + + {canCreateFinding && ( + mutate()} /> + )} +
+
+
+ +
+ {sortedFindings.length === 0 ? ( +
+ +

No findings for this area

+ {canCreateFinding && ( +

Create a finding to flag an issue

+ )} +
+ ) : ( +
+ {visibleFindings.map((finding: Finding) => ( + setExpandedId(expandedId === finding.id ? null : finding.id)} + onStatusChange={(status, revisionNote) => + handleStatusChange(finding.id, status, revisionNote) + } + onDelete={() => handleDelete(finding.id)} + onViewHistory={onViewHistory ? () => onViewHistory(finding.id) : undefined} + /> + ))} + + {sortedFindings.length > INITIAL_DISPLAY_COUNT && ( +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index 67c36a96ad..53f18fdb5b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -30,13 +30,22 @@ export interface TeamMembersProps { canManageMembers: boolean; canInviteUsers: boolean; isAuditor: boolean; + isPlatformAdmin: boolean; isCurrentUserOwner: boolean; organizationId: string; deviceStatusMap: Record; } export async function TeamMembers(props: TeamMembersProps) { - const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId, deviceStatusMap } = props; + const { + canManageMembers, + canInviteUsers, + isAuditor, + isPlatformAdmin, + isCurrentUserOwner, + organizationId, + deviceStatusMap, + } = props; if (!organizationId) { return null; @@ -147,6 +156,7 @@ export async function TeamMembers(props: TeamMembersProps) { employeeSyncData={employeeSyncData} taskCompletionMap={taskCompletionMap} deviceStatusMap={deviceStatusMap} + isPlatformAdmin={isPlatformAdmin} /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index ac9b88e7b4..2916f1a9e8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -43,6 +43,7 @@ import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMemb import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; +import { PeopleFindings } from './PeopleFindings'; interface TeamMembersClientProps { data: TeamMembersData; @@ -50,6 +51,7 @@ interface TeamMembersClientProps { canManageMembers: boolean; canInviteUsers: boolean; isAuditor: boolean; + isPlatformAdmin: boolean; isCurrentUserOwner: boolean; employeeSyncData: EmployeeSyncConnectionsData; taskCompletionMap: Record; @@ -62,6 +64,7 @@ export function TeamMembersClient({ canManageMembers, canInviteUsers, isAuditor, + isPlatformAdmin, isCurrentUserOwner, employeeSyncData, taskCompletionMap, @@ -484,6 +487,12 @@ export function TeamMembersClient({
)} + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 1b1249871c..cd15f6547c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -10,8 +10,9 @@ import { TabsTrigger, } from '@trycompai/design-system'; import { Add } from '@trycompai/design-system/icons'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { InviteMembersModal } from '../all/components/InviteMembersModal'; interface PeoplePageTabsProps { @@ -27,6 +28,46 @@ interface PeoplePageTabsProps { organizationId: string; } +/** ?tab= value → Radix tab value */ +function tabParamToInternal( + tabParam: string | null, + showEmployeeTasks: boolean, + showRoleMapping: boolean, +): string { + if (!tabParam || tabParam === 'people') { + return 'people'; + } + if (tabParam === 'tasks') { + return showEmployeeTasks ? 'employee-tasks' : 'people'; + } + if (tabParam === 'devices') { + return 'devices'; + } + if (tabParam === 'chart') { + return 'org-chart'; + } + if (tabParam === 'role-mapping') { + return showRoleMapping ? 'role-mapping' : 'people'; + } + return 'people'; +} + +/** Radix tab value → ?tab= query param */ +function internalValueToTabParam(value: string): string { + switch (value) { + case 'employee-tasks': + return 'tasks'; + case 'org-chart': + return 'chart'; + case 'people': + case 'devices': + case 'role-mapping': + return value; + default: + return 'people'; + } +} + export function PeoplePageTabs({ peopleContent, employeeTasksContent, @@ -39,10 +80,34 @@ export function PeoplePageTabs({ canManageMembers, organizationId, }: PeoplePageTabsProps) { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const activeTab = tabParamToInternal( + searchParams.get('tab'), + showEmployeeTasks, + showRoleMapping, + ); + + const handleTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + const tabParam = internalValueToTabParam(value); + if (tabParam === 'people') { + params.delete('tab'); + } else { + params.set('tab', tabParam); + } + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams], + ); + return ( - + +
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx index 09c4bc6ccf..f17e2fff6e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx @@ -1,6 +1,9 @@ 'use client'; import type { Edge, Node } from '@xyflow/react'; +import { FindingScope } from '@db'; + +import { PeopleFindings } from '../../all/components/PeopleFindings'; import { OrgChartEditor } from './OrgChartEditor'; import { OrgChartEmptyState } from './OrgChartEmptyState'; import { OrgChartImageView } from './OrgChartImageView'; @@ -19,50 +22,79 @@ interface OrgChartData { interface OrgChartContentProps { chartData: OrgChartData | null; members: OrgChartMember[]; + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; } export function OrgChartContent({ chartData, members, + isAuditor, + isPlatformAdmin, + isAdminOrOwner, }: OrgChartContentProps) { + const findingsSection = ( + + ); + // No chart exists yet - show empty state if (!chartData) { - return ; + return ( +
+ + {findingsSection} +
+ ); } // Uploaded image mode if (chartData.type === 'uploaded' && chartData.signedImageUrl) { return ( - +
+ + {findingsSection} +
); } // Uploaded chart but image could not be loaded (e.g. S3 unavailable) if (chartData.type === 'uploaded') { return ( -
-
-

- The uploaded org chart image could not be loaded. -

-

- Please try again later or re-upload the image. -

+
+
+
+

+ The uploaded org chart image could not be loaded. +

+

+ Please try again later or re-upload the image. +

+
+ {findingsSection}
); } // Interactive mode return ( - +
+ + {findingsSection} +
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index aced60ac1d..b1dea19c9e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -3,10 +3,12 @@ import { auth } from '@/utils/auth'; import { s3Client, BUCKET_NAME } from '@/app/s3'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@/lib/s3-presigner'; +import { FindingScope } from '@db'; import { db } from '@db/server'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +import { PeopleFindings } from './all/components/PeopleFindings'; import { TeamMembers } from './all/components/TeamMembers'; import { getEmployeeSyncConnections } from './all/data/queries'; import { PeoplePageTabs } from './components/PeoplePageTabs'; @@ -41,6 +43,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const isAuditor = currentUserRoles.includes('auditor'); const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); + const isPlatformAdmin = session.user.role === 'admin'; // Fetch members with user info (used for both employee check and org chart) const membersWithUsers = await db.member.findMany({ @@ -198,6 +201,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: } - employeeTasksContent={showEmployeeTasks ? : null} + employeeTasksContent={ + showEmployeeTasks ? ( + + ) : null + } devicesContent={
{/* Unified compliance chart covering both device-agent and fleet devices */} @@ -224,12 +236,22 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: {filteredFleetDevices.length > 0 && ( )} + +
} orgChartContent={ } showRoleMapping={false} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx index eea28690a3..725dece7cf 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx @@ -1,6 +1,7 @@ 'use client'; import type { EvidenceFormType } from '@trycompai/company'; +import type { FindingScope } from '@db'; import { Button } from '@trycompai/design-system'; import { Plus } from 'lucide-react'; import { useState } from 'react'; @@ -10,6 +11,7 @@ interface CreateFindingButtonProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + scope?: FindingScope; onSuccess?: () => void; } @@ -17,6 +19,7 @@ export function CreateFindingButton({ taskId, evidenceSubmissionId, evidenceFormType, + scope, onSuccess, }: CreateFindingButtonProps) { const [open, setOpen] = useState(false); @@ -30,6 +33,7 @@ export function CreateFindingButton({ taskId={taskId} evidenceSubmissionId={evidenceSubmissionId} evidenceFormType={evidenceFormType} + scope={scope} open={open} onOpenChange={setOpen} onSuccess={onSuccess} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx index 7034178e69..523dc529bc 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx @@ -10,9 +10,9 @@ import { type FindingTemplate, } from '@/hooks/use-findings-api'; import type { EvidenceFormType } from '@trycompai/company'; +import { FindingScope, FindingType } from '@db'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@trycompai/ui/form'; import { useMediaQuery } from '@trycompai/ui/hooks'; -import { FindingType } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; import { Button, @@ -52,6 +52,7 @@ interface CreateFindingSheetProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + scope?: FindingScope; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -61,6 +62,7 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, + scope, open, onOpenChange, onSuccess, @@ -115,6 +117,7 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, + scope, type: data.type, templateId: templateId || undefined, content: data.content, @@ -129,7 +132,7 @@ export function CreateFindingSheet({ setIsSubmitting(false); } }, - [createFinding, taskId, evidenceSubmissionId, evidenceFormType, onOpenChange, form, onSuccess], + [createFinding, taskId, evidenceSubmissionId, evidenceFormType, scope, onOpenChange, form, onSuccess], ); const handleTemplateChange = useCallback( diff --git a/apps/app/src/hooks/use-findings-api.ts b/apps/app/src/hooks/use-findings-api.ts index 3cc04ed9a7..a5cc5ef729 100644 --- a/apps/app/src/hooks/use-findings-api.ts +++ b/apps/app/src/hooks/use-findings-api.ts @@ -3,7 +3,7 @@ import { useApi } from '@/hooks/use-api'; import { useApiSWR, UseApiSWROptions } from '@/hooks/use-api-swr'; import type { EvidenceFormType } from '@trycompai/company'; -import type { FindingStatus, FindingType } from '@db'; +import type { FindingStatus, FindingType, FindingScope } from '@db'; import { useCallback } from 'react'; // Types for findings @@ -18,6 +18,7 @@ export interface Finding { taskId: string | null; evidenceSubmissionId: string | null; evidenceFormType: EvidenceFormType | null; + scope?: FindingScope | null; templateId: string | null; createdById: string; organizationId: string; @@ -61,6 +62,7 @@ interface CreateFindingData { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + scope?: FindingScope; type?: FindingType; templateId?: string; content: string; @@ -151,6 +153,18 @@ export function useFormTypeFindings( }); } +/** + * Hook to fetch findings for a People-area scope (directory, devices, etc.) + */ +export function useScopeFindings(scope: FindingScope | null, options: UseFindingsOptions = {}) { + const endpoint = scope ? `/v1/findings?scope=${encodeURIComponent(scope)}` : null; + + return useApiSWR(endpoint, { + ...options, + refreshInterval: options.refreshInterval ?? DEFAULT_FINDINGS_POLLING_INTERVAL, + }); +} + /** * Hook to fetch all findings for an organization */ diff --git a/packages/db/prisma/migrations/20260410120000_add_finding_scope/migration.sql b/packages/db/prisma/migrations/20260410120000_add_finding_scope/migration.sql new file mode 100644 index 0000000000..5b5fad77f2 --- /dev/null +++ b/packages/db/prisma/migrations/20260410120000_add_finding_scope/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "FindingScope" AS ENUM ('people', 'people_tasks', 'people_devices', 'people_chart'); + +-- AlterTable +ALTER TABLE "Finding" ADD COLUMN "scope" "FindingScope"; diff --git a/packages/db/prisma/schema/finding.prisma b/packages/db/prisma/schema/finding.prisma index f092559ae8..f026dd3cae 100644 --- a/packages/db/prisma/schema/finding.prisma +++ b/packages/db/prisma/schema/finding.prisma @@ -10,6 +10,13 @@ enum FindingStatus { closed } +enum FindingScope { + people + people_tasks + people_devices + people_chart +} + model FindingTemplate { id String @id @default(dbgenerated("generate_prefixed_cuid('fnd_t'::text)")) category String // e.g., "evidence_issue", "further_evidence", "task_specific", "na_incorrect" @@ -28,6 +35,7 @@ model Finding { status FindingStatus @default(open) content String // Custom message or copied from template revisionNote String? // Auditor's note when requesting revision + scope FindingScope? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 0c875f0240..f424fb4785 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -13824,6 +13824,21 @@ ], "type": "string" } + }, + { + "name": "scope", + "required": false, + "in": "query", + "description": "People-area scope (e.g. people directory)", + "schema": { + "enum": [ + "people", + "people_tasks", + "people_devices", + "people_chart" + ], + "type": "string" + } } ], "responses": { @@ -25159,6 +25174,16 @@ "tabletop-exercise" ] }, + "scope": { + "type": "string", + "description": "People area scope (e.g. people directory) when not tied to a task or evidence", + "enum": [ + "people", + "people_tasks", + "people_devices", + "people_chart" + ] + }, "type": { "type": "string", "description": "Type of finding (SOC 2 or ISO 27001)", From 5bdf430b630b4b8277ebf58e10390ce34f6d3d2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:29:29 -0400 Subject: [PATCH 3/4] fix(app): able to change meeting type when uploading evidence (#2514) Co-authored-by: chasprowebdev Co-authored-by: chasprowebdev <70908289+chasprowebdev@users.noreply.github.com> --- .../components/CompanyFormPageClient.tsx | 48 +++++++++++++++++-- packages/ui/src/components/select.tsx | 9 ++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx index 86b452af17..5826175a08 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx @@ -4,7 +4,9 @@ import { conciseFormDescriptions } from '@/app/(app)/[orgId]/documents/form-desc import { evidenceFormDefinitions, meetingSubTypeValues, + meetingSubTypes, type EvidenceFormType, + type MeetingSubType, } from '@/app/(app)/[orgId]/documents/forms'; import { api } from '@/lib/api-client'; import { useActiveMember } from '@/utils/auth-client'; @@ -27,6 +29,8 @@ import { EmptyHeader, EmptyMedia, EmptyTitle, + Field, + FieldLabel, InputGroup, InputGroupAddon, InputGroupInput, @@ -61,6 +65,13 @@ import { DialogHeader, DialogTitle, } from '@trycompai/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@trycompai/ui/select'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useMemo, useRef, useState } from 'react'; @@ -160,6 +171,8 @@ export function CompanyFormPageClient({ const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploading, setIsUploading] = useState(false); const [selectedFile, setSelectedFile] = useState(null); + const [selectedMeetingType, setSelectedMeetingType] = useState('board-meeting'); + const [uploadSelectPortalRoot, setUploadSelectPortalRoot] = useState(null); const fileInputRef = useRef(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [submissionToDelete, setSubmissionToDelete] = useState(null); @@ -297,7 +310,7 @@ export function CompanyFormPageClient({ setIsUploading(true); try { const fileData = await fileToBase64(selectedFile); - const submitFormType = isMeeting ? MEETING_SUB_TYPES[0] : formType; + const submitFormType = isMeeting ? selectedMeetingType : formType; const response = await api.post( `/v1/evidence-forms/${submitFormType}/upload-submission`, @@ -329,7 +342,7 @@ export function CompanyFormPageClient({ } finally { setIsUploading(false); } - }, [selectedFile, isMeeting, formType, organizationId, query, globalMutate]); + }, [selectedFile, selectedMeetingType, isMeeting, formType, organizationId, query, globalMutate]); const handleConfirmDelete = useCallback(async () => { if (!submissionToDelete) return; @@ -589,7 +602,36 @@ export function CompanyFormPageClient({ Upload a PDF or image as evidence for this document. -
+
+ {isMeeting && ( + +
+
+ Meeting type +
+
+ +
+
+
+ )} , - React.ComponentPropsWithoutRef ->(({ className, children, position = 'popper', ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + /** Mount the menu into this element (e.g. a ref inside a Dialog) so focus trap and stacking work. */ + container?: HTMLElement | null; + } +>(({ className, children, position = 'popper', container, ...props }, ref) => ( + Date: Thu, 16 Apr 2026 15:49:29 -0400 Subject: [PATCH 4/4] fix(trust-portal): trust Vercel verdict for custom domain DNS verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(trust-portal): trust Vercel verdict for custom domain DNS verification The custom-domain DNS check was marking a domain as verified based on our own regex matching of the CNAME target, ignoring Vercel's own verdict. This let users add a CNAME pointing to the legacy `cname.vercel-dns.com` (or any `*.vercel-dns*.com` target) and see a green check in our UI even when Vercel's project required a unique dedicated target and rejected the domain as "Invalid Configuration". The UI also fell back to displaying `cname.vercel-dns.com` as the recommended CNAME whenever Vercel's `/v6/domains/{d}/config` call failed, effectively guessing a value Vercel no longer accepts for new projects. Changes: - Extract a pure `decideDomainVerification` + `deriveCnameVerified` module with 16 unit tests covering same-account, cross-account, config-failure, and Vercel-not-configured scenarios. - `checkDnsRecords` now fetches `/v6/domains/{d}/config` in parallel with the status call and uses `configuredBy === 'CNAME' && !misconfigured` as the CNAME verdict. DNS regex is only a fallback when Vercel is unreachable. - Return `vercelMisconfigured` and `recommendedCNAME` from the check-dns endpoint so the UI can show Vercel's view and the exact expected target. - `getDomainStatus` DTO gains a `misconfigured` field. - Remove `DEFAULT_CNAME_TARGET` guess from the frontend. When the target is not yet known the UI now shows "Loading from Vercel…" and disables the copy button rather than prompting the user to copy a stale default. - Add a warning alert when Vercel reports the domain as still misconfigured, showing the exact recommended CNAME value. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(trust-portal): never de-verify a working domain on transient Vercel errors `decideDomainVerification` now distinguishes confident failures (DNS wrong, Vercel explicitly reports misconfigured=true) from transient ones (Vercel API unreachable, no verdict available). `checkDnsRecords` only writes `domainVerified=false` on confident failures, preserving the prior verification state when the failure is transient. Prevents a temporary Vercel outage from silently breaking a customer's already-working custom domain. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(trust-portal): trust Vercel's misconfigured flag regardless of DNS record type Previously required `configuredBy === 'CNAME' && !misconfigured` to accept a domain as verified. That filter blocked apex domains (which need A records) and any other valid Vercel-accepted configuration (ALIAS, A, etc.) even when Vercel itself reported the domain as working. Vercel hosts these domains, so if Vercel says `misconfigured: false` the domain is reachable — the DNS method used to get there doesn't matter. Rename `deriveCnameVerified` → `deriveDnsVerified` to reflect that the function now tracks "DNS points at Vercel correctly" rather than "a CNAME specifically was used". Co-Authored-By: Claude Opus 4.7 (1M context) * fix(trust-portal): correctly distinguish loading from errored domain status `!!initialDomain && !domainStatus` was true for both SWR states — the initial fetch AND a failed request — so a failed Vercel status request left the UI stuck on "Loading from Vercel…" forever instead of surfacing the "Unavailable — refresh to retry" fallback. Use SWR's `isLoading` flag directly so the loading message only shows during the actual initial fetch. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.7 (1M context) --- .../trust/portal-settings/components/TrustPortalDomain.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx index 3292f48775..42ae0b0a18 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx @@ -49,7 +49,8 @@ export function TrustPortalDomain({ vercelVerification: string | null; orgId: string; }) { - const { data: domainStatus } = useDomain(initialDomain); + const { data: domainStatus, isLoading: domainStatusLoading } = + useDomain(initialDomain); const verificationInfo = useMemo(() => { const data = domainStatus?.data; @@ -94,7 +95,6 @@ export function TrustPortalDomain({ return target.endsWith('.') ? target : `${target}.`; }, [checkRecommendedCNAME, domainStatus?.data?.cnameTarget]); - const domainStatusLoading = !!initialDomain && !domainStatus; const vercelReportsMisconfigured = vercelMisconfigured === true || (domainStatus?.data?.misconfigured === true &&