feat: Whitelist user's from workflow spam scanning (#20717)

* Add whitelistWorkflows to user

* Do not autolock whitelisted users

* Add endpoint to whitelist a user

* Get users data - include whitelistWorkflows

* Whitelist a user on the user admin page

* Fix typo

* Add test

* Use translations

* Add `updateWhitelistWorkflows` to `UserRepository`

* Use i18 for strings

* Rename to whitelistWorkflows

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
Joe Au-Yeung
2025-04-16 18:09:29 -04:00
committed by GitHub
co-authored by Benny Joo
parent 515f3263e0
commit a82d616b79
11 changed files with 110 additions and 2 deletions
@@ -3124,7 +3124,10 @@
"resend_in_seconds": "Resend in {{seconds}} seconds",
"resend": "Resend",
"verification_email_sent": "Verification email sent",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑",
"remove_whitelist_status": "Remove whitelist status",
"whitelist_user_workflows": "Whitelist user workflows",
"user_workflows_whitelisted": "User workflows whitelisted",
"user_workflows_unwhitelisted": "User workflows unwhitelisted",
"date_and_time": "Date & Time",
"email_reminder_upcoming_event_notice": "This is a reminder about your upcoming event.",
"email_reminder_triggered_by_workflow": "This reminder was triggered by a Workflow in Cal.",
@@ -3136,5 +3139,6 @@
"meeting_not_joined_question": "didn't join the meeting?",
"reschedule_cta_short": "Reschedule here.",
"you_and_conjunction": "You &",
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal."
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -122,6 +122,16 @@ function UsersTableBare() {
const verifyWorkflows = trpc.viewer.admin.verifyWorkflows.useMutation({
onSuccess: () => {
showToast("Workflows verified", "success");
utils.viewer.admin.listPaginated.invalidate();
},
});
const whitelistUserWorkflows = trpc.viewer.admin.whitelistUserWorkflows.useMutation({
onSuccess: (data) => {
showToast(
data.whitelistWorkflows ? t("user_workflows_whitelisted") : t("user_workflows_unwhitelisted"),
"success"
);
utils.viewer.admin.listPaginated.invalidate();
},
});
@@ -248,6 +258,19 @@ function UsersTableBare() {
onClick: () => verifyWorkflows.mutate({ userId: user.id }),
icon: "check",
},
{
id: "whitelist-user-workflows",
label: user.whitelistWorkflows
? t("remove_whitelist_status")
: t("whitelist_user_workflows"),
onClick: () => {
whitelistUserWorkflows.mutate({
userId: user.id,
whitelistWorkflows: !user.whitelistWorkflows,
});
},
icon: "check",
},
{
id: "impersonation",
label: "Impersonate",
@@ -172,4 +172,23 @@ describe("scanWorkflowBody", () => {
expect(scheduleWorkflowNotifications).not.toHaveBeenCalled();
});
it("should handle whitelisted user being flagged as spam", async () => {
const payload = JSON.stringify({
userId: 1,
workflowStepIds: [1],
});
prismaMock.workflowStep.findMany.mockResolvedValue([
{ ...mockWorkflowStep, workflow: { user: { whitelistWorkflows: true } } },
]);
prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow);
mockAkismetCheckSpam.mockResolvedValue(true);
await scanWorkflowBody(payload);
expect(mockAkismetCheckSpam).toHaveBeenCalled();
expect(prismaMock.workflowStep.update).not.toHaveBeenCalled();
expect(lockUser).not.toHaveBeenCalled();
});
});
@@ -40,6 +40,7 @@ export async function scanWorkflowBody(payload: string) {
select: {
locale: true,
timeFormat: true,
whitelistWorkflows: true,
},
},
},
@@ -100,6 +101,13 @@ export async function scanWorkflowBody(payload: string) {
const isSpam = await client.checkSpam(comment);
if (isSpam) {
if (workflowStep.workflow.user?.whitelistWorkflows) {
log.warn(
`For whitelisted user, workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`
);
return;
}
// We won't delete the workflow step incase it is flagged as a false positive
log.warn(`Workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`);
await lockUser("userId", userId.toString(), LockReason.SPAM_WORKFLOW_BODY);
+13
View File
@@ -927,4 +927,17 @@ export class UserRepository {
data: { metadata: { ...existingMetadata, stripeCustomerId } },
});
}
static async updateWhitelistWorkflows({
id,
whitelistWorkflows,
}: {
id: number;
whitelistWorkflows: boolean;
}) {
return prisma.user.update({
where: { id },
data: { whitelistWorkflows },
});
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "whitelistWorkflows" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -384,6 +384,7 @@ model User {
creationSource CreationSource?
createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings")
filterSegments FilterSegment[]
whitelistWorkflows Boolean @default(false)
@@unique([email])
@@unique([email, username])
@@ -8,6 +8,7 @@ import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema";
import { ZSetSMSLockState } from "./setSMSLockState.schema";
import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure";
import { ZAdminVerifyWorkflowsSchema } from "./verifyWorkflows.schema";
import { ZWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema";
import {
workspacePlatformCreateSchema,
workspacePlatformUpdateSchema,
@@ -55,6 +56,10 @@ export const adminRouter = router({
const { default: handler } = await import("./verifyWorkflows.handler");
return handler(opts);
}),
whitelistUserWorkflows: authedAdminProcedure.input(ZWhitelistUserWorkflows).mutation(async (opts) => {
const { default: handler } = await import("./whitelistUserWorkflows.handler");
return handler(opts);
}),
workspacePlatform: router({
list: authedAdminProcedure.query(async () => {
const { default: handler } = await import("./workspacePlatform/list.handler");
@@ -72,6 +72,7 @@ const listPaginatedHandler = async ({ input }: GetOptions) => {
username: true,
},
},
whitelistWorkflows: true,
},
});
@@ -0,0 +1,24 @@
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { TrpcSessionUser } from "../../../types";
import type { TWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TWhitelistUserWorkflows;
};
export const whitelistUserWorkflows = async ({ input }: GetOptions) => {
const { userId, whitelistWorkflows } = input;
const user = await UserRepository.updateWhitelistWorkflows({
id: userId,
whitelistWorkflows,
});
return { whitelistWorkflows: user.whitelistWorkflows };
};
export default whitelistUserWorkflows;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZWhitelistUserWorkflows = z.object({
userId: z.number(),
whitelistWorkflows: z.boolean(),
});
export type TWhitelistUserWorkflows = z.infer<typeof ZWhitelistUserWorkflows>;