From 8eb024a299aab17f45d9c0debd1b437bdb316971 Mon Sep 17 00:00:00 2001 From: zomars Date: Thu, 16 Nov 2023 18:02:45 -0700 Subject: [PATCH] Implements basic user locking for admins --- packages/features/auth/lib/ErrorCode.ts | 1 + .../features/auth/lib/next-auth-options.ts | 5 ++ .../ee/users/components/UsersTable.tsx | 37 +++++++++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 3 + .../server/middlewares/sessionMiddleware.ts | 2 + .../server/routers/viewer/admin/_router.ts | 62 +++++++------------ .../viewer/admin/listPaginated.handler.ts | 4 +- .../viewer/admin/lockUserAccount.handler.ts | 36 +++++++++++ .../viewer/admin/lockUserAccount.schema.ts | 8 +++ 10 files changed, 118 insertions(+), 42 deletions(-) create mode 100644 packages/prisma/migrations/20231117002911_add_users_locked/migration.sql create mode 100644 packages/trpc/server/routers/viewer/admin/lockUserAccount.handler.ts create mode 100644 packages/trpc/server/routers/viewer/admin/lockUserAccount.schema.ts diff --git a/packages/features/auth/lib/ErrorCode.ts b/packages/features/auth/lib/ErrorCode.ts index c4f86af26b56cc..4f5bdd435b6e53 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 97cc30606555f1..9f0726fa4c3ad6 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -136,6 +136,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 96340689a09563..c5ea0aeb7a62d2 100644 --- a/packages/features/ee/users/components/UsersTable.tsx +++ b/packages/features/ee/users/components/UsersTable.tsx @@ -76,6 +76,32 @@ 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; + console.log("newUser", newUser); + 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 +166,11 @@ function UsersTableBare() {
{user.name} /{user.username} + {user.locked && ( + + + + )}
{user.email}
@@ -167,6 +198,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/prisma/migrations/20231117002911_add_users_locked/migration.sql b/packages/prisma/migrations/20231117002911_add_users_locked/migration.sql new file mode 100644 index 00000000000000..94f7ebc2c83fcd --- /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 c9c2bf65962c3b..20c21926c60a8e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -258,6 +258,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 2a9e072bd862c4..46b55b6450926c 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 59924347e09c70..0052db7cabdd54 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(); @@ -67,3 +67,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 00000000000000..64c3ad6eb587c8 --- /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 00000000000000..e5a9434f197079 --- /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;