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
18 changes: 18 additions & 0 deletions apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '../config/load-env';
import {
ChangeEmailConfirmationEmail,
MagicLinkEmail,
OTPVerificationEmail,
VerifyEmail,
Expand Down Expand Up @@ -572,6 +573,23 @@ export const auth = betterAuth({
socialProviders,
user: {
modelName: 'User',
changeEmail: {
enabled: true,
// Double opt-in: this link goes to the CURRENT address; confirming it
// makes better-auth send a verification link to the NEW address (via
// emailVerification.sendVerificationEmail), which applies the change.
sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
await triggerEmail({
to: user.email,
subject: 'Confirm your email change for Comp AI',
react: ChangeEmailConfirmationEmail({
currentEmail: user.email,
newEmail,
url,
}),
});
},
},
},
organization: {
modelName: 'Organization',
Expand Down
80 changes: 80 additions & 0 deletions apps/api/src/email/templates/login-email-changed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
Body,
Container,
Font,
Heading,
Html,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import { Footer } from '../components/footer';
import { Logo } from '../components/logo';

interface Props {
organizationName: string;
oldEmail: string;
newEmail: string;
}

export const LoginEmailChangedEmail = ({ organizationName, oldEmail, newEmail }: Props) => {
return (
<Html>
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={500}
fontStyle="normal"
/>
</head>
<Preview>Your Comp AI login email was changed</Preview>

<Body className="mx-auto my-auto bg-[#fff] font-sans">
<Container
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
style={{ borderStyle: 'solid', borderWidth: 1 }}
>
<Logo />
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
Your login email was changed
</Heading>

<Text className="text-[14px] leading-[24px] text-[#121212]">
An administrator of <span className="font-medium">{organizationName}</span>{' '}
changed your Comp AI login email from{' '}
<span className="font-medium">{oldEmail}</span> to{' '}
<span className="font-medium">{newEmail}</span>.
</Text>
<Text className="text-[14px] leading-[24px] text-[#121212]">
From now on, use <span className="font-medium">{newEmail}</span> to
sign in.
</Text>

<br />
<Section>
<Text className="text-[12px] leading-[24px] text-[#666666]">
If you did not expect this change, contact your organization
administrator or support@trycomp.ai.
</Text>
</Section>

<br />

<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};

export default LoginEmailChangedEmail;
1 change: 1 addition & 0 deletions apps/api/src/people/people.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ export class PeopleController {
@ApiResponse(UPDATE_MEMBER_RESPONSES[400])
@ApiResponse(UPDATE_MEMBER_RESPONSES[401])
@ApiResponse(UPDATE_MEMBER_RESPONSES[404])
@ApiResponse(UPDATE_MEMBER_RESPONSES[409])
@ApiResponse(UPDATE_MEMBER_RESPONSES[500])
async updateMember(
@Param('id') memberId: string,
Expand Down
158 changes: 156 additions & 2 deletions apps/api/src/people/people.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@ import {
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { PeopleService } from './people.service';
import { FleetService } from '../lib/fleet.service';
import { TimelinesService } from '../timelines/timelines.service';
import { MemberValidator } from './utils/member-validator';
import { MemberQueries } from './utils/member-queries';

// Mock the database
// Mock the database. Includes a stand-in Prisma.PrismaClientKnownRequestError
// so the service's `instanceof` error-code branches can be exercised.
jest.mock('@db', () => ({
Prisma: {
PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error {
code: string;
constructor(message: string, { code }: { code: string }) {
super(message);
this.code = code;
this.name = 'PrismaClientKnownRequestError';
}
},
},
db: {
member: {
findFirst: jest.fn(),
Expand Down Expand Up @@ -86,8 +98,13 @@ jest.mock('@trycompai/email', () => ({

jest.mock('./utils/member-validator');
jest.mock('./utils/member-queries');
jest.mock('./utils/login-email-change');

import { db } from '@db';
import { db, Prisma } from '@db';
import {
notifyLoginEmailChanged,
validateLoginEmailChange,
} from './utils/login-email-change';

describe('PeopleService', () => {
let service: PeopleService;
Expand Down Expand Up @@ -298,6 +315,143 @@ describe('PeopleService', () => {
);
});

describe('login email change', () => {
const existingMember = {
id: 'mem_1',
userId: 'usr_target',
role: 'employee',
};
const updatedMember = {
id: 'mem_1',
user: { name: 'Alice' },
role: 'employee',
};

beforeEach(() => {
(MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
undefined,
);
(MemberValidator.validateMemberExists as jest.Mock).mockResolvedValue(
existingMember,
);
(MemberQueries.updateMember as jest.Mock).mockResolvedValue(
updatedMember,
);
});

it('applies the normalized email and notifies both addresses', async () => {
(validateLoginEmailChange as jest.Mock).mockResolvedValue({
oldEmail: 'old@company.dev',
newEmail: 'new@company.io',
});

await service.updateById('mem_1', 'org_123', {
email: ' New@Company.IO ',
});

expect(validateLoginEmailChange).toHaveBeenCalledWith({
userId: 'usr_target',
organizationId: 'org_123',
requestedEmail: ' New@Company.IO ',
});
expect(MemberQueries.updateMember).toHaveBeenCalledWith(
'mem_1',
'org_123',
{ email: 'new@company.io' },
);
expect(notifyLoginEmailChanged).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: 'org_123',
change: {
oldEmail: 'old@company.dev',
newEmail: 'new@company.io',
},
}),
);
});

it('drops a no-op email change and does not notify', async () => {
(validateLoginEmailChange as jest.Mock).mockResolvedValue(null);

await service.updateById('mem_1', 'org_123', {
email: 'old@company.dev',
department: 'it',
});

expect(MemberQueries.updateMember).toHaveBeenCalledWith(
'mem_1',
'org_123',
{ department: 'it' },
);
expect(notifyLoginEmailChanged).not.toHaveBeenCalled();
});

it('rejects combining a userId reassignment with an email change', async () => {
await expect(
service.updateById('mem_1', 'org_123', {
userId: 'usr_other',
email: 'new@company.io',
}),
).rejects.toThrow(BadRequestException);

expect(validateLoginEmailChange).not.toHaveBeenCalled();
expect(MemberQueries.updateMember).not.toHaveBeenCalled();
});

it('returns the member without writing when the no-op email is the only field', async () => {
(validateLoginEmailChange as jest.Mock).mockResolvedValue(null);
(MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
updatedMember,
);

const result = await service.updateById('mem_1', 'org_123', {
email: 'old@company.dev',
});

expect(result).toEqual(updatedMember);
expect(MemberQueries.updateMember).not.toHaveBeenCalled();
// Must include deactivated members — the update path accepts them,
// so the no-op return can't silently 404 on a deactivated member.
expect(MemberQueries.findByIdInOrganization).toHaveBeenCalledWith(
'mem_1',
'org_123',
{ includeDeactivated: true },
);
});

it('translates a unique-constraint race on the write into a 409', async () => {
(validateLoginEmailChange as jest.Mock).mockResolvedValue({
oldEmail: 'old@company.dev',
newEmail: 'new@company.io',
});
(MemberQueries.updateMember as jest.Mock).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('Unique constraint failed', {
code: 'P2002',
clientVersion: 'test',
}),
);

await expect(
service.updateById('mem_1', 'org_123', { email: 'new@company.io' }),
).rejects.toThrow(ConflictException);
});

it('propagates ConflictException from validation without wrapping', async () => {
(validateLoginEmailChange as jest.Mock).mockRejectedValue(
new ConflictException('That email is already used by another account'),
);

await expect(
service.updateById('mem_1', 'org_123', {
email: 'taken@company.io',
}),
).rejects.toThrow(ConflictException);

expect(MemberQueries.updateMember).not.toHaveBeenCalled();
expect(notifyLoginEmailChanged).not.toHaveBeenCalled();
});
});

it('should validate new userId when changing user', async () => {
const updateData = { userId: 'usr_new' };
const existingMember = {
Expand Down
Loading
Loading