diff --git a/.github/workflows/trigger-tasks-deploy-main.yml b/.github/workflows/trigger-tasks-deploy-main.yml index a2d57deb8..bbd03d59d 100644 --- a/.github/workflows/trigger-tasks-deploy-main.yml +++ b/.github/workflows/trigger-tasks-deploy-main.yml @@ -21,6 +21,9 @@ jobs: - name: Install DB package dependencies working-directory: ./packages/db run: bun install --frozen-lockfile --ignore-scripts + - name: Install Email package dependencies + working-directory: ./packages/email + run: bun install --frozen-lockfile --ignore-scripts - name: Generate Prisma client working-directory: ./packages/db run: bunx prisma generate diff --git a/.github/workflows/trigger-tasks-deploy-release.yml b/.github/workflows/trigger-tasks-deploy-release.yml index ee792b1b2..b5b5f158c 100644 --- a/.github/workflows/trigger-tasks-deploy-release.yml +++ b/.github/workflows/trigger-tasks-deploy-release.yml @@ -24,6 +24,9 @@ jobs: - name: Install DB package dependencies working-directory: ./packages/db run: bun install --frozen-lockfile --ignore-scripts + - name: Install Email package dependencies + working-directory: ./packages/email + run: bun install --frozen-lockfile --ignore-scripts - name: Generate Prisma client working-directory: ./packages/db diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7280e9c5a..a6410a423 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -16,6 +16,7 @@ import { RisksModule } from './risks/risks.module'; import { TasksModule } from './tasks/tasks.module'; import { VendorsModule } from './vendors/vendors.module'; import { ContextModule } from './context/context.module'; +import { TrustPortalModule } from './trust-portal/trust-portal.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { ContextModule } from './context/context.module'; TasksModule, CommentsModule, HealthModule, + TrustPortalModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0df1cedd5..06001fcde 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,5 @@ import type { INestApplication } from '@nestjs/common'; -import { VersioningType } from '@nestjs/common'; +import { ValidationPipe, VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import type { OpenAPIObject } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -11,6 +11,18 @@ import path from 'path'; async function bootstrap(): Promise { const app: INestApplication = await NestFactory.create(AppModule); + // Enable global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + // Configure body parser limits for file uploads (base64 encoded files) app.use(express.json({ limit: '15mb' })); app.use(express.urlencoded({ limit: '15mb', extended: 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 new file mode 100644 index 000000000..7f06dc66c --- /dev/null +++ b/apps/api/src/trust-portal/dto/domain-status.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Matches } from 'class-validator'; + +export class GetDomainStatusDto { + @ApiProperty({ + description: 'The domain name to check status for', + example: 'portal.example.com', + }) + @IsString() + @IsNotEmpty({ message: 'domain cannot be empty' }) + @Matches(/^(?!-)[A-Za-z0-9-]+([-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,6}$/, { + message: 'domain must be a valid domain format', + }) + domain: string; +} + +export class DomainVerificationDto { + @ApiProperty({ description: 'Verification type (e.g., TXT, CNAME)' }) + type: string; + + @ApiProperty({ description: 'Domain for verification' }) + domain: string; + + @ApiProperty({ description: 'Verification value' }) + value: string; + + @ApiProperty({ + description: 'Reason for verification status', + required: false, + }) + reason?: string; +} + +export class DomainStatusResponseDto { + @ApiProperty({ description: 'The domain name' }) + domain: string; + + @ApiProperty({ description: 'Whether the domain is verified' }) + verified: boolean; + + @ApiProperty({ + description: 'Verification records for the domain', + type: [DomainVerificationDto], + required: false, + }) + verification?: DomainVerificationDto[]; +} diff --git a/apps/api/src/trust-portal/trust-portal.controller.ts b/apps/api/src/trust-portal/trust-portal.controller.ts new file mode 100644 index 000000000..9a102882f --- /dev/null +++ b/apps/api/src/trust-portal/trust-portal.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiQuery, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { + DomainStatusResponseDto, + GetDomainStatusDto, +} from './dto/domain-status.dto'; +import { TrustPortalService } from './trust-portal.service'; + +@ApiTags('Trust Portal') +@Controller({ path: 'trust-portal', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class TrustPortalController { + constructor(private readonly trustPortalService: TrustPortalService) {} + + @Get('domain/status') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get domain verification status', + description: + 'Retrieve the verification status and DNS records for a custom domain configured in the Vercel trust portal project', + }) + @ApiQuery({ + name: 'domain', + description: 'The domain name to check status for', + example: 'portal.example.com', + required: true, + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Domain status retrieved successfully', + type: DomainStatusResponseDto, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: 'Failed to retrieve domain status from Vercel', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized - Invalid or missing authentication', + }) + async getDomainStatus( + @Query() dto: GetDomainStatusDto, + ): Promise { + return this.trustPortalService.getDomainStatus(dto); + } +} diff --git a/apps/api/src/trust-portal/trust-portal.module.ts b/apps/api/src/trust-portal/trust-portal.module.ts new file mode 100644 index 000000000..a5d011f76 --- /dev/null +++ b/apps/api/src/trust-portal/trust-portal.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { TrustPortalController } from './trust-portal.controller'; +import { TrustPortalService } from './trust-portal.service'; + +@Module({ + imports: [AuthModule], + controllers: [TrustPortalController], + providers: [TrustPortalService], + exports: [TrustPortalService], +}) +export class TrustPortalModule {} diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts new file mode 100644 index 000000000..185d7d0f0 --- /dev/null +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -0,0 +1,117 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; +import { + DomainStatusResponseDto, + DomainVerificationDto, + GetDomainStatusDto, +} from './dto/domain-status.dto'; + +interface VercelDomainVerification { + type: string; + domain: string; + value: string; + reason?: string; +} + +interface VercelDomainResponse { + name: string; + verified: boolean; + verification?: VercelDomainVerification[]; +} + +@Injectable() +export class TrustPortalService { + private readonly logger = new Logger(TrustPortalService.name); + private readonly vercelApi: AxiosInstance; + + constructor() { + const bearerToken = process.env.VERCEL_ACCESS_TOKEN; + + if (!bearerToken) { + this.logger.warn('VERCEL_ACCESS_TOKEN is not set'); + } + + // Initialize axios instance for Vercel API + this.vercelApi = axios.create({ + baseURL: 'https://api.vercel.com', + headers: { + Authorization: `Bearer ${bearerToken || ''}`, + 'Content-Type': 'application/json', + }, + }); + } + + async getDomainStatus( + dto: GetDomainStatusDto, + ): Promise { + const { domain } = dto; + + if (!process.env.TRUST_PORTAL_PROJECT_ID) { + throw new InternalServerErrorException( + 'TRUST_PORTAL_PROJECT_ID is not configured', + ); + } + + if (!process.env.VERCEL_TEAM_ID) { + throw new InternalServerErrorException( + 'VERCEL_TEAM_ID is not configured', + ); + } + + if (!domain) { + throw new BadRequestException('Domain is required'); + } + + try { + this.logger.log(`Fetching domain status for: ${domain}`); + + // Get domain information including verification status + // Vercel API endpoint: GET /v9/projects/{projectId}/domains/{domain} + const response = await this.vercelApi.get( + `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}`, + { + params: { + teamId: process.env.VERCEL_TEAM_ID, + }, + }, + ); + + const domainInfo = response.data; + + const verification: DomainVerificationDto[] | undefined = + domainInfo.verification?.map((v) => ({ + type: v.type, + domain: v.domain, + value: v.value, + reason: v.reason, + })); + + return { + domain: domainInfo.name, + verified: domainInfo.verified ?? false, + verification, + }; + } catch (error) { + this.logger.error( + `Failed to get domain status for ${domain}:`, + error instanceof Error ? error.stack : error, + ); + + // Handle axios errors with more detail + if (axios.isAxiosError(error)) { + const statusCode = error.response?.status; + const message = error.response?.data?.error?.message || error.message; + this.logger.error(`Vercel API error (${statusCode}): ${message}`); + } + + throw new InternalServerErrorException( + 'Failed to get domain status from Vercel', + ); + } + } +} diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index bdaa73637..c1ca22993 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -1,6 +1,8 @@ 'use server'; +import { sendNewPolicyEmail } from '@/jobs/tasks/email/new-policy-email'; import { db, PolicyStatus } from '@db'; +import { tasks } from '@trigger.dev/sdk'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; import { authActionClient } from '../safe-action'; @@ -92,34 +94,36 @@ export const acceptRequestedPolicyChangesAction = authActionClient return roles.includes('employee'); }); - // Call /api/send-policy-email to send emails to employees - // Prepare the events array for the API const events = employeeMembers .filter((employee) => employee.user.email) - .map((employee) => ({ - subscriberId: `${employee.user.id}-${session.activeOrganizationId}`, - email: employee.user.email, - userName: employee.user.name || employee.user.email || 'Employee', - policyName: policy.name, - organizationName: policy.organization.name, - url: `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${session.activeOrganizationId}`, - description: `The "${policy.name}" policy has been ${isNewPolicy ? 'created' : 'updated'}.`, - })); + .map((employee) => { + let notificationType: 'new' | 're-acceptance' | 'updated'; + const wasAlreadySigned = policy.signedBy.includes(employee.id); + if (isNewPolicy) { + notificationType = 'new'; + } else if (wasAlreadySigned) { + notificationType = 're-acceptance'; + } else { + notificationType = 'updated'; + } + + return { + email: employee.user.email, + userName: employee.user.name || employee.user.email || 'Employee', + policyName: policy.name, + organizationId: session.activeOrganizationId || '', + organizationName: policy.organization.name, + notificationType, + }; + }); // Call the API route to send the emails - try { - await fetch(`${process.env.BETTER_AUTH_URL ?? ''}/api/send-policy-email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(events), - }); - } catch (error) { - console.error('Failed to call /api/send-policy-email:', error); - // Don't throw, just log - } + await Promise.all( + events.map((event) => + tasks.trigger('send-new-policy-email', event), + ), + ); // If a comment was provided, create a comment if (comment && comment.trim() !== '') { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts index 058239ea1..3ec1d54f5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts @@ -19,21 +19,21 @@ export const getEmployeeDevices: () => Promise = async () => { return null; } - const organization = await db.organization.findUnique({ + // Find all members belonging to the organization. + const employees = await db.member.findMany({ where: { - id: organizationId, + organizationId, }, }); - if (!organization) { - return null; - } - - const labelId = organization.fleetDmLabelId; - - // Get all hosts to get their ids. - const employeeDevices = await fleet.get(`/labels/${labelId}/hosts`); - const allIds = employeeDevices.data.hosts.map((host: { id: number }) => host.id); + const labelIdsResponses = await Promise.all( + employees + .filter((employee) => employee.fleetDmLabelId) + .map((employee) => fleet.get(`/labels/${employee.fleetDmLabelId}/hosts`)), + ); + const allIds = labelIdsResponses.flatMap((response) => + response.data.hosts.map((host: { id: number }) => host.id), + ); // Get all devices by id. in parallel const devices = await Promise.all(allIds.map((id: number) => fleet.get(`/hosts/${id}`))); diff --git a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx index 636d78c23..05e6c2eed 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx @@ -1,5 +1,6 @@ import { deleteContextEntryAction } from '@/actions/context-hub/delete-context-entry-action'; import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header'; +import { isJSON } from '@/lib/utils'; import { AlertDialog, AlertDialogAction, @@ -78,7 +79,24 @@ export const columns = (): ColumnDef[] => [ id: 'answer', accessorKey: 'answer', header: ({ column }) => , - cell: ({ row }) => {row.original.answer}, + cell: ({ row }) => { + if (isJSON(row.original.answer)) { + return ( + + {Object.entries(JSON.parse(row.original.answer)) + .filter(([key, value]) => !!value) + .map(([key, value]) => ( +
+ {key}: + {value as string} +
+ ))} +
+ ); + } + + return {row.original.answer}; + }, meta: { label: 'Answer' }, enableColumnFilter: true, enableSorting: false, diff --git a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalDomain.tsx b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalDomain.tsx index 62495cc81..247fa382a 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalDomain.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalDomain.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useDomain } from '@/hooks/use-domain'; import { Button } from '@comp/ui/button'; import { Card, @@ -13,9 +14,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from ' import { Input } from '@comp/ui/input'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AlertCircle, CheckCircle, ClipboardCopy, Loader2 } from 'lucide-react'; +import { AlertCircle, CheckCircle, ClipboardCopy, ExternalLink, Loader2 } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -51,6 +52,17 @@ export function TrustPortalDomain({ const [isTxtVerified, setIsTxtVerified] = useState(false); const [isVercelTxtVerified, setIsVercelTxtVerified] = useState(false); + const { data: domainStatus } = useDomain(initialDomain); + + const verificationInfo = useMemo(() => { + const data = domainStatus?.data; + if (data && !data.verified && data.verification && data.verification.length > 0) { + return data.verification[0]; + } + + return null; + }, [domainStatus]); + useEffect(() => { const isCnameVerified = localStorage.getItem(`${initialDomain}-isCnameVerified`); const isTxtVerified = localStorage.getItem(`${initialDomain}-isTxtVerified`); @@ -215,6 +227,28 @@ export function TrustPortalDomain({ initialDomain !== '' && !domainVerified && (
+ {verificationInfo && ( +
+
+ +

+ This domain is linked to another Vercel account. To use it with this + project, add a {verificationInfo.type} record at{' '} + {verificationInfo.domain} to verify ownership. You can remove the record + after verification is complete. + + Learn more + + +

+
+
+ )}
diff --git a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts index 1002c945a..bbe85dc1f 100644 --- a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts @@ -23,6 +23,11 @@ const onboardingCompletionSchema = z.object({ infrastructure: z.string().min(1), dataTypes: z.string().min(1), geo: z.string().min(1), + shipping: z.object({ + fullName: z.string(), + address: z.string(), + phone: z.string(), + }), }); export const completeOnboarding = authActionClient @@ -63,16 +68,18 @@ export const completeOnboarding = authActionClient // Save the remaining steps to context const postPaymentSteps = steps.slice(3); // Steps 4-12 - await db.context.createMany({ - data: postPaymentSteps - .filter((step) => step.key in parsedInput) - .map((step) => ({ - question: step.question, - answer: parsedInput[step.key as keyof typeof parsedInput] as string, - tags: ['onboarding'], - organizationId: parsedInput.organizationId, - })), - }); + const contextData = postPaymentSteps + .filter((step) => step.key in parsedInput) + .map((step) => ({ + question: step.question, + answer: + typeof parsedInput[step.key as keyof typeof parsedInput] === 'object' + ? JSON.stringify(parsedInput[step.key as keyof typeof parsedInput]) + : (parsedInput[step.key as keyof typeof parsedInput] as string), + tags: ['onboarding'], + organizationId: parsedInput.organizationId, + })); + await db.context.createMany({ data: contextData }); // Update organization to mark onboarding as complete await db.organization.update({ diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 29fa8f756..a5116e889 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -88,7 +88,7 @@ export function PostPaymentOnboarding({
Step {stepIndex + 1} of {totalSteps}
- + {step?.question || ''} @@ -103,10 +103,7 @@ export function PostPaymentOnboarding({ autoComplete="off" > {steps.map((s, idx) => ( -
+
( @@ -118,9 +115,11 @@ export function PostPaymentOnboarding({ savedAnswers={savedAnswers} /> -
- -
+ {s.key !== 'shipping' && ( +
+ +
+ )} )} /> diff --git a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts index 08943bcd9..16b50af31 100644 --- a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts +++ b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts @@ -134,6 +134,11 @@ export function usePostPaymentOnboarding({ infrastructure: allAnswers.infrastructure || '', dataTypes: allAnswers.dataTypes || '', geo: allAnswers.geo || '', + shipping: allAnswers.shipping || { + fullName: '', + address: '', + phone: '', + }, }); }; @@ -153,7 +158,7 @@ export function usePostPaymentOnboarding({ // Handle multi-select fields with "Other" option for (const key of Object.keys(newAnswers)) { - if (step.options && step.key === key && key !== 'frameworkIds') { + if (step.options && step.key === key && key !== 'frameworkIds' && key !== 'shipping') { const customValue = newAnswers[`${key}Other`] || ''; const values = (newAnswers[key] || '').split(',').filter(Boolean); diff --git a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx index ca5d2135a..f0f38a230 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx @@ -1,3 +1,4 @@ +import { FormLabel } from '@comp/ui/form'; import { Input } from '@comp/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { SelectPills } from '@comp/ui/select-pills'; @@ -39,6 +40,55 @@ export function OnboardingStepInput({ ); } + if (currentStep.key === 'shipping') { + return ( +
+
+
+ Full Name + +

+ {form.formState.errors.shipping?.fullName?.message} +

+
+
+ Phone + +

+ {form.formState.errors.shipping?.phone?.message} +

+
+
+
+ Address +