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:
sean-brydon
2023-07-07 18:25:21 +05:30
committed by GitHub
parent cbe1907118
commit 0969d5960a
13 changed files with 390 additions and 86 deletions
+4
View File
@@ -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.
+17
View File
@@ -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;
}
+1
View File
@@ -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>;