diff --git a/packages/features/auth/lib/ErrorCode.ts b/packages/features/auth/lib/ErrorCode.ts index c4f86af26b..4f5bdd435b 100644 --- a/packages/features/auth/lib/ErrorCode.ts +++ b/packages/features/auth/lib/ErrorCode.ts @@ -16,4 +16,5 @@ export enum ErrorCode { ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled", RateLimitExceeded = "rate-limit-exceeded", SocialIdentityProviderRequired = "social-identity-provider-required", + UserAccountLocked = "user-account-locked", } diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 97cc306065..ebe299571f 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -105,6 +105,7 @@ const providers: Provider[] = [ email: credentials.email.toLowerCase(), }, select: { + locked: true, role: true, id: true, username: true, @@ -136,6 +137,11 @@ const providers: Provider[] = [ throw new Error(ErrorCode.IncorrectEmailPassword); } + // Locked users cannot login + if (user.locked) { + throw new Error(ErrorCode.UserAccountLocked); + } + await checkRateLimitAndThrowError({ identifier: user.email, }); diff --git a/packages/features/ee/users/components/UsersTable.tsx b/packages/features/ee/users/components/UsersTable.tsx index 96340689a0..e4af526cbd 100644 --- a/packages/features/ee/users/components/UsersTable.tsx +++ b/packages/features/ee/users/components/UsersTable.tsx @@ -76,6 +76,31 @@ function UsersTableBare() { }, }); + const lockUserAccount = trpc.viewer.admin.lockUserAccount.useMutation({ + onSuccess: ({ userId, locked }) => { + showToast(locked ? "User was locked" : "User was unlocked", "success"); + utils.viewer.admin.listPaginated.setInfiniteData({ limit: FETCH_LIMIT }, (cachedData) => { + if (!cachedData) { + return { + pages: [], + pageParams: [], + }; + } + return { + ...cachedData, + pages: cachedData.pages.map((page) => ({ + ...page, + rows: page.rows.map((row) => { + const newUser = row; + if (row.id === userId) newUser.locked = locked; + return newUser; + }), + })), + }; + }); + }, + }); + //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]); const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; @@ -140,6 +165,11 @@ function UsersTableBare() {
{user.name} /{user.username} + {user.locked && ( + + + + )}
{user.email}
@@ -167,6 +197,12 @@ function UsersTableBare() { onClick: () => sendPasswordResetEmail.mutate({ userId: user.id }), icon: Lock, }, + { + id: "lock-user", + label: user.locked ? "Unlock User Account" : "Lock User Account", + onClick: () => lockUserAccount.mutate({ userId: user.id, locked: !user.locked }), + icon: Lock, + }, { id: "delete", label: "Delete", diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index bd9e9fc516..e9eb598659 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -182,6 +182,7 @@ type UserPayload = Prisma.UserGetPayload<{ }>; export const buildUser = >(user?: T): UserPayload => { return { + locked: false, name: faker.name.firstName(), email: faker.internet.email(), timeZone: faker.address.timeZone(), diff --git a/packages/prisma/migrations/20231117002911_add_users_locked/migration.sql b/packages/prisma/migrations/20231117002911_add_users_locked/migration.sql new file mode 100644 index 0000000000..94f7ebc2c8 --- /dev/null +++ b/packages/prisma/migrations/20231117002911_add_users_locked/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "locked" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 004fcc3147..3130a9d423 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -259,6 +259,9 @@ model User { //linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade) //linkedUsers User[] @relation("linked_account")*/ + // Used to lock the user account + locked Boolean @default(false) + @@unique([email]) @@unique([email, username]) @@unique([username, organizationId]) diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index 2a9e072bd8..46b55b6450 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -20,6 +20,8 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe `${NAMESPACE}.${s}`; export const adminRouter = router({ - listPaginated: authedAdminProcedure.input(ZListMembersSchema).query(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.listPaginated) { - UNSTABLE_HANDLER_CACHE.listPaginated = await import("./listPaginated.handler").then( - (mod) => mod.listPaginatedHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.listPaginated) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.listPaginated({ - ctx, - input, - }); + listPaginated: authedAdminProcedure.input(ZListMembersSchema).query(async (opts) => { + const handler = await importHandler(namespaced("listPaginated"), () => import("./listPaginated.handler")); + return handler(opts); + }), + sendPasswordReset: authedAdminProcedure.input(ZAdminPasswordResetSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("sendPasswordReset"), + () => import("./sendPasswordReset.handler") + ); + return handler(opts); + }), + lockUserAccount: authedAdminProcedure.input(ZAdminLockUserAccountSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("lockUserAccount"), + () => import("./lockUserAccount.handler") + ); + return handler(opts); }), - sendPasswordReset: authedAdminProcedure - .input(ZAdminPasswordResetSchema) - .mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.sendPasswordReset) { - UNSTABLE_HANDLER_CACHE.sendPasswordReset = await import("./sendPasswordReset.handler").then( - (mod) => mod.sendPasswordResetHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.sendPasswordReset) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.sendPasswordReset({ - ctx, - input, - }); - }), toggleFeatureFlag: authedAdminProcedure .input(z.object({ slug: z.string(), enabled: z.boolean() })) .mutation(({ ctx, input }) => { diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts index 59924347e0..d0b43d918a 100644 --- a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts @@ -11,7 +11,7 @@ type GetOptions = { input: TListMembersSchema; }; -export const listPaginatedHandler = async ({ input }: GetOptions) => { +const listPaginatedHandler = async ({ input }: GetOptions) => { const { cursor, limit, searchTerm } = input; const getTotalUsers = await prisma.user.count(); @@ -44,6 +44,7 @@ export const listPaginatedHandler = async ({ input }: GetOptions) => { }, select: { id: true, + locked: true, email: true, username: true, name: true, @@ -67,3 +68,5 @@ export const listPaginatedHandler = async ({ input }: GetOptions) => { }, }; }; + +export default listPaginatedHandler; diff --git a/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts b/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts new file mode 100644 index 0000000000..64c3ad6eb5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts @@ -0,0 +1,36 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TAdminLockUserAccountSchema } from "./lockUserAccount.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TAdminLockUserAccountSchema; +}; + +const lockUserAccountHandler = async ({ input }: GetOptions) => { + const { userId, locked } = input; + + const user = await prisma.user.update({ + where: { + id: userId, + }, + data: { + locked, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + return { + success: true, + userId, + locked, + }; +}; + +export default lockUserAccountHandler; diff --git a/packages/trpc/server/routers/viewer/admin/lockUserAccount.schema.ts b/packages/trpc/server/routers/viewer/admin/lockUserAccount.schema.ts new file mode 100644 index 0000000000..e5a9434f19 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/lockUserAccount.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZAdminLockUserAccountSchema = z.object({ + userId: z.number(), + locked: z.boolean(), +}); + +export type TAdminLockUserAccountSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts index b15f4e758c..0c222a824c 100644 --- a/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/sendPasswordReset.handler.ts @@ -14,7 +14,7 @@ type GetOptions = { input: TAdminPasswordResetSchema; }; -export const sendPasswordResetHandler = async ({ input }: GetOptions) => { +const sendPasswordResetHandler = async ({ input }: GetOptions) => { const { userId } = input; const user = await prisma.user.findUnique({ @@ -57,3 +57,5 @@ export const sendPasswordResetHandler = async ({ input }: GetOptions) => { success: true, }; }; + +export default sendPasswordResetHandler;