Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ jest.mock('@db', () => ({
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findFirst: jest.fn(),
},
},
}));

Expand All @@ -16,6 +19,9 @@ const mockedDb = db as unknown as {
findUnique: jest.Mock;
update: jest.Mock;
};
user: {
findFirst: jest.Mock;
};
};

const buildService = (
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 21 additions & 4 deletions apps/api/src/organization-access/organization-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -106,6 +112,17 @@ export class OrganizationAccessService {
return { hasAccess: false, autoApproved: false, reason: 'not-eligible' };
}

private async isEmailVerified(email: string | undefined): Promise<boolean> {
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<void> {
await db.organization.update({
where: { id: organizationId },
Expand Down
66 changes: 64 additions & 2 deletions apps/api/src/organization/dto/update-organization.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion apps/api/src/organization/organization.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
95 changes: 95 additions & 0 deletions apps/api/src/organization/organization.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 22 additions & 2 deletions apps/api/src/organization/organization.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 25 additions & 5 deletions apps/api/src/organization/schemas/organization-api-bodies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
},
Expand Down
Loading
Loading