diff --git a/apps/api/src/findings/dto/create-finding.dto.ts b/apps/api/src/findings/dto/create-finding.dto.ts index 33d6766ec1..acb5887aa1 100644 --- a/apps/api/src/findings/dto/create-finding.dto.ts +++ b/apps/api/src/findings/dto/create-finding.dto.ts @@ -7,7 +7,7 @@ import { IsOptional, MaxLength, } from 'class-validator'; -import { FindingType } from '@db'; +import { FindingScope, FindingType } from '@db'; import { evidenceFormTypeSchema, type EvidenceFormType, @@ -43,6 +43,16 @@ export class CreateFindingDto { @IsOptional() evidenceFormType?: EvidenceFormType; + @ApiProperty({ + description: + 'People area scope (e.g. people directory) when not tied to a task or evidence', + enum: FindingScope, + required: false, + }) + @IsEnum(FindingScope) + @IsOptional() + scope?: FindingScope; + @ApiProperty({ description: 'Type of finding (SOC 2 or ISO 27001)', enum: FindingType, diff --git a/apps/api/src/findings/finding-audit.service.ts b/apps/api/src/findings/finding-audit.service.ts index 5f3405abaa..d4ce430f52 100644 --- a/apps/api/src/findings/finding-audit.service.ts +++ b/apps/api/src/findings/finding-audit.service.ts @@ -21,6 +21,7 @@ export class FindingAuditService { taskTitle?: string; evidenceSubmissionId?: string; evidenceSubmissionFormType?: string; + findingScope?: string; content: string; type: FindingType; }, @@ -41,6 +42,7 @@ export class FindingAuditService { taskTitle: params.taskTitle, evidenceSubmissionId: params.evidenceSubmissionId, evidenceSubmissionFormType: params.evidenceSubmissionFormType, + findingScope: params.findingScope, content: params.content, type: params.type, status: FindingStatus.open, @@ -155,6 +157,7 @@ export class FindingAuditService { taskTitle?: string; evidenceSubmissionId?: string; evidenceSubmissionFormType?: string; + findingScope?: string; content: string; }, ): Promise { @@ -174,6 +177,7 @@ export class FindingAuditService { taskTitle: params.taskTitle, evidenceSubmissionId: params.evidenceSubmissionId, evidenceSubmissionFormType: params.evidenceSubmissionFormType, + findingScope: params.findingScope, content: params.content, }, }, diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index a65f76300d..982f4e82be 100644 --- a/apps/api/src/findings/finding-notifier.service.spec.ts +++ b/apps/api/src/findings/finding-notifier.service.spec.ts @@ -10,6 +10,12 @@ jest.mock( soc2: 'soc2', iso27001: 'iso27001', }, + FindingScope: { + people: 'people', + people_tasks: 'people_tasks', + people_devices: 'people_devices', + people_chart: 'people_chart', + }, FindingStatus: { open: 'open', ready_for_review: 'ready_for_review', @@ -75,8 +81,14 @@ const mockDbModule: { soc2: 'soc2'; iso27001: 'iso27001'; }; + FindingScope: { + people: 'people'; + people_tasks: 'people_tasks'; + people_devices: 'people_devices'; + people_chart: 'people_chart'; + }; } = jest.requireMock('@db'); -const { db, FindingType } = mockDbModule; +const { db, FindingType, FindingScope } = mockDbModule; describe('FindingNotifierService', () => { const mockedDb = db; @@ -194,5 +206,49 @@ describe('FindingNotifierService', () => { }), ); }); + + it('builds People page URLs with tab query for scope-based findings', async () => { + await service.notifyFindingCreated({ + organizationId: 'org_123', + findingId: 'fdg_scope', + findingScope: FindingScope.people_devices, + findingContent: 'Device area finding', + findingType: FindingType.soc2, + actorUserId: 'usr_actor', + actorName: 'Actor', + }); + + expect(mockedDb.task.findUnique).not.toHaveBeenCalled(); + expect(mockedDb.evidenceSubmission.findUnique).not.toHaveBeenCalled(); + + expect(novuTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + findingUrl: 'https://app.trycomp.ai/org_123/people?tab=people', + }), + }), + ); + }); + + it('aligns title and URL by preferring People scope over document fields when both are set', async () => { + await service.notifyFindingCreated({ + organizationId: 'org_123', + findingId: 'fdg_mixed', + findingScope: FindingScope.people, + evidenceSubmissionFormType: 'meeting', + findingContent: 'Ambiguous finding', + findingType: FindingType.soc2, + actorUserId: 'usr_actor', + actorName: 'Actor', + }); + + expect(novuTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + findingUrl: 'https://app.trycomp.ai/org_123/people?tab=devices', + }), + }), + ); + }); }); }); diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index e233cf0adf..2ee6835e46 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -1,4 +1,4 @@ -import { db, FindingStatus, FindingType } from '@db'; +import { db, FindingScope, FindingStatus, FindingType } from '@db'; import { Injectable, Logger } from '@nestjs/common'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; @@ -37,6 +37,7 @@ interface NotificationParams { evidenceSubmissionId?: string; evidenceSubmissionFormType?: string; evidenceSubmissionSubmittedById?: string | null; + findingScope?: FindingScope | null; findingContent: string; findingType: FindingType; actorUserId: string; @@ -100,6 +101,92 @@ function getDocumentContextTitle( return 'Document submission'; } +/** Matches People page `?tab=` query (see PeoplePageTabs). */ +function scopePeopleTabParam(scope: FindingScope): string { + switch (scope) { + case FindingScope.people: + return 'people'; + case FindingScope.people_tasks: + return 'tasks'; + case FindingScope.people_devices: + return 'devices'; + case FindingScope.people_chart: + return 'chart'; + default: + return 'people'; + } +} + +function scopeContextTitle(scope: FindingScope): string { + switch (scope) { + case FindingScope.people: + return 'People'; + case FindingScope.people_tasks: + return 'People: Tasks'; + case FindingScope.people_devices: + return 'People: Devices'; + case FindingScope.people_chart: + return 'People: Chart'; + default: + return 'People'; + } +} + +function resolveFindingContextTitle(params: { + taskTitle?: string; + evidenceSubmissionFormType?: string; + evidenceSubmissionId?: string; + findingScope?: FindingScope | null; +}): string { + const { + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + } = params; + if (taskTitle) { + return taskTitle; + } + if (findingScope) { + return scopeContextTitle(findingScope); + } + return getDocumentContextTitle( + evidenceSubmissionFormType, + evidenceSubmissionId, + ); +} + +function buildFindingDeepLink(params: { + organizationId: string; + taskId?: string; + evidenceSubmissionId?: string; + evidenceSubmissionFormType?: string; + findingScope?: FindingScope | null; +}): string { + const base = getAppUrl(); + const { + organizationId, + taskId, + evidenceSubmissionId, + evidenceSubmissionFormType, + findingScope, + } = params; + + if (taskId) { + return `${base}/${organizationId}/tasks/${taskId}`; + } + if (findingScope) { + return `${base}/${organizationId}/people?tab=${scopePeopleTabParam(findingScope)}`; + } + if (evidenceSubmissionId && evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`; + } + if (evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}`; + } + return `${base}/${organizationId}/overview`; +} + // ============================================================================ // Service // ============================================================================ @@ -116,7 +203,8 @@ export class FindingNotifierService { /** * Notify when a new finding is created. - * Recipients: All org members (filtered by notification matrix) + * Recipients: task-linked → assignee pool; People scope → owners/admins; + * document-linked → submitter + admins (see getSubmissionSubmitterAndAdmins). */ async notifyFindingCreated(params: NotificationParams): Promise { const { @@ -129,26 +217,34 @@ export class FindingNotifierService { findingType, actorUserId, actorName, + findingScope, } = params; - const recipients = taskId - ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) - : await this.getSubmissionSubmitterAndAdmins( - organizationId, - evidenceSubmissionId, - evidenceSubmissionSubmittedById, - actorUserId, - ); + const recipients = await this.resolveFindingNotificationRecipients({ + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + }); if (recipients.length === 0) { this.logger.log('No recipients for finding created notification'); return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); - const contextLabel = taskId ? 'task' : 'document submission'; + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); + const contextLabel = taskId + ? 'task' + : findingScope + ? 'People area' + : 'document submission'; await this.sendNotifications({ ...params, @@ -175,6 +271,7 @@ export class FindingNotifierService { actorUserId, actorName, findingCreatorMemberId, + findingScope, } = params; this.logger.log( @@ -197,9 +294,12 @@ export class FindingNotifierService { `[notifyReadyForReview] Finding ${findingId}: Sending to ${recipients.length} recipient(s): ${recipients.map((r) => r.email).join(', ')}`, ); - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -214,7 +314,8 @@ export class FindingNotifierService { /** * Notify when status changes to Needs Revision. - * Recipients: All org members (filtered by notification matrix) + * Recipients: task-linked → assignee pool; People scope → owners/admins; + * document-linked → submitter + admins. */ async notifyNeedsRevision(params: NotificationParams): Promise { const { @@ -226,25 +327,29 @@ export class FindingNotifierService { evidenceSubmissionSubmittedById, actorUserId, actorName, + findingScope, } = params; - const recipients = taskId - ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) - : await this.getSubmissionSubmitterAndAdmins( - organizationId, - evidenceSubmissionId, - evidenceSubmissionSubmittedById, - actorUserId, - ); + const recipients = await this.resolveFindingNotificationRecipients({ + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + }); if (recipients.length === 0) { this.logger.log('No recipients for needs revision notification'); return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -259,7 +364,8 @@ export class FindingNotifierService { /** * Notify when finding is closed. - * Recipients: All org members (filtered by notification matrix) + * Recipients: task-linked → assignee pool; People scope → owners/admins; + * document-linked → submitter + admins. */ async notifyFindingClosed(params: NotificationParams): Promise { const { @@ -271,25 +377,29 @@ export class FindingNotifierService { evidenceSubmissionSubmittedById, actorUserId, actorName, + findingScope, } = params; - const recipients = taskId - ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) - : await this.getSubmissionSubmitterAndAdmins( - organizationId, - evidenceSubmissionId, - evidenceSubmissionSubmittedById, - actorUserId, - ); + const recipients = await this.resolveFindingNotificationRecipients({ + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + }); if (recipients.length === 0) { this.logger.log('No recipients for finding closed notification'); return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -328,6 +438,7 @@ export class FindingNotifierService { heading, message, newStatus, + findingScope, } = params; // Fetch organization name @@ -337,15 +448,19 @@ export class FindingNotifierService { }); const organizationName = organization?.name ?? 'your organization'; - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); - const findingUrl = - evidenceSubmissionId && evidenceSubmissionFormType - ? `${getAppUrl()}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}` - : evidenceSubmissionFormType - ? `${getAppUrl()}/${organizationId}/documents/${evidenceSubmissionFormType}` - : `${getAppUrl()}/${organizationId}/tasks/${taskId}`; + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); + const findingUrl = buildFindingDeepLink({ + organizationId, + taskId, + evidenceSubmissionId, + evidenceSubmissionFormType, + findingScope, + }); const typeLabel = TYPE_LABELS[findingType]; const statusLabel = newStatus ? STATUS_LABELS[newStatus] : undefined; @@ -594,6 +709,95 @@ export class FindingNotifierService { // Private Methods - Recipient Resolution // ========================================================================== + /** + * Task findings → task notification pool; People scope → owners/admins only; + * otherwise document flow (submitter + admins). + */ + private async resolveFindingNotificationRecipients(args: { + organizationId: string; + taskId?: string; + findingScope?: FindingScope | null; + evidenceSubmissionId?: string; + evidenceSubmissionSubmittedById?: string | null; + actorUserId: string; + }): Promise { + const { + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + } = args; + + if (taskId) { + return this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId); + } + if (findingScope) { + return this.getOrganizationOwnersAndAdmins(organizationId, actorUserId); + } + return this.getSubmissionSubmitterAndAdmins( + organizationId, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + ); + } + + /** + * Recipients for People-area (scope) findings: organization owners and admins. + * Excludes the actor. Notification matrix (unsubscribe) still applies per recipient. + */ + private async getOrganizationOwnersAndAdmins( + organizationId: string, + excludeUserId: string, + ): Promise { + try { + const allMembers = await db.member.findMany({ + where: { + organizationId, + deactivated: false, + }, + select: { + role: true, + user: { select: { id: true, email: true, name: true } }, + }, + }); + + const members = allMembers.filter( + (member) => + member.role.includes('admin') || member.role.includes('owner'), + ); + + const recipients: Recipient[] = []; + const addedUserIds = new Set(); + + for (const member of members) { + const user = member.user; + if ( + user.id !== excludeUserId && + user.email && + !addedUserIds.has(user.id) + ) { + recipients.push({ + userId: user.id, + email: user.email, + name: user.name || user.email, + }); + addedUserIds.add(user.id); + } + } + + return recipients; + } catch (error) { + this.logger.error( + 'Failed to get organization owners and admins for scope finding:', + error instanceof Error ? error.message : 'Unknown error', + ); + return []; + } + } + /** * Get all organization members as potential recipients. * Excludes the actor (person who triggered the action). diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts index 2f0d3dc9ed..f05affb158 100644 --- a/apps/api/src/findings/findings.controller.ts +++ b/apps/api/src/findings/findings.controller.ts @@ -22,7 +22,7 @@ import { ApiTags, ApiSecurity, } from '@nestjs/swagger'; -import { FindingStatus } from '@db'; +import { FindingScope, FindingStatus } from '@db'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; @@ -68,6 +68,12 @@ export class FindingsController { example: 'access-request', enum: evidenceFormTypeSchema.options, }) + @ApiQuery({ + name: 'scope', + required: false, + description: 'People-area scope (e.g. people directory)', + enum: FindingScope, + }) @ApiResponse({ status: 200, description: 'List of findings', @@ -84,19 +90,20 @@ export class FindingsController { @Query('taskId') taskId: string, @Query('evidenceSubmissionId') evidenceSubmissionId: string, @Query('evidenceFormType') evidenceFormType: string, + @Query('scope') scope: string, @AuthContext() authContext: AuthContextType, ) { - const targets = [taskId, evidenceSubmissionId, evidenceFormType].filter( + const targets = [taskId, evidenceSubmissionId, evidenceFormType, scope].filter( Boolean, ); if (targets.length === 0) { throw new BadRequestException( - 'One of taskId, evidenceSubmissionId, or evidenceFormType query parameter is required', + 'One of taskId, evidenceSubmissionId, evidenceFormType, or scope query parameter is required', ); } if (targets.length > 1) { throw new BadRequestException( - 'Provide only one target: taskId, evidenceSubmissionId, or evidenceFormType', + 'Provide only one target: taskId, evidenceSubmissionId, evidenceFormType, or scope', ); } @@ -109,6 +116,18 @@ export class FindingsController { ); } + if (scope) { + if (!Object.values(FindingScope).includes(scope as FindingScope)) { + throw new BadRequestException( + `Invalid scope value. Must be one of: ${Object.values(FindingScope).join(', ')}`, + ); + } + return await this.findingsService.findByScope( + authContext.organizationId, + scope as FindingScope, + ); + } + if (taskId) { return await this.findingsService.findByTaskId( authContext.organizationId, diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index a8864b39dc..81f358e502 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -8,6 +8,7 @@ import { import { db, EvidenceFormType as DbEvidenceFormType, + FindingScope, FindingStatus, FindingType, } from '@db'; @@ -175,6 +176,22 @@ export class FindingsService { return findings.map((finding) => this.normalizeFindingFormTypes(finding)); } + /** + * Get all findings for a People-area scope (directory, devices tab, etc.) + */ + async findByScope(organizationId: string, scope: FindingScope) { + const findings = await db.finding.findMany({ + where: { organizationId, scope }, + include: this.findingInclude, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + }); + + this.logger.log( + `Retrieved ${findings.length} findings for scope ${scope} in org ${organizationId}`, + ); + return findings.map((finding) => this.normalizeFindingFormTypes(finding)); + } + /** * Get all findings for an organization */ @@ -225,19 +242,21 @@ export class FindingsService { const hasTaskTarget = Boolean(createDto.taskId); const hasSubmissionTarget = Boolean(createDto.evidenceSubmissionId); const hasFormTypeTarget = Boolean(createDto.evidenceFormType); + const hasScopeTarget = Boolean(createDto.scope); const targetCount = (hasTaskTarget ? 1 : 0) + (hasSubmissionTarget ? 1 : 0) + - (hasFormTypeTarget ? 1 : 0); + (hasFormTypeTarget ? 1 : 0) + + (hasScopeTarget ? 1 : 0); if (targetCount === 0) { throw new BadRequestException( - 'One of taskId, evidenceSubmissionId, or evidenceFormType is required', + 'One of taskId, evidenceSubmissionId, evidenceFormType, or scope is required', ); } if (targetCount > 1) { throw new BadRequestException( - 'Provide only one target: taskId, evidenceSubmissionId, or evidenceFormType', + 'Provide only one target: taskId, evidenceSubmissionId, evidenceFormType, or scope', ); } @@ -308,6 +327,7 @@ export class FindingsService { evidenceFormType: createDto.evidenceFormType ? toDbEvidenceFormType(createDto.evidenceFormType) : null, + scope: createDto.scope ?? null, type: createDto.type, content: createDto.content, templateId: createDto.templateId, @@ -328,6 +348,7 @@ export class FindingsService { taskTitle: task?.title, evidenceSubmissionId: evidenceSubmission?.id, evidenceSubmissionFormType: resolvedFormType, + findingScope: createDto.scope, content: createDto.content, type: createDto.type ?? FindingType.soc2, }); @@ -346,6 +367,7 @@ export class FindingsService { evidenceSubmissionId: evidenceSubmission?.id, evidenceSubmissionFormType: resolvedFormType, evidenceSubmissionSubmittedById: evidenceSubmission?.submittedById, + findingScope: createDto.scope ?? null, findingContent: createDto.content, findingType: createDto.type ?? FindingType.soc2, actorUserId: userId, @@ -356,7 +378,9 @@ export class FindingsService { ? `task ${task.id}` : createDto.evidenceFormType ? `evidence form type ${createDto.evidenceFormType}` - : `evidence submission ${evidenceSubmission?.id}`; + : createDto.scope + ? `scope ${createDto.scope}` + : `evidence submission ${evidenceSubmission?.id}`; this.logger.log(`Created finding ${finding.id} for ${target}`); return this.normalizeFindingFormTypes(finding); } @@ -516,6 +540,7 @@ export class FindingsService { finding.evidenceFormType ?? finding.evidenceSubmission?.formType, evidenceSubmissionSubmittedById: finding.evidenceSubmission?.submittedById, + findingScope: finding.scope ?? null, findingContent: updatedFinding.content, findingType: updatedFinding.type, actorUserId: userId, @@ -599,6 +624,7 @@ export class FindingsService { evidenceSubmissionId: finding.evidenceSubmission?.id, evidenceSubmissionFormType: finding.evidenceFormType ?? finding.evidenceSubmission?.formType, + findingScope: finding.scope ?? undefined, content: finding.content, }); 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]/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 +
+
+ +
+
+
+ )}
- {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/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..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 @@ -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, @@ -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; @@ -72,23 +73,33 @@ 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 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'} + + )} +