From 50c7210d6b64dc70987b99b7ea4b160704d9af1f Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:13:33 +0400 Subject: [PATCH] fix: resolve signup watchlist review issues and auto-unlock on SIGNUP entry removal (#27923) * fix: resolve signup watchlist review issues with deleteEntry, email verification ordering, and unlock flow Co-Authored-By: ali@cal.com * fix: scope sendEmailVerification to non-invite signups only Co-Authored-By: ali@cal.com * feat: auto-unlock users when SIGNUP-source watchlist entries are removed Co-Authored-By: ali@cal.com * fix: remove PII from error logging in unlockSignupUser Co-Authored-By: ali@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../signup/handlers/calcomSignupHandler.ts | 19 +- .../di/watchlist/containers/watchlist.ts | 14 +- .../users/repositories/UserRepository.ts | 20 ++ .../GlobalWatchlistRepository.test.ts | 99 ++++++++ .../repository/GlobalWatchlistRepository.ts | 5 +- .../lib/repository/WatchlistRepository.ts | 13 +- .../AdminWatchlistOperationsService.test.ts | 221 ++++++++++++++++++ .../AdminWatchlistOperationsService.ts | 35 ++- .../admin/lockUserAccount.handler.test.ts | 134 +++++++++++ .../viewer/admin/lockUserAccount.handler.ts | 8 +- 10 files changed, 542 insertions(+), 26 deletions(-) create mode 100644 packages/features/watchlist/lib/repository/GlobalWatchlistRepository.test.ts create mode 100644 packages/features/watchlist/lib/service/AdminWatchlistOperationsService.test.ts create mode 100644 packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.test.ts diff --git a/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts b/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts index 2c0b63f0e1..79c0ef4517 100644 --- a/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts +++ b/apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts @@ -12,8 +12,8 @@ import { validateAndGetCorrectedUsernameForTeam, } from "@calcom/features/auth/signup/utils/token"; import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername"; -import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository"; +import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository"; import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry"; @@ -287,15 +287,6 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => { if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) { await prefillAvatar({ email }); } - // Only send verification email for non-premium usernames - // Premium usernames will get a magic link after payment in paymentCallback - if (!checkoutSessionId) { - sendEmailVerification({ - email, - language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())), - username: username || "", - }); - } } const featureRepository = getFeatureRepository(); @@ -326,6 +317,14 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => { ); } + if (!checkoutSessionId && !token) { + sendEmailVerification({ + email, + language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())), + username: username || "", + }); + } + if (checkoutSessionId) { console.log("Created user but missing payment", checkoutSessionId); return NextResponse.json( diff --git a/packages/features/di/watchlist/containers/watchlist.ts b/packages/features/di/watchlist/containers/watchlist.ts index 86091db4b8..fb5ec0b794 100644 --- a/packages/features/di/watchlist/containers/watchlist.ts +++ b/packages/features/di/watchlist/containers/watchlist.ts @@ -1,5 +1,3 @@ -import { createContainer } from "@evyweb/ioctopus"; - import { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"; import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service"; @@ -11,8 +9,10 @@ import { createWatchlistFeature, type WatchlistFeature, } from "@calcom/features/watchlist/lib/facade/WatchlistFeature"; -import type { IGlobalWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories"; -import type { IOrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories"; +import type { + IGlobalWatchlistRepository, + IOrganizationWatchlistRepository, +} from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories"; import { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository"; import { AdminWatchlistOperationsService } from "@calcom/features/watchlist/lib/service/AdminWatchlistOperationsService"; import { AdminWatchlistQueryService } from "@calcom/features/watchlist/lib/service/AdminWatchlistQueryService"; @@ -23,9 +23,9 @@ import { OrganizationWatchlistQueryService } from "@calcom/features/watchlist/li import type { WatchlistAuditService } from "@calcom/features/watchlist/lib/service/WatchlistAuditService"; import type { WatchlistService } from "@calcom/features/watchlist/lib/service/WatchlistService"; import { prisma } from "@calcom/prisma"; - -import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens"; +import { createContainer } from "@evyweb/ioctopus"; import { watchlistModule } from "../modules/Watchlist.module"; +import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens"; export const watchlistContainer = createContainer(); @@ -77,10 +77,12 @@ export async function getWatchlistFeature(): Promise { export function getAdminWatchlistOperationsService(): AdminWatchlistOperationsService { const watchlistRepo = new WatchlistRepository(prisma); const bookingReportRepo = new PrismaBookingReportRepository(prisma); + const userRepo = new UserRepository(prisma); return new AdminWatchlistOperationsService({ watchlistRepo, bookingReportRepo, + userRepo, }); } diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 8458b3876a..2cb3e61ffb 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1436,4 +1436,24 @@ export class UserRepository { data: { locked: true }, }); } + + async unlockByEmail({ + email, + }: { + email: string; + }): Promise<{ email: string; username: string | null } | null> { + const user = await this.prismaClient.user.findFirst({ + where: { email, locked: true }, + select: { id: true, email: true, username: true }, + }); + + if (!user) return null; + + await this.prismaClient.user.update({ + where: { id: user.id }, + data: { locked: false }, + }); + + return { email: user.email, username: user.username }; + } } diff --git a/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.test.ts b/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.test.ts new file mode 100644 index 0000000000..07d5711328 --- /dev/null +++ b/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.test.ts @@ -0,0 +1,99 @@ +import type { PrismaClient } from "@calcom/prisma/client"; +import { WatchlistAction, WatchlistSource, WatchlistType } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { type DeepMockProxy, mockDeep, mockReset } from "vitest-mock-extended"; +import { GlobalWatchlistRepository } from "./GlobalWatchlistRepository"; + +describe("GlobalWatchlistRepository", () => { + let prismaMock: DeepMockProxy; + let repo: GlobalWatchlistRepository; + + beforeEach(() => { + prismaMock = mockDeep(); + repo = new GlobalWatchlistRepository(prismaMock); + mockReset(prismaMock); + }); + + describe("deleteEntry", () => { + it("should await the prisma delete call", async () => { + const entryId = "test-entry-id"; + prismaMock.watchlist.delete.mockResolvedValue({} as never); + + await repo.deleteEntry(entryId); + + expect(prismaMock.watchlist.delete).toHaveBeenCalledWith({ + where: { id: entryId }, + }); + }); + + it("should propagate errors from prisma delete", async () => { + const entryId = "test-entry-id"; + prismaMock.watchlist.delete.mockRejectedValue(new Error("Record not found")); + + await expect(repo.deleteEntry(entryId)).rejects.toThrow("Record not found"); + }); + }); + + describe("createEntry", () => { + it("should use provided source instead of defaulting to MANUAL", async () => { + const mockEntry = { + id: "new-id", + type: WatchlistType.EMAIL, + value: "test@example.com", + description: "Auto-added during signup review", + isGlobal: true, + organizationId: null, + action: WatchlistAction.BLOCK, + source: WatchlistSource.SIGNUP, + lastUpdatedAt: new Date(), + }; + prismaMock.watchlist.create.mockResolvedValue(mockEntry as never); + + const result = await repo.createEntry({ + type: WatchlistType.EMAIL, + value: "test@example.com", + action: WatchlistAction.BLOCK, + source: WatchlistSource.SIGNUP, + description: "Auto-added during signup review", + }); + + expect(prismaMock.watchlist.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + source: WatchlistSource.SIGNUP, + }), + }) + ); + expect(result.source).toBe(WatchlistSource.SIGNUP); + }); + + it("should default to MANUAL when source is not provided", async () => { + const mockEntry = { + id: "new-id", + type: WatchlistType.EMAIL, + value: "test@example.com", + description: null, + isGlobal: true, + organizationId: null, + action: WatchlistAction.BLOCK, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + prismaMock.watchlist.create.mockResolvedValue(mockEntry as never); + + await repo.createEntry({ + type: WatchlistType.EMAIL, + value: "test@example.com", + action: WatchlistAction.BLOCK, + }); + + expect(prismaMock.watchlist.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + source: WatchlistSource.MANUAL, + }), + }) + ); + }); + }); +}); diff --git a/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts b/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts index 2a73c72e2a..83608ab031 100644 --- a/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts +++ b/packages/features/watchlist/lib/repository/GlobalWatchlistRepository.ts @@ -1,6 +1,5 @@ import type { PrismaClient, Watchlist } from "@calcom/prisma/client"; -import { WatchlistAction, WatchlistType, WatchlistSource } from "@calcom/prisma/enums"; - +import { WatchlistAction, WatchlistSource, WatchlistType } from "@calcom/prisma/enums"; import type { IGlobalWatchlistRepository } from "../interface/IWatchlistRepositories"; /** @@ -164,7 +163,7 @@ export class GlobalWatchlistRepository implements IGlobalWatchlistRepository { } async deleteEntry(id: string): Promise { - this.prisma.watchlist.delete({ + await this.prisma.watchlist.delete({ where: { id }, }); } diff --git a/packages/features/watchlist/lib/repository/WatchlistRepository.ts b/packages/features/watchlist/lib/repository/WatchlistRepository.ts index caff46857e..105f26818d 100644 --- a/packages/features/watchlist/lib/repository/WatchlistRepository.ts +++ b/packages/features/watchlist/lib/repository/WatchlistRepository.ts @@ -1,13 +1,12 @@ import type { PrismaClient } from "@calcom/prisma"; import { WatchlistAction, WatchlistSource } from "@calcom/prisma/enums"; - import type { - IWatchlistRepository, - CreateWatchlistInput, CheckWatchlistInput, - WatchlistEntry, + CreateWatchlistInput, FindAllEntriesInput, + IWatchlistRepository, WatchlistAuditEntry, + WatchlistEntry, } from "./IWatchlistRepository"; export class WatchlistRepository implements IWatchlistRepository { @@ -238,6 +237,9 @@ export class WatchlistRepository implements IWatchlistRepository { id: string; isGlobal: boolean; organizationId: number | null; + source: WatchlistSource; + type: WatchlistEntry["type"]; + value: string; }> > { return this.prismaClient.watchlist.findMany({ @@ -246,6 +248,9 @@ export class WatchlistRepository implements IWatchlistRepository { id: true, isGlobal: true, organizationId: true, + source: true, + type: true, + value: true, }, }); } diff --git a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.test.ts b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.test.ts new file mode 100644 index 0000000000..44dd051959 --- /dev/null +++ b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.test.ts @@ -0,0 +1,221 @@ +import { WatchlistAction, WatchlistSource, WatchlistType } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AdminWatchlistOperationsService } from "./AdminWatchlistOperationsService"; + +vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({ + sendEmailVerification: vi.fn().mockResolvedValue(undefined), +})); + +import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; + +function createMockWatchlistRepo() { + return { + findEntryWithAuditAndReports: vi.fn(), + deleteEntry: vi.fn().mockResolvedValue(undefined), + findEntriesByIds: vi.fn().mockResolvedValue([]), + bulkDeleteEntries: vi.fn().mockResolvedValue({ deleted: 0 }), + createEntry: vi.fn(), + createEntryIfNotExists: vi.fn(), + checkExists: vi.fn(), + findAllEntriesWithLatestAudit: vi.fn(), + createEntryFromReport: vi.fn(), + findOrgAndGlobalEntries: vi.fn(), + }; +} + +function createMockBookingReportRepo() { + return { + findPendingSystemReportsByEmail: vi.fn(), + findPendingSystemReportsByDomain: vi.fn(), + bulkLinkGlobalWatchlistWithSystemStatus: vi.fn(), + dismissSystemReportsByEmail: vi.fn(), + }; +} + +function createMockUserRepo() { + return { + unlockByEmail: vi.fn().mockResolvedValue(null), + }; +} + +function makeEntry(overrides: Record = {}) { + return { + id: "entry-1", + type: WatchlistType.EMAIL, + value: "test@example.com", + action: WatchlistAction.BLOCK, + description: null, + organizationId: null, + isGlobal: true, + source: WatchlistSource.SIGNUP, + lastUpdatedAt: new Date(), + ...overrides, + }; +} + +describe("AdminWatchlistOperationsService", () => { + let service: AdminWatchlistOperationsService; + let mockWatchlistRepo: ReturnType; + let mockBookingReportRepo: ReturnType; + let mockUserRepo: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockWatchlistRepo = createMockWatchlistRepo(); + mockBookingReportRepo = createMockBookingReportRepo(); + mockUserRepo = createMockUserRepo(); + + service = new AdminWatchlistOperationsService({ + watchlistRepo: mockWatchlistRepo as never, + bookingReportRepo: mockBookingReportRepo as never, + userRepo: mockUserRepo as never, + }); + }); + + describe("deleteWatchlistEntry - auto-unlock", () => { + test("should unlock user and send verification email when deleting SIGNUP email entry", async () => { + const entry = makeEntry({ source: WatchlistSource.SIGNUP, type: WatchlistType.EMAIL }); + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] }); + mockUserRepo.unlockByEmail.mockResolvedValue({ email: "test@example.com", username: "testuser" }); + + await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 }); + + expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-1", 1); + expect(mockUserRepo.unlockByEmail).toHaveBeenCalledWith({ email: "test@example.com" }); + expect(sendEmailVerification).toHaveBeenCalledWith({ + email: "test@example.com", + username: "testuser", + }); + }); + + test("should not unlock user when deleting MANUAL source entry", async () => { + const entry = makeEntry({ source: WatchlistSource.MANUAL, type: WatchlistType.EMAIL }); + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] }); + + await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 }); + + expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-1", 1); + expect(mockUserRepo.unlockByEmail).not.toHaveBeenCalled(); + expect(sendEmailVerification).not.toHaveBeenCalled(); + }); + + test("should not unlock user when deleting SIGNUP domain entry", async () => { + const entry = makeEntry({ + source: WatchlistSource.SIGNUP, + type: WatchlistType.DOMAIN, + value: "example.com", + }); + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] }); + + await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 }); + + expect(mockUserRepo.unlockByEmail).not.toHaveBeenCalled(); + }); + + test("should not send verification email when user is not locked", async () => { + const entry = makeEntry({ source: WatchlistSource.SIGNUP, type: WatchlistType.EMAIL }); + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] }); + mockUserRepo.unlockByEmail.mockResolvedValue(null); + + await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 }); + + expect(mockUserRepo.unlockByEmail).toHaveBeenCalledWith({ email: "test@example.com" }); + expect(sendEmailVerification).not.toHaveBeenCalled(); + }); + + test("should not throw if unlock fails", async () => { + const entry = makeEntry({ source: WatchlistSource.SIGNUP, type: WatchlistType.EMAIL }); + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] }); + mockUserRepo.unlockByEmail.mockRejectedValue(new Error("db error")); + + const result = await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 }); + + expect(result.success).toBe(true); + }); + }); + + describe("bulkDeleteWatchlistEntries - auto-unlock", () => { + test("should unlock users for SIGNUP email entries in bulk delete", async () => { + const entries = [ + { + id: "e1", + isGlobal: true, + organizationId: null, + source: WatchlistSource.SIGNUP, + type: WatchlistType.EMAIL, + value: "user1@example.com", + }, + { + id: "e2", + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + type: WatchlistType.EMAIL, + value: "user2@example.com", + }, + ]; + mockWatchlistRepo.findEntriesByIds.mockResolvedValue(entries); + mockWatchlistRepo.bulkDeleteEntries.mockResolvedValue({ deleted: 2 }); + mockUserRepo.unlockByEmail.mockResolvedValue({ email: "user1@example.com", username: "user1" }); + + await service.bulkDeleteWatchlistEntries({ entryIds: ["e1", "e2"], userId: 1 }); + + expect(mockUserRepo.unlockByEmail).toHaveBeenCalledTimes(1); + expect(mockUserRepo.unlockByEmail).toHaveBeenCalledWith({ email: "user1@example.com" }); + expect(sendEmailVerification).toHaveBeenCalledWith({ + email: "user1@example.com", + username: "user1", + }); + }); + + test("should not unlock users for non-SIGNUP entries in bulk delete", async () => { + const entries = [ + { + id: "e1", + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + type: WatchlistType.EMAIL, + value: "user@example.com", + }, + ]; + mockWatchlistRepo.findEntriesByIds.mockResolvedValue(entries); + mockWatchlistRepo.bulkDeleteEntries.mockResolvedValue({ deleted: 1 }); + + await service.bulkDeleteWatchlistEntries({ entryIds: ["e1"], userId: 1 }); + + expect(mockUserRepo.unlockByEmail).not.toHaveBeenCalled(); + }); + + test("should handle multiple SIGNUP entries in bulk delete", async () => { + const entries = [ + { + id: "e1", + isGlobal: true, + organizationId: null, + source: WatchlistSource.SIGNUP, + type: WatchlistType.EMAIL, + value: "user1@example.com", + }, + { + id: "e2", + isGlobal: true, + organizationId: null, + source: WatchlistSource.SIGNUP, + type: WatchlistType.EMAIL, + value: "user2@example.com", + }, + ]; + mockWatchlistRepo.findEntriesByIds.mockResolvedValue(entries); + mockWatchlistRepo.bulkDeleteEntries.mockResolvedValue({ deleted: 2 }); + mockUserRepo.unlockByEmail + .mockResolvedValueOnce({ email: "user1@example.com", username: "user1" }) + .mockResolvedValueOnce({ email: "user2@example.com", username: "user2" }); + + await service.bulkDeleteWatchlistEntries({ entryIds: ["e1", "e2"], userId: 1 }); + + expect(mockUserRepo.unlockByEmail).toHaveBeenCalledTimes(2); + expect(sendEmailVerification).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts index 588c53232d..bbde16fcd7 100644 --- a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts +++ b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts @@ -1,8 +1,9 @@ +import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import type { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"; +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository"; import logger from "@calcom/lib/logger"; -import { SystemReportStatus, WatchlistType } from "@calcom/prisma/enums"; - +import { SystemReportStatus, WatchlistSource, WatchlistType } from "@calcom/prisma/enums"; import { WatchlistErrors } from "../errors/WatchlistErrors"; import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization"; import type { @@ -53,13 +54,16 @@ export interface BulkDismissReportsByEmailInput { type Deps = { watchlistRepo: WatchlistRepository; bookingReportRepo: PrismaBookingReportRepository; + userRepo: UserRepository; }; export class AdminWatchlistOperationsService extends WatchlistOperationsService { private adminLog = logger.getSubLogger({ prefix: ["AdminWatchlistOperationsService"] }); + private userRepo: UserRepository; constructor(deps: Deps) { super(deps); + this.userRepo = deps.userRepo; } protected getScope(): WatchlistOperationsScope { @@ -94,6 +98,13 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService validIds.push(id); } + const signupEmailEntries = validIds + .map((id) => entryMap.get(id)) + .filter( + (e): e is NonNullable => + !!e && e.source === WatchlistSource.SIGNUP && e.type === WatchlistType.EMAIL + ); + let successCount = 0; if (validIds.length > 0) { const result = await this.deps.watchlistRepo.bulkDeleteEntries({ @@ -103,6 +114,8 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService successCount = result.deleted; } + await Promise.all(signupEmailEntries.map((entry) => this.unlockSignupUser(entry.value))); + if (successCount === 0 && failed.length > 0) { this.adminLog.error("Bulk delete watchlist entries failures", { failed }); throw WatchlistErrors.bulkDeletePartialFailure(`Failed to delete all entries: ${failed[0].reason}`); @@ -131,6 +144,10 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService await this.deps.watchlistRepo.deleteEntry(input.entryId, input.userId); + if (entry.source === WatchlistSource.SIGNUP && entry.type === WatchlistType.EMAIL) { + await this.unlockSignupUser(entry.value); + } + return { success: true, message: "Entry deleted successfully", @@ -194,6 +211,20 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService return { success: true, count: result.count }; } + private async unlockSignupUser(email: string): Promise { + try { + const user = await this.userRepo.unlockByEmail({ email }); + if (user) { + await sendEmailVerification({ + email: user.email, + username: user.username || "", + }); + } + } catch (error) { + this.adminLog.error("Failed to auto-unlock user after SIGNUP watchlist removal", { error }); + } + } + async bulkDismissReportsByEmail(input: BulkDismissReportsByEmailInput): Promise { const results = await Promise.all( input.emails.map(async (email) => { diff --git a/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.test.ts b/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.test.ts new file mode 100644 index 0000000000..548b7fdc50 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSendEmailVerification = vi.fn().mockResolvedValue({ ok: true, skipped: false }); +const mockFindBlockedEmail = vi.fn(); +const mockDeleteEntry = vi.fn(); + +vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({ + sendEmailVerification: (...args: unknown[]) => mockSendEmailVerification(...args), +})); +vi.mock("@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository", () => ({ + GlobalWatchlistRepository: class { + findBlockedEmail = mockFindBlockedEmail; + deleteEntry = mockDeleteEntry; + }, +})); +vi.mock("@calcom/features/watchlist/lib/utils/normalization", () => ({ + normalizeEmail: vi.fn((e: string) => e.toLowerCase()), +})); +vi.mock("@calcom/prisma", () => { + const mockPrisma = { + user: { + update: vi.fn(), + }, + }; + return { default: mockPrisma, prisma: mockPrisma }; +}); + +import { prisma } from "@calcom/prisma"; +import lockUserAccountHandler from "./lockUserAccount.handler"; + +describe("lockUserAccountHandler", () => { + const mockUser = { + id: 1, + email: "admin@example.com", + organizationId: null, + profile: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: 42, + email: "test@example.com", + username: "testuser", + } as never); + mockFindBlockedEmail.mockResolvedValue(null); + mockDeleteEntry.mockResolvedValue(undefined); + mockSendEmailVerification.mockResolvedValue({ ok: true, skipped: false }); + }); + + describe("unlocking a user", () => { + it("should remove watchlist entry when unlocking a user", async () => { + const watchlistEntry = { id: "watchlist-entry-123" }; + mockFindBlockedEmail.mockResolvedValue(watchlistEntry); + + await lockUserAccountHandler({ + ctx: { user: mockUser as never }, + input: { userId: 42, locked: false }, + }); + + expect(mockDeleteEntry).toHaveBeenCalledWith("watchlist-entry-123"); + }); + + it("should send verification email after unlocking and removing from watchlist", async () => { + const watchlistEntry = { id: "watchlist-entry-123" }; + mockFindBlockedEmail.mockResolvedValue(watchlistEntry); + + await lockUserAccountHandler({ + ctx: { user: mockUser as never }, + input: { userId: 42, locked: false }, + }); + + expect(mockSendEmailVerification).toHaveBeenCalledWith({ + email: "test@example.com", + username: "testuser", + }); + }); + + it("should send verification email even when no watchlist entry exists", async () => { + mockFindBlockedEmail.mockResolvedValue(null); + + await lockUserAccountHandler({ + ctx: { user: mockUser as never }, + input: { userId: 42, locked: false }, + }); + + expect(mockDeleteEntry).not.toHaveBeenCalled(); + expect(mockSendEmailVerification).toHaveBeenCalledWith({ + email: "test@example.com", + username: "testuser", + }); + }); + + it("should call deleteEntry before sendEmailVerification", async () => { + const callOrder: string[] = []; + mockFindBlockedEmail.mockResolvedValue({ id: "entry-1" }); + mockDeleteEntry.mockImplementation(async () => { + callOrder.push("deleteEntry"); + }); + mockSendEmailVerification.mockImplementation(async () => { + callOrder.push("sendEmailVerification"); + return { ok: true, skipped: false }; + }); + + await lockUserAccountHandler({ + ctx: { user: mockUser as never }, + input: { userId: 42, locked: false }, + }); + + expect(callOrder).toEqual(["deleteEntry", "sendEmailVerification"]); + }); + }); + + describe("locking a user", () => { + it("should not remove watchlist entry when locking a user", async () => { + await lockUserAccountHandler({ + ctx: { user: mockUser as never }, + input: { userId: 42, locked: true }, + }); + + expect(mockFindBlockedEmail).not.toHaveBeenCalled(); + expect(mockDeleteEntry).not.toHaveBeenCalled(); + }); + + it("should not send verification email when locking a user", async () => { + await lockUserAccountHandler({ + ctx: { user: mockUser as never }, + input: { userId: 42, locked: true }, + }); + + expect(mockSendEmailVerification).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts b/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts index e2ba7b7063..6ba7186071 100644 --- a/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts @@ -1,7 +1,7 @@ +import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository"; import { normalizeEmail } from "@calcom/features/watchlist/lib/utils/normalization"; import { prisma } from "@calcom/prisma"; - import type { TrpcSessionUser } from "../../../types"; import type { TAdminLockUserAccountSchema } from "./lockUserAccount.schema"; @@ -25,6 +25,7 @@ const lockUserAccountHandler = async ({ input }: GetOptions) => { select: { id: true, email: true, + username: true, }, }); @@ -39,6 +40,11 @@ const lockUserAccountHandler = async ({ input }: GetOptions) => { if (watchlistEntry) { await globalWatchlistRepo.deleteEntry(watchlistEntry.id); } + + await sendEmailVerification({ + email: user.email, + username: user.username || "", + }); } return {