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:
co-authored by
sean-brydon
parent
176564f4ed
commit
c818ef3188
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user