feat: temp admin user management
## What does this PR do? Loom: https://www.loom.com/share/361ca6ca9b5748079cba59ed2f348d90 Not all items on edit from update the user. This is very temp page to help Milos How to test: Login as admin user settings > admin > users search - see filtered users scroll to bottom of list - notice we refetch before you hit the bottom so you dont notice items loading delete user -> user is deleted and removed from list (radix bug in main causes dialog to freeze refresh to make this go away) edit user -> Edit users email/username/identityprovider. Not all items on this update work just yet. These will be picked up when we come to implement the proper user table.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { adminRouter } from "@calcom/trpc/server/routers/viewer/admin/_router";
|
||||
|
||||
export default createNextApiHandler(adminRouter);
|
||||
@@ -103,7 +103,7 @@ export const UserForm = ({
|
||||
identityProvider: {
|
||||
value: defaultValues?.identityProvider || identityProviderOptions[0].value,
|
||||
label:
|
||||
identityProviderOptions.find((option) => option.value === defaultValues?.role)?.label ||
|
||||
identityProviderOptions.find((option) => option.value === defaultValues?.identityProvider)?.label ||
|
||||
identityProviderOptions[0].label,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge, ConfirmationDialogContent, Dialog, DropdownActions, showToast, Table } from "@calcom/ui";
|
||||
import { Edit, Trash } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
Badge,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DropdownActions,
|
||||
showToast,
|
||||
Table,
|
||||
TextField,
|
||||
Avatar,
|
||||
} from "@calcom/ui";
|
||||
import { Edit, Trash, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
import { withLicenseRequired } from "../../common/components/LicenseRequired";
|
||||
|
||||
const { Body, Cell, ColumnTitle, Header, Row } = Table;
|
||||
const { Cell, ColumnTitle, Header, Row } = Table;
|
||||
|
||||
const FETCH_LIMIT = 25;
|
||||
|
||||
function UsersTableBare() {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useContext();
|
||||
const [data] = trpc.viewer.users.list.useSuspenseQuery();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
|
||||
const mutation = trpc.viewer.users.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast("User has been deleted", "success");
|
||||
await utils.viewer.users.list.invalidate();
|
||||
// Lets not invalidated the whole cache, just remove the user from the cache.
|
||||
// usefull cause in prod this will be fetching 100k+ users
|
||||
utils.viewer.admin.listPaginated.setInfiniteData({ limit: FETCH_LIMIT }, (data) => {
|
||||
if (!data) {
|
||||
return {
|
||||
pages: [],
|
||||
pageParams: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => ({
|
||||
...page,
|
||||
rows: page.rows.filter((row) => row.id !== userToDelete),
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err.message);
|
||||
@@ -24,89 +56,142 @@ function UsersTableBare() {
|
||||
setUserToDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
const { data, fetchNextPage, isFetching } = trpc.viewer.admin.listPaginated.useInfiniteQuery(
|
||||
{
|
||||
limit: FETCH_LIMIT,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const sendPasswordResetEmail = trpc.viewer.admin.sendPasswordReset.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Password reset email has been sent", "success");
|
||||
},
|
||||
});
|
||||
|
||||
//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;
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
|
||||
const [userToDelete, setUserToDelete] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table>
|
||||
<Header>
|
||||
<ColumnTitle widthClassNames="w-auto">User</ColumnTitle>
|
||||
<ColumnTitle>Timezone</ColumnTitle>
|
||||
<ColumnTitle>Role</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">
|
||||
<span className="sr-only">Edit</span>
|
||||
</ColumnTitle>
|
||||
</Header>
|
||||
|
||||
<Body>
|
||||
{data.map((user) => (
|
||||
<Row key={user.email}>
|
||||
<Cell widthClassNames="w-auto">
|
||||
{/*
|
||||
Using flexbox in a table will mess with the overflow, that's why I used position absolute
|
||||
to position the image.
|
||||
*/}
|
||||
<div className="min-h-10 relative pl-12">
|
||||
<img
|
||||
className="absolute left-0 top-1/2 h-10 w-10 -translate-y-1/2 rounded-full"
|
||||
src={user.avatar}
|
||||
alt={`Avatar of ${user.name}`}
|
||||
/>
|
||||
|
||||
<div className="text-subtle ml-4 font-medium">
|
||||
<span className="text-default">{user.name}</span>
|
||||
<span className="ml-3">/{user.username}</span>
|
||||
<br />
|
||||
<span className="break-all">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>{user.timeZone}</Cell>
|
||||
<Cell>
|
||||
<Badge className="capitalize" variant={user.role === "ADMIN" ? "red" : "gray"}>
|
||||
{user.role.toLowerCase()}
|
||||
</Badge>
|
||||
</Cell>
|
||||
<Cell widthClassNames="w-auto">
|
||||
<div className="flex w-full justify-end">
|
||||
<DropdownActions
|
||||
actions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
href: `/settings/admin/users/${user.id}/edit`,
|
||||
icon: Edit,
|
||||
},
|
||||
// {
|
||||
// id: "reset-password",
|
||||
// label: "Reset Password",
|
||||
// href: `/deployments/${router.query.deploymentId}/users/${user.id}/edit`,
|
||||
// icon: FiLock,
|
||||
// },
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
color: "destructive",
|
||||
onClick: () => setUserToDelete(user.id),
|
||||
icon: Trash,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</Table>
|
||||
<DeleteUserDialog
|
||||
user={userToDelete}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (!userToDelete) return;
|
||||
mutation.mutate({ userId: userToDelete });
|
||||
}}
|
||||
<>
|
||||
<TextField
|
||||
placeholder="username or email"
|
||||
label="Search"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-md border"
|
||||
ref={tableContainerRef}
|
||||
onScroll={() => fetchMoreOnBottomReached()}
|
||||
style={{
|
||||
height: "calc(100vh - 30vh)",
|
||||
overflow: "auto",
|
||||
}}>
|
||||
<Table>
|
||||
<Header>
|
||||
<ColumnTitle widthClassNames="w-auto">User</ColumnTitle>
|
||||
<ColumnTitle>Timezone</ColumnTitle>
|
||||
<ColumnTitle>Role</ColumnTitle>
|
||||
<ColumnTitle widthClassNames="w-auto">
|
||||
<span className="sr-only">Edit</span>
|
||||
</ColumnTitle>
|
||||
</Header>
|
||||
|
||||
<tbody className="divide-subtle divide-y rounded-md">
|
||||
{flatData.map((user) => (
|
||||
<Row key={user.email}>
|
||||
<Cell widthClassNames="w-auto">
|
||||
<div className="min-h-10 flex ">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={`Avatar of ${user.username || "Nameless"}`}
|
||||
gravatarFallbackMd5=""
|
||||
imageSrc={`${WEBAPP_URL}/${user.username}/avatar.png`}
|
||||
/>
|
||||
|
||||
<div className="text-subtle ml-4 font-medium">
|
||||
<span className="text-default">{user.name}</span>
|
||||
<span className="ml-3">/{user.username}</span>
|
||||
<br />
|
||||
<span className="break-all">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>{user.timeZone}</Cell>
|
||||
<Cell>
|
||||
<Badge className="capitalize" variant={user.role === "ADMIN" ? "red" : "gray"}>
|
||||
{user.role.toLowerCase()}
|
||||
</Badge>
|
||||
</Cell>
|
||||
<Cell widthClassNames="w-auto">
|
||||
<div className="flex w-full justify-end">
|
||||
<DropdownActions
|
||||
actions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
href: `/settings/admin/users/${user.id}/edit`,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
id: "reset-password",
|
||||
label: "Reset Password",
|
||||
onClick: () => sendPasswordResetEmail.mutate({ userId: user.id }),
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
color: "destructive",
|
||||
onClick: () => setUserToDelete(user.id),
|
||||
icon: Trash,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<DeleteUserDialog
|
||||
user={userToDelete}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (!userToDelete) return;
|
||||
mutation.mutate({ userId: userToDelete });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ export const userBodySchema = z
|
||||
role: optionToValueSchema(z.enum(["USER", "ADMIN"])),
|
||||
weekStart: optionToValueSchema(z.string()),
|
||||
timeFormat: optionToValueSchema(z.number()),
|
||||
identityProvider: optionToValueSchema(z.enum(["CAL", "GOOGLE", "SAML"])),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -27,6 +27,7 @@ const userBodySchema = User.pick({
|
||||
// brandColor: true,
|
||||
// darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
identityProvider: true,
|
||||
// away: true,
|
||||
role: true,
|
||||
// @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type { AppRouter } from "../server/routers/_app";
|
||||
* TODO: Make this dynamic based on folders in trpc server?
|
||||
*/
|
||||
const ENDPOINTS = [
|
||||
"admin",
|
||||
"apiKeys",
|
||||
"appRoutingForms",
|
||||
"apps",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
|
||||
import { mergeRouters, router } from "../../trpc";
|
||||
import { loggedInViewerRouter } from "../loggedInViewer/_router";
|
||||
import { publicViewerRouter } from "../publicViewer/_router";
|
||||
import { adminRouter } from "./admin/_router";
|
||||
import { apiKeysRouter } from "./apiKeys/_router";
|
||||
import { appsRouter } from "./apps/_router";
|
||||
import { authRouter } from "./auth/_router";
|
||||
@@ -48,5 +49,6 @@ export const viewerRouter = mergeRouters(
|
||||
appsRouter,
|
||||
users: userAdminRouter,
|
||||
googleWorkspace: googleWorkspaceRouter,
|
||||
admin: adminRouter,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { authedAdminProcedure } from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZListMembersSchema } from "./listPaginated.schema";
|
||||
import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema";
|
||||
|
||||
type AdminRouterHandlerCache = {
|
||||
listPaginated?: typeof import("./listPaginated.handler").listPaginatedHandler;
|
||||
sendPasswordReset?: typeof import("./sendPasswordReset.handler").sendPasswordResetHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: AdminRouterHandlerCache = {};
|
||||
|
||||
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,
|
||||
});
|
||||
}),
|
||||
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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
import type { TListMembersSchema } from "./listPaginated.schema";
|
||||
|
||||
type GetOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TListMembersSchema;
|
||||
};
|
||||
|
||||
export const listPaginatedHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const { cursor, limit, searchTerm } = input;
|
||||
|
||||
const getTotalUsers = await prisma.user.count();
|
||||
|
||||
let searchFilters: Prisma.UserWhereInput = {};
|
||||
|
||||
if (searchTerm) {
|
||||
searchFilters = {
|
||||
OR: [
|
||||
{
|
||||
email: {
|
||||
contains: searchTerm.toLowerCase(),
|
||||
},
|
||||
},
|
||||
{
|
||||
username: {
|
||||
contains: searchTerm.toLocaleLowerCase(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
take: limit + 1, // We take +1 as itll be used for the next cursor
|
||||
where: searchFilters,
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
let nextCursor: typeof cursor | undefined = undefined;
|
||||
if (users && users.length > limit) {
|
||||
const nextItem = users.pop();
|
||||
nextCursor = nextItem!.id;
|
||||
}
|
||||
|
||||
return {
|
||||
rows: users || [],
|
||||
nextCursor,
|
||||
meta: {
|
||||
totalRowCount: getTotalUsers || 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZListMembersSchema = z.object({
|
||||
limit: z.number().min(1).max(100),
|
||||
cursor: z.number().nullish(),
|
||||
searchTerm: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type TListMembersSchema = z.infer<typeof ZListMembersSchema>;
|
||||
@@ -0,0 +1,59 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendPasswordResetEmail } from "@calcom/emails";
|
||||
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
import type { TAdminPasswordResetSchema } from "./sendPasswordReset.schema";
|
||||
|
||||
type GetOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TAdminPasswordResetSchema;
|
||||
};
|
||||
|
||||
export const sendPasswordResetHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const { userId } = input;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
|
||||
const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate();
|
||||
|
||||
const passwordResetToken = await prisma.resetPasswordRequest.create({
|
||||
data: {
|
||||
email: user.email,
|
||||
expires: expiry,
|
||||
},
|
||||
});
|
||||
|
||||
const resetLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${passwordResetToken.id}`;
|
||||
await sendPasswordResetEmail({
|
||||
language: t,
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
resetLink,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZAdminPasswordResetSchema = z.object({
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type TAdminPasswordResetSchema = z.infer<typeof ZAdminPasswordResetSchema>;
|
||||
Reference in New Issue
Block a user