feat: implements basic user locking for admins (#12393)

* feat: implements basic user locking for admins

* Update sendPasswordReset.handler.ts

* check fixes

* Update packages/features/ee/users/components/UsersTable.tsx

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
Omar López
2023-11-17 16:06:29 +00:00
committed by GitHub
co-authored by sean-brydon
parent 176564f4ed
commit c818ef3188
12 changed files with 123 additions and 43 deletions
+1
View File
@@ -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",
}
@@ -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,
});
@@ -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() {
<div className="text-subtle ml-4 font-medium">
<span className="text-default">{user.name}</span>
<span className="ml-3">/{user.username}</span>
{user.locked && (
<span className="ml-3">
<Lock />
</span>
)}
<br />
<span className="break-all">{user.email}</span>
</div>
@@ -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",
+1
View File
@@ -182,6 +182,7 @@ type UserPayload = Prisma.UserGetPayload<{
}>;
export const buildUser = <T extends Partial<UserPayload>>(user?: T): UserPayload => {
return {
locked: false,
name: faker.name.firstName(),
email: faker.internet.email(),
timeZone: faker.address.timeZone(),
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "locked" BOOLEAN NOT NULL DEFAULT false;
+3
View File
@@ -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])
@@ -20,6 +20,8 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
// Locked users can't login
locked: false,
},
select: {
id: true,
@@ -1,54 +1,34 @@
import { z } from "zod";
import { authedAdminProcedure } from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { router, importHandler } from "../../../trpc";
import { ZListMembersSchema } from "./listPaginated.schema";
import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema";
import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema";
type AdminRouterHandlerCache = {
listPaginated?: typeof import("./listPaginated.handler").listPaginatedHandler;
sendPasswordReset?: typeof import("./sendPasswordReset.handler").sendPasswordResetHandler;
};
const NAMESPACE = "admin";
const UNSTABLE_HANDLER_CACHE: AdminRouterHandlerCache = {};
const namespaced = (s: string) => `${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 }) => {
@@ -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;
@@ -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<TrpcSessionUser>;
};
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;
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZAdminLockUserAccountSchema = z.object({
userId: z.number(),
locked: z.boolean(),
});
export type TAdminLockUserAccountSchema = z.infer<typeof ZAdminLockUserAccountSchema>;
@@ -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;