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
120 changes: 120 additions & 0 deletions apps/api/src/people/people-invite.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jest.mock('@db', () => ({
user: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
member: {
findFirst: jest.fn(),
Expand Down Expand Up @@ -324,6 +325,125 @@ describe('PeopleInviteService', () => {
);
});

it('creates invited employees with a verified email so trusted SSO providers can link', async () => {
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
name: 'Test Org',
});
(mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.user.create as jest.Mock).mockResolvedValue({
id: 'user_new',
email: 'emp@example.com',
});
(mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.member.create as jest.Mock).mockResolvedValue({
id: 'member_new',
});
(
mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock
).mockResolvedValue({
count: 5,
});

const results = await service.inviteMembers({
...baseParams,
invites: [{ email: 'emp@example.com', roles: ['employee'] }],
});

expect(results[0].success).toBe(true);
// An unverified local user makes better-auth refuse to link trusted
// OAuth providers (account_not_linked), stranding invited employees at
// the portal sign-in page, so new users must be created verified.
expect(mockDb.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({ emailVerified: true }),
});
});

it('upgrades a legacy unverified user to verified when re-invited as an employee', async () => {
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
name: 'Test Org',
});
// Legacy row: created before employees were created verified, and no
// longer a member anywhere, so the one-time member backfill missed it.
(mockDb.user.findFirst as jest.Mock).mockResolvedValue({
id: 'user_legacy',
email: 'emp@example.com',
emailVerified: false,
});
(mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.member.create as jest.Mock).mockResolvedValue({
id: 'member_new',
});
(
mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock
).mockResolvedValue({ count: 5 });

const results = await service.inviteMembers({
...baseParams,
invites: [{ email: 'emp@example.com', roles: ['employee'] }],
});

expect(results[0].success).toBe(true);
expect(mockDb.user.update).toHaveBeenCalledWith({
where: { id: 'user_legacy' },
data: { emailVerified: true },
});
});

it('does not touch an already-verified user when re-invited', async () => {
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
name: 'Test Org',
});
(mockDb.user.findFirst as jest.Mock).mockResolvedValue({
id: 'user_verified',
email: 'emp@example.com',
emailVerified: true,
});
(mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.member.create as jest.Mock).mockResolvedValue({
id: 'member_new',
});
(
mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock
).mockResolvedValue({ count: 5 });

const results = await service.inviteMembers({
...baseParams,
invites: [{ email: 'emp@example.com', roles: ['employee'] }],
});

expect(results[0].success).toBe(true);
expect(mockDb.user.update).not.toHaveBeenCalled();
});

it('upgrades a legacy unverified user to verified when invited to an admin role', async () => {
(mockDb.user.findFirst as jest.Mock).mockResolvedValue({
id: 'user_legacy',
email: 'admin@example.com',
emailVerified: false,
});
(mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
name: 'Test Org',
});
(mockDb.invitation.create as jest.Mock).mockResolvedValue({
id: 'inv_new',
});

const results = await service.inviteMembers({
...baseParams,
invites: [{ email: 'admin@example.com', roles: ['admin'] }],
});

expect(results[0].success).toBe(true);
expect(mockDb.invitation.create).toHaveBeenCalled();
// The invitee signs in to accept; an unverified row would block linking
// a Google/Microsoft sign-in to it (account_not_linked).
expect(mockDb.user.update).toHaveBeenCalledWith({
where: { id: 'user_legacy' },
data: { emailVerified: true },
});
});

it('should reactivate deactivated members', async () => {
(mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
name: 'Test Org',
Expand Down
40 changes: 39 additions & 1 deletion apps/api/src/people/people-invite.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,20 @@ export class PeopleInviteService {
});

if (!existingUser) {
// Mark the email verified up front: the address is admin-provided, and
// every sign-in method (OTP, magic link, trusted OAuth) proves mailbox
// ownership anyway. An unverified user row makes better-auth refuse to
// link Google/Microsoft sign-ins to it (account_not_linked), which
// strands invited employees at the portal sign-in page.
const newUser = await db.user.create({
data: { emailVerified: false, email, name: email.split('@')[0] },
data: { emailVerified: true, email, name: email.split('@')[0] },
});
userId = newUser.id;
} else {
// Legacy rows may predate the created-verified behavior (and the member
// backfill only covered users who were members at the time), so upgrade
// on re-invite too.
await this.ensureEmailVerified(existingUser);
}

const finalUserId = existingUser?.id ?? userId;
Expand Down Expand Up @@ -250,6 +260,29 @@ export class PeopleInviteService {
return { emailSent };
}

/**
* Upgrade a legacy unverified user row to verified when (re-)inviting them.
*
* Invited addresses are admin-provided, and every sign-in method (email OTP,
* magic link, trusted OAuth) proves mailbox ownership before a session is
* issued, so the flag grants nothing to anyone who cannot already receive
* mail at the address. Without it, better-auth refuses to link a
* Google/Microsoft sign-in to the existing row (account_not_linked). No-op
* when already verified.
*/
private async ensureEmailVerified(user: {
id: string;
emailVerified: boolean;
}): Promise<void> {
if (user.emailVerified) {
return;
}
await db.user.update({
where: { id: user.id },
data: { emailVerified: true },
});
}

private async inviteWithCheck(params: {
email: string;
roles: string[];
Expand All @@ -272,6 +305,11 @@ export class PeopleInviteService {
});

if (existingUser) {
// Same rationale as the employee path: an unverified legacy row would
// keep blocking Google/Microsoft sign-in linking (account_not_linked)
// when this invitee goes to accept.
await this.ensureEmailVerified(existingUser);

const existingMember = await db.member.findFirst({
where: { userId: existingUser.id, organizationId },
});
Expand Down
57 changes: 0 additions & 57 deletions apps/app/src/actions/people/create-employee-action.ts

This file was deleted.

9 changes: 0 additions & 9 deletions apps/app/src/actions/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
CommentEntityType,
Departments,
Frequency,
Impact,
Likelihood,
Expand Down Expand Up @@ -283,14 +282,6 @@ export const assistantSettingsSchema = z.object({
enabled: z.boolean().optional(),
});

export const createEmployeeSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
department: z.nativeEnum(Departments, { error: 'Department is required' }),
externalEmployeeId: z.string().optional(),
isActive: z.boolean().default(true),
});

export const updatePolicyOverviewSchema = z.object({
id: z.string(),
title: z.string(),
Expand Down
Loading
Loading