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;