diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index ea6ed4d449..de93dee123 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -1,5 +1,9 @@ import '../config/load-env'; -import { MagicLinkEmail, OTPVerificationEmail } from '@trycompai/email'; +import { + MagicLinkEmail, + OTPVerificationEmail, + VerifyEmail, +} from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; import { InviteEmail } from '../email/templates/invite-member'; import { db } from '@db'; @@ -293,6 +297,20 @@ export const auth = betterAuth({ trustedOrigins: getTrustedOrigins(), emailAndPassword: { enabled: true, + requireEmailVerification: true, + }, + emailVerification: { + sendOnSignUp: true, + sendVerificationEmail: async ({ user, url }) => { + if (process.env.NODE_ENV === 'development') { + console.log('[Auth] Sending verification email to:', user.email); + } + await triggerEmail({ + to: user.email, + subject: 'Verify your email for Comp AI', + react: VerifyEmail({ email: user.email, url }), + }); + }, }, advanced: { database: { diff --git a/apps/api/src/organization-access/organization-access.service.spec.ts b/apps/api/src/organization-access/organization-access.service.spec.ts index 211b60779f..19f003e930 100644 --- a/apps/api/src/organization-access/organization-access.service.spec.ts +++ b/apps/api/src/organization-access/organization-access.service.spec.ts @@ -8,6 +8,9 @@ jest.mock('@db', () => ({ findUnique: jest.fn(), update: jest.fn(), }, + user: { + findFirst: jest.fn(), + }, }, })); @@ -16,6 +19,9 @@ const mockedDb = db as unknown as { findUnique: jest.Mock; update: jest.Mock; }; + user: { + findFirst: jest.Mock; + }; }; const buildService = ( @@ -39,6 +45,7 @@ describe('OrganizationAccessService', () => { delete process.env.SELF_HOSTED; delete process.env.NEXT_PUBLIC_SELF_HOSTED; mockedDb.organization.update.mockResolvedValue({}); + mockedDb.user.findFirst.mockResolvedValue({ emailVerified: true }); }); afterAll(() => { @@ -135,6 +142,29 @@ describe('OrganizationAccessService', () => { expect(mockedDb.organization.update).toHaveBeenCalled(); }); + it('does not grant on @trycomp.ai email when the account email is unverified', async () => { + mockedDb.organization.findUnique.mockResolvedValue({ + id: 'org_1', + hasAccess: false, + website: 'acme.com', + }); + mockedDb.user.findFirst.mockResolvedValue({ emailVerified: false }); + const { service, isDomainActiveCustomer } = buildService(); + + const result = await service.autoApproveAccess({ + organizationId: 'org_1', + userEmail: 'tofik@trycomp.ai', + }); + + expect(result).toEqual({ + hasAccess: false, + autoApproved: false, + reason: 'not-eligible', + }); + expect(isDomainActiveCustomer).not.toHaveBeenCalled(); + expect(mockedDb.organization.update).not.toHaveBeenCalled(); + }); + it('grants when user email domain matches org website AND is an active Stripe customer', async () => { mockedDb.organization.findUnique.mockResolvedValue({ id: 'org_1', diff --git a/apps/api/src/organization-access/organization-access.service.ts b/apps/api/src/organization-access/organization-access.service.ts index 818acecfc9..4f38f97099 100644 --- a/apps/api/src/organization-access/organization-access.service.ts +++ b/apps/api/src/organization-access/organization-access.service.ts @@ -80,10 +80,16 @@ export class OrganizationAccessService { return { hasAccess: false, autoApproved: false, reason: 'not-eligible' }; } - const isTrycompEmail = userEmailDomain === 'trycomp.ai'; + const isTrycompEmailDomain = userEmailDomain === 'trycomp.ai'; + + // Only treat an @trycomp.ai address as an internal team member once the + // account's email has actually been verified. The domain on its own proves + // nothing about who controls the mailbox. + const isVerifiedTrycompEmail = + isTrycompEmailDomain && (await this.isEmailVerified(userEmail)); const canAutoApproveViaDomain = - !isTrycompEmail && + !isTrycompEmailDomain && Boolean(orgWebsiteDomain) && userEmailDomain === orgWebsiteDomain && !isPublicEmailDomain(userEmailDomain); @@ -92,9 +98,9 @@ export class OrganizationAccessService { ? await this.stripeService.isDomainActiveCustomer(userEmailDomain) : false; - if (isTrycompEmail || isStripeCustomer) { + if (isVerifiedTrycompEmail || isStripeCustomer) { await this.grantAccess(organizationId); - const reason: AutoApproveReason = isTrycompEmail + const reason: AutoApproveReason = isVerifiedTrycompEmail ? 'trycomp-email' : 'stripe-customer'; this.logger.log( @@ -106,6 +112,17 @@ export class OrganizationAccessService { return { hasAccess: false, autoApproved: false, reason: 'not-eligible' }; } + private async isEmailVerified(email: string | undefined): Promise { + if (!email) { + return false; + } + const user = await db.user.findFirst({ + where: { email }, + select: { emailVerified: true }, + }); + return user?.emailVerified === true; + } + private async grantAccess(organizationId: string): Promise { await db.organization.update({ where: { id: organizationId }, diff --git a/apps/api/src/organization/dto/update-organization.dto.ts b/apps/api/src/organization/dto/update-organization.dto.ts index 18c301ffc0..d06f997184 100644 --- a/apps/api/src/organization/dto/update-organization.dto.ts +++ b/apps/api/src/organization/dto/update-organization.dto.ts @@ -1,14 +1,76 @@ -export interface UpdateOrganizationDto { +import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; + +/** + * Fields an organization member may update on their own organization. + * + * Note: this is a class (not an interface) on purpose — the global + * ValidationPipe runs with `whitelist` + `forbidNonWhitelisted`, which only + * enforce the contract when class-validator metadata survives to runtime. + * Properties that are not represented here are rejected by the pipe. The + * request/response documentation lives in `organization-api-bodies.ts`. + */ +export class UpdateOrganizationDto { + @IsOptional() + @IsString() name?: string; + + @IsOptional() + @IsString() slug?: string; + + @IsOptional() + @IsString() logo?: string; + + @IsOptional() + @IsString() metadata?: string; + + @IsOptional() + @IsString() website?: string; + + @IsOptional() + @IsBoolean() onboardingCompleted?: boolean; - hasAccess?: boolean; + + @IsOptional() + @IsInt() fleetDmLabelId?: number; + + @IsOptional() + @IsBoolean() isFleetSetupCompleted?: boolean; + + @IsOptional() + @IsString() primaryColor?: string; + + @IsOptional() + @IsBoolean() advancedModeEnabled?: boolean; + + @IsOptional() + @IsBoolean() backgroundCheckStepEnabled?: boolean; + + @IsOptional() + @IsBoolean() + evidenceApprovalEnabled?: boolean; + + @IsOptional() + @IsBoolean() + deviceAgentStepEnabled?: boolean; + + @IsOptional() + @IsBoolean() + securityTrainingStepEnabled?: boolean; + + @IsOptional() + @IsBoolean() + whistleblowerReportEnabled?: boolean; + + @IsOptional() + @IsBoolean() + accessRequestFormEnabled?: boolean; } diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts index a93c9044df..e0951b0f8d 100644 --- a/apps/api/src/organization/organization.controller.ts +++ b/apps/api/src/organization/organization.controller.ts @@ -24,7 +24,7 @@ import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; import { ApiKeyService } from '../auth/api-key.service'; import type { AuthContext as AuthContextType } from '../auth/types'; -import type { UpdateOrganizationDto } from './dto/update-organization.dto'; +import { UpdateOrganizationDto } from './dto/update-organization.dto'; import type { TransferOwnershipDto } from './dto/transfer-ownership.dto'; import { OrganizationService } from './organization.service'; import { GET_ORGANIZATION_RESPONSES } from './schemas/get-organization.responses'; diff --git a/apps/api/src/organization/organization.service.spec.ts b/apps/api/src/organization/organization.service.spec.ts new file mode 100644 index 0000000000..b1104d8b37 --- /dev/null +++ b/apps/api/src/organization/organization.service.spec.ts @@ -0,0 +1,95 @@ +jest.mock('@db', () => ({ + db: { + organization: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }, + Role: {}, +})); + +jest.mock('../app/s3', () => ({ + s3Client: {}, + getSignedUrl: jest.fn(), + PutObjectCommand: jest.fn(), + GetObjectCommand: jest.fn(), + APP_AWS_ORG_ASSETS_BUCKET: 'bucket', +})); + +jest.mock('@trycompai/auth', () => ({ + allRoles: {}, +})); + +import { db } from '@db'; +import { OrganizationService } from './organization.service'; +import type { UpdateOrganizationDto } from './dto/update-organization.dto'; + +const mockedDb = db as unknown as { + organization: { findUnique: jest.Mock; update: jest.Mock }; +}; + +describe('OrganizationService.updateById', () => { + const service = new OrganizationService(); + const existing = { + id: 'org_1', + name: 'Acme', + slug: 'acme', + logo: null, + metadata: null, + website: null, + onboardingCompleted: false, + hasAccess: false, + fleetDmLabelId: null, + isFleetSetupCompleted: false, + primaryColor: null, + advancedModeEnabled: false, + createdAt: new Date(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockedDb.organization.findUnique.mockResolvedValue(existing); + mockedDb.organization.update.mockResolvedValue(existing); + }); + + it('persists the profile fields that were provided', async () => { + await service.updateById('org_1', { + name: 'New Name', + website: 'https://acme.com', + }); + + const arg = mockedDb.organization.update.mock.calls[0][0]; + expect(arg.where).toEqual({ id: 'org_1' }); + expect(arg.data.name).toBe('New Name'); + expect(arg.data.website).toBe('https://acme.com'); + }); + + it('persists the org-owned onboarding and portal toggles', async () => { + await service.updateById('org_1', { + evidenceApprovalEnabled: true, + deviceAgentStepEnabled: false, + securityTrainingStepEnabled: false, + whistleblowerReportEnabled: false, + accessRequestFormEnabled: true, + }); + + const arg = mockedDb.organization.update.mock.calls[0][0]; + expect(arg.data.evidenceApprovalEnabled).toBe(true); + expect(arg.data.deviceAgentStepEnabled).toBe(false); + expect(arg.data.securityTrainingStepEnabled).toBe(false); + expect(arg.data.whistleblowerReportEnabled).toBe(false); + expect(arg.data.accessRequestFormEnabled).toBe(true); + }); + + it('never persists hasAccess supplied through the update payload', async () => { + const payload = { + name: 'New Name', + hasAccess: true, + } as unknown as UpdateOrganizationDto; + + await service.updateById('org_1', payload); + + const arg = mockedDb.organization.update.mock.calls[0][0]; + expect(arg.data).not.toHaveProperty('hasAccess'); + }); +}); diff --git a/apps/api/src/organization/organization.service.ts b/apps/api/src/organization/organization.service.ts index e54546c06e..2d7bea2b7e 100644 --- a/apps/api/src/organization/organization.service.ts +++ b/apps/api/src/organization/organization.service.ts @@ -87,10 +87,30 @@ export class OrganizationService { throw new NotFoundException(`Organization with ID ${id} not found`); } - // Update the organization with only provided fields + // Persist only the profile fields an organization owner may change. + // Platform- and billing-managed flags are set through their own flows. + const data = { + name: updateData.name, + slug: updateData.slug, + logo: updateData.logo, + metadata: updateData.metadata, + website: updateData.website, + onboardingCompleted: updateData.onboardingCompleted, + fleetDmLabelId: updateData.fleetDmLabelId, + isFleetSetupCompleted: updateData.isFleetSetupCompleted, + primaryColor: updateData.primaryColor, + advancedModeEnabled: updateData.advancedModeEnabled, + backgroundCheckStepEnabled: updateData.backgroundCheckStepEnabled, + evidenceApprovalEnabled: updateData.evidenceApprovalEnabled, + deviceAgentStepEnabled: updateData.deviceAgentStepEnabled, + securityTrainingStepEnabled: updateData.securityTrainingStepEnabled, + whistleblowerReportEnabled: updateData.whistleblowerReportEnabled, + accessRequestFormEnabled: updateData.accessRequestFormEnabled, + }; + const updatedOrganization = await db.organization.update({ where: { id }, - data: updateData, + data, select: { id: true, name: true, diff --git a/apps/api/src/organization/schemas/organization-api-bodies.ts b/apps/api/src/organization/schemas/organization-api-bodies.ts index 75957acfcc..e29e54a7ed 100644 --- a/apps/api/src/organization/schemas/organization-api-bodies.ts +++ b/apps/api/src/organization/schemas/organization-api-bodies.ts @@ -35,11 +35,6 @@ export const UPDATE_ORGANIZATION_BODY: ApiBodyOptions = { description: 'Whether onboarding is completed', example: true, }, - hasAccess: { - type: 'boolean', - description: 'Whether organization has access to the platform', - example: true, - }, fleetDmLabelId: { type: 'integer', description: 'FleetDM label ID for device management', @@ -66,6 +61,31 @@ export const UPDATE_ORGANIZATION_BODY: ApiBodyOptions = { 'Whether the background-check step is required during member onboarding. Set to false to turn off the "Require background checks" setting; true to require it.', example: true, }, + evidenceApprovalEnabled: { + type: 'boolean', + description: 'Whether evidence requires approval before it is accepted.', + example: false, + }, + deviceAgentStepEnabled: { + type: 'boolean', + description: 'Whether the device-agent step is enabled during member onboarding.', + example: true, + }, + securityTrainingStepEnabled: { + type: 'boolean', + description: 'Whether the security-training step is enabled during member onboarding.', + example: true, + }, + whistleblowerReportEnabled: { + type: 'boolean', + description: 'Whether the whistleblower reporting feature is enabled.', + example: true, + }, + accessRequestFormEnabled: { + type: 'boolean', + description: 'Whether the trust-portal access request form is enabled.', + example: true, + }, }, additionalProperties: false, }, diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 3c70ff27d6..5177c55ed3 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -42,9 +42,11 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }; } - // Check if user email domain is trycomp.ai + // Internal team accounts (verified @trycomp.ai) have access provisioned up front. const userEmail = session.user.email; - const isTryCompEmail = userEmail?.endsWith('@trycomp.ai') ?? false; + const isVerifiedTryCompEmail = + (userEmail?.endsWith('@trycomp.ai') ?? false) && + session.user.emailVerified === true; // Check if self-hosted const isSelfHosted = env.NEXT_PUBLIC_SELF_HOSTED === 'true'; @@ -129,9 +131,9 @@ export const createOrganizationMinimal = authActionClientWithoutOrg name: parsedInput.organizationName, website: parsedInput.website, onboardingCompleted: false, // Explicitly set to false - // Auto-enable for trycomp.ai emails, local development, or self-hosted instances + // Auto-enable for verified internal accounts, local development, or self-hosted instances ...((process.env.NEXT_PUBLIC_APP_ENV !== 'production' || - isTryCompEmail || + isVerifiedTryCompEmail || isSelfHosted) && { hasAccess: true, }), diff --git a/apps/app/src/app/(app)/setup/actions/create-organization.ts b/apps/app/src/app/(app)/setup/actions/create-organization.ts index 2557f201e8..0214448536 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization.ts @@ -35,9 +35,11 @@ export const createOrganization = authActionClientWithoutOrg }; } - // Check if user email domain is trycomp.ai + // Internal team accounts (verified @trycomp.ai) have access provisioned up front. const userEmail = session.user.email; - const isTryCompEmail = userEmail?.endsWith('@trycomp.ai') ?? false; + const isVerifiedTryCompEmail = + (userEmail?.endsWith('@trycomp.ai') ?? false) && + session.user.emailVerified === true; // Create a new organization directly in the database const randomSuffix = Math.floor(100000 + Math.random() * 900000).toString(); @@ -53,8 +55,9 @@ export const createOrganization = authActionClientWithoutOrg data: { name: parsedInput.organizationName, website: parsedInput.website, - // Auto-enable for trycomp.ai emails or local development - ...((process.env.NEXT_PUBLIC_APP_ENV !== 'production' || isTryCompEmail) && { + // Auto-enable for verified internal accounts or local development + ...((process.env.NEXT_PUBLIC_APP_ENV !== 'production' || + isVerifiedTryCompEmail) && { hasAccess: true, }), members: { diff --git a/packages/db/prisma/migrations/20260624120000_backfill_email_verified/migration.sql b/packages/db/prisma/migrations/20260624120000_backfill_email_verified/migration.sql new file mode 100644 index 0000000000..ca20abb335 --- /dev/null +++ b/packages/db/prisma/migrations/20260624120000_backfill_email_verified/migration.sql @@ -0,0 +1,12 @@ +-- Backfill emailVerified for all existing accounts so the new email-verification +-- requirement (emailAndPassword.requireEmailVerification) does not block any +-- current user from signing in. OAuth / magic-link / OTP users are already +-- verified; this covers the rest. +-- +-- DEPLOY ORDER: remove the spoofed test accounts (credential accounts created +-- on/after 2026-06-23) BEFORE this runs in production — otherwise they would +-- also be marked verified and regain the ability to sign in. + +UPDATE "User" +SET "emailVerified" = true +WHERE "emailVerified" = false; diff --git a/packages/email/emails/render.test.tsx b/packages/email/emails/render.test.tsx index 14503a2981..5acb76c904 100644 --- a/packages/email/emails/render.test.tsx +++ b/packages/email/emails/render.test.tsx @@ -4,6 +4,7 @@ import { AllPolicyNotificationEmail } from './all-policy-notification'; import { InviteEmail } from './invite'; import { InvitePortalEmail } from './invite-portal'; import { MagicLinkEmail } from './magic-link'; +import { VerifyEmail } from './verify-email'; import { WelcomeEmail } from './marketing/welcome'; import { OTPVerificationEmail } from './otp'; import { PolicyAcknowledgmentDigestEmail } from './policy-acknowledgment-digest'; @@ -115,6 +116,10 @@ const cases = [ ), }, { name: 'otp', el: }, + { + name: 'verify-email', + el: , + }, { name: 'training-completed', el: ( diff --git a/packages/email/emails/verify-email.tsx b/packages/email/emails/verify-email.tsx new file mode 100644 index 0000000000..5c63a631dd --- /dev/null +++ b/packages/email/emails/verify-email.tsx @@ -0,0 +1,76 @@ +import { + Body, + Button, + Container, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; + +interface Props { + email: string; + url: string; +} + +export const VerifyEmail = ({ email, url }: Props) => { + return ( + + + + Verify your email for Comp AI + + + + + + Verify your email for Comp AI + + + + Confirm your email address to finish setting up your Comp AI + account. + +
+ +
+ + + or copy and paste this URL into your browser{' '} + + {url} + + + +
+
+ + this verification link was intended for{' '} + {email}.{' '} + +
+ +
+ +