first commit
This commit is contained in:
259
calcom/packages/features/ee/users/components/UserForm.tsx
Normal file
259
calcom/packages/features/ee/users/components/UserForm.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { noop } from "lodash";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localeOptions } from "@calcom/lib/i18n";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
EmailField,
|
||||
Form,
|
||||
ImageUploader,
|
||||
Label,
|
||||
Select,
|
||||
TextField,
|
||||
TimezoneSelect,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { UserAdminRouterOutputs } from "../server/trpc-router";
|
||||
|
||||
type User = UserAdminRouterOutputs["get"]["user"];
|
||||
|
||||
type Option<T extends string | number = string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type OptionValues = {
|
||||
locale: Option;
|
||||
timeFormat: Option<number>;
|
||||
timeZone: string;
|
||||
weekStart: Option;
|
||||
role: Option;
|
||||
identityProvider: Option;
|
||||
};
|
||||
|
||||
type FormValues = Pick<User, "avatarUrl" | "name" | "username" | "email" | "bio"> & OptionValues;
|
||||
|
||||
export const UserForm = ({
|
||||
defaultValues,
|
||||
localeProp = "en",
|
||||
onSubmit = noop,
|
||||
submitLabel = "save",
|
||||
}: {
|
||||
defaultValues?: Pick<User, keyof FormValues>;
|
||||
localeProp?: string;
|
||||
onSubmit: (data: FormValues) => void;
|
||||
submitLabel?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const timeFormatOptions = [
|
||||
{ value: 12, label: t("12_hour") },
|
||||
{ value: 24, label: t("24_hour") },
|
||||
];
|
||||
|
||||
const weekStartOptions = [
|
||||
{ value: "Sunday", label: nameOfDay(localeProp, 0) },
|
||||
{ value: "Monday", label: nameOfDay(localeProp, 1) },
|
||||
{ value: "Tuesday", label: nameOfDay(localeProp, 2) },
|
||||
{ value: "Wednesday", label: nameOfDay(localeProp, 3) },
|
||||
{ value: "Thursday", label: nameOfDay(localeProp, 4) },
|
||||
{ value: "Friday", label: nameOfDay(localeProp, 5) },
|
||||
{ value: "Saturday", label: nameOfDay(localeProp, 6) },
|
||||
];
|
||||
|
||||
const userRoleOptions = [
|
||||
{ value: "USER", label: t("user") },
|
||||
{ value: "ADMIN", label: t("admin") },
|
||||
];
|
||||
|
||||
const identityProviderOptions = [
|
||||
{ value: "CAL", label: "CAL" },
|
||||
{ value: "GOOGLE", label: "GOOGLE" },
|
||||
{ value: "SAML", label: "SAML" },
|
||||
];
|
||||
const defaultLocale = defaultValues?.locale || localeOptions[0].value;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
avatarUrl: defaultValues?.avatarUrl,
|
||||
name: defaultValues?.name,
|
||||
username: defaultValues?.username,
|
||||
email: defaultValues?.email,
|
||||
bio: defaultValues?.bio,
|
||||
locale: {
|
||||
value: defaultLocale,
|
||||
label: new Intl.DisplayNames(defaultLocale, { type: "language" }).of(defaultLocale) || "",
|
||||
},
|
||||
timeFormat: {
|
||||
value: defaultValues?.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === defaultValues?.timeFormat)?.label || "12",
|
||||
},
|
||||
timeZone: defaultValues?.timeZone || "",
|
||||
weekStart: {
|
||||
value: defaultValues?.weekStart || weekStartOptions[0].value,
|
||||
label: nameOfDay(localeProp, defaultValues?.weekStart === "Sunday" ? 0 : 1),
|
||||
},
|
||||
role: {
|
||||
value: defaultValues?.role || userRoleOptions[0].value,
|
||||
label:
|
||||
userRoleOptions.find((option) => option.value === defaultValues?.role)?.label ||
|
||||
userRoleOptions[0].label,
|
||||
},
|
||||
identityProvider: {
|
||||
value: defaultValues?.identityProvider || identityProviderOptions[0].value,
|
||||
label:
|
||||
identityProviderOptions.find((option) => option.value === defaultValues?.identityProvider)?.label ||
|
||||
identityProviderOptions[0].label,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form form={form} className="space-y-4" handleSubmit={onSubmit}>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="avatarUrl"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Avatar
|
||||
alt={form.getValues("name") || ""}
|
||||
imageSrc={getUserAvatarUrl({
|
||||
avatarUrl: value,
|
||||
})}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ml-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg="Change avatar"
|
||||
handleAvatarChange={onChange}
|
||||
imageSrc={getUserAvatarUrl({
|
||||
avatarUrl: value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="role"
|
||||
control={form.control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<Label className="text-default font-medium" htmlFor="role">
|
||||
{t("role")}
|
||||
</Label>
|
||||
<Select<{ label: string; value: string }>
|
||||
value={value}
|
||||
options={userRoleOptions}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="identityProvider"
|
||||
control={form.control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<Label className="text-default font-medium" htmlFor="identityProvider">
|
||||
{t("identity_provider")}
|
||||
</Label>
|
||||
<Select<{ label: string; value: string }>
|
||||
value={value}
|
||||
options={identityProviderOptions}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TextField label="Name" placeholder="example" required {...form.register("name")} />
|
||||
<TextField label="Username" placeholder="example" required {...form.register("username")} />
|
||||
<EmailField label="Email" placeholder="user@example.com" required {...form.register("email")} />
|
||||
<TextField label="About" {...form.register("bio")} />
|
||||
<Controller
|
||||
name="locale"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label className="text-default">
|
||||
<>{t("language")}</>
|
||||
</Label>
|
||||
<Select<{ label: string; value: string }>
|
||||
className="capitalize"
|
||||
options={localeOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
control={form.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-default mt-8">
|
||||
<>{t("timezone")}</>
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
id="timezone"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (event) form.setValue("timeZone", event.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeFormat"
|
||||
control={form.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-default mt-8">
|
||||
<>{t("time_format")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={timeFormatOptions}
|
||||
onChange={(event) => {
|
||||
if (event) form.setValue("timeFormat", { ...event });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="weekStart"
|
||||
control={form.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label className="text-default mt-8">
|
||||
<>{t("start_of_week")}</>
|
||||
</Label>
|
||||
<Select
|
||||
value={value}
|
||||
options={weekStartOptions}
|
||||
onChange={(event) => {
|
||||
if (event) form.setValue("weekStart", { ...event });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<Button type="submit" color="primary">
|
||||
{t(submitLabel)}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
324
calcom/packages/features/ee/users/components/UsersTable.tsx
Normal file
324
calcom/packages/features/ee/users/components/UsersTable.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DropdownActions,
|
||||
Icon,
|
||||
showToast,
|
||||
Table,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { withLicenseRequired } from "../../common/components/LicenseRequired";
|
||||
|
||||
const { Cell, ColumnTitle, Header, Row } = Table;
|
||||
|
||||
const FETCH_LIMIT = 25;
|
||||
|
||||
function UsersTableBare() {
|
||||
const { t } = useLocale();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = trpc.viewer.users.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast("User has been deleted", "success");
|
||||
// Lets not invalidated the whole cache, just remove the user from the cache.
|
||||
// usefull cause in prod this will be fetching 100k+ users
|
||||
// FIXME: Tested locally and it doesnt't work, need to investigate
|
||||
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.filter((row) => row.id !== userToDelete),
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err.message);
|
||||
showToast("There has been an error deleting this user.", "error");
|
||||
},
|
||||
onSettled: () => {
|
||||
setUserToDelete(null);
|
||||
},
|
||||
});
|
||||
|
||||
const { data, fetchNextPage, isFetching } = trpc.viewer.admin.listPaginated.useInfiniteQuery(
|
||||
{
|
||||
limit: FETCH_LIMIT,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const sendPasswordResetEmail = trpc.viewer.admin.sendPasswordReset.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Password reset email has been sent", "success");
|
||||
},
|
||||
});
|
||||
|
||||
const removeTwoFactor = trpc.viewer.admin.removeTwoFactor.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("2FA has been removed", "success");
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleImpersonateUser = async (username: string | null) => {
|
||||
await signIn("impersonation-auth", { redirect: false, username: username });
|
||||
router.push(`/event-types`);
|
||||
};
|
||||
|
||||
//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 (
|
||||
<>
|
||||
<TextField
|
||||
placeholder="username or email"
|
||||
label="Search"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className="border-subtle 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"}`}
|
||||
// @ts-expect-error - Figure it out later. Ideally we should show all the profiles here for the user.
|
||||
imageSrc={`${WEBAPP_URL}/${user.username}/avatar.png?orgId=${user.organizationId}`}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<Icon name="lock" />
|
||||
</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: "pencil",
|
||||
},
|
||||
{
|
||||
id: "reset-password",
|
||||
label: "Reset Password",
|
||||
onClick: () => sendPasswordResetEmail.mutate({ userId: user.id }),
|
||||
icon: "lock",
|
||||
},
|
||||
{
|
||||
id: "impersonate-user",
|
||||
label: "Impersonate User",
|
||||
onClick: () => handleImpersonateUser(user?.username),
|
||||
icon: "user",
|
||||
},
|
||||
{
|
||||
id: "lock-user",
|
||||
label: user.locked ? "Unlock User Account" : "Lock User Account",
|
||||
onClick: () => lockUserAccount.mutate({ userId: user.id, locked: !user.locked }),
|
||||
icon: "lock",
|
||||
},
|
||||
{
|
||||
id: "impersonation",
|
||||
label: "Impersonate",
|
||||
onClick: () => {
|
||||
setSelectedUser(user.username);
|
||||
setShowImpersonateModal(true);
|
||||
},
|
||||
icon: "venetian-mask",
|
||||
},
|
||||
{
|
||||
id: "remove-2fa",
|
||||
label: "Remove 2FA",
|
||||
color: "destructive",
|
||||
onClick: () => removeTwoFactor.mutate({ userId: user.id }),
|
||||
icon: "shield",
|
||||
},
|
||||
{
|
||||
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>
|
||||
{showImpersonateModal && selectedUser && (
|
||||
<Dialog open={showImpersonateModal} onOpenChange={() => setShowImpersonateModal(false)}>
|
||||
<DialogContent type="creation" title={t("impersonate")} description={t("impersonation_user_tip")}>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("impersonation-auth", { redirect: false, username: selectedUser });
|
||||
setShowImpersonateModal(false);
|
||||
router.replace("/settings/my-account/profile");
|
||||
}}>
|
||||
<DialogFooter showDivider className="mt-8">
|
||||
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||
<Button color="primary" type="submit">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DeleteUserDialog = ({
|
||||
user,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
user: number | null;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- noop
|
||||
<Dialog name="delete-user" open={!!user} onOpenChange={(open) => (open ? () => {} : onClose())}>
|
||||
<ConfirmationDialogContent
|
||||
title="Delete User"
|
||||
confirmBtnText="Delete"
|
||||
cancelBtnText="Cancel"
|
||||
variety="danger"
|
||||
onConfirm={onConfirm}>
|
||||
<p>Are you sure you want to delete this user?</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const UsersTable = withLicenseRequired(UsersTableBare);
|
||||
49
calcom/packages/features/ee/users/pages/users-add-view.tsx
Normal file
49
calcom/packages/features/ee/users/pages/users-add-view.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { getParserWithGeneric } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Meta, showToast } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
import LicenseRequired from "../../common/components/LicenseRequired";
|
||||
import { UserForm } from "../components/UserForm";
|
||||
import { userBodySchema } from "../schemas/userBodySchema";
|
||||
|
||||
const UsersAddView = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.viewer.users.add.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast("User added successfully", "success");
|
||||
await utils.viewer.users.list.invalidate();
|
||||
|
||||
if (pathname !== null) {
|
||||
router.replace(pathname.replace("/add", ""));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err.message);
|
||||
showToast("There has been an error adding this user.", "error");
|
||||
},
|
||||
});
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta title="Add new user" description="Here you can add a new user." />
|
||||
<UserForm
|
||||
submitLabel="Add user"
|
||||
onSubmit={async (values) => {
|
||||
const parser = getParserWithGeneric(userBodySchema);
|
||||
const parsedValues = parser(values);
|
||||
mutation.mutate(parsedValues);
|
||||
}}
|
||||
/>
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
UsersAddView.getLayout = getLayout;
|
||||
|
||||
export default UsersAddView;
|
||||
71
calcom/packages/features/ee/users/pages/users-edit-view.tsx
Normal file
71
calcom/packages/features/ee/users/pages/users-edit-view.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import NoSSR from "@calcom/core/components/NoSSR";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { getParserWithGeneric } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Meta, showToast } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
import LicenseRequired from "../../common/components/LicenseRequired";
|
||||
import { UserForm } from "../components/UserForm";
|
||||
import { userBodySchema } from "../schemas/userBodySchema";
|
||||
|
||||
const userIdSchema = z.object({ id: z.coerce.number() });
|
||||
|
||||
const UsersEditPage = () => {
|
||||
const params = useParamsWithFallback();
|
||||
const input = userIdSchema.safeParse(params);
|
||||
|
||||
if (!input.success) return <div>Invalid input</div>;
|
||||
|
||||
return <UsersEditView userId={input.data.id} />;
|
||||
};
|
||||
|
||||
const UsersEditView = ({ userId }: { userId: number }) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [data] = trpc.viewer.users.get.useSuspenseQuery({ userId });
|
||||
const { user } = data;
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.viewer.users.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
Promise.all([utils.viewer.users.list.invalidate(), utils.viewer.users.get.invalidate()]);
|
||||
showToast("User updated successfully", "success");
|
||||
router.replace(`${pathname?.split("/users/")[0]}/users`);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err.message);
|
||||
showToast("There has been an error updating this user.", "error");
|
||||
},
|
||||
});
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta title={`Editing user: ${user.username}`} description="Here you can edit a current user." />
|
||||
<NoSSR>
|
||||
<UserForm
|
||||
key={JSON.stringify(user)}
|
||||
onSubmit={(values) => {
|
||||
const parser = getParserWithGeneric(userBodySchema);
|
||||
const parsedValues = parser(values);
|
||||
const data: Partial<typeof parsedValues & { userId: number }> = {
|
||||
...parsedValues,
|
||||
userId: user.id,
|
||||
};
|
||||
// Don't send username if it's the same as the current one
|
||||
if (user.username === data.username) delete data.username;
|
||||
mutation.mutate(data);
|
||||
}}
|
||||
defaultValues={user}
|
||||
/>
|
||||
</NoSSR>
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
|
||||
UsersEditPage.getLayout = getLayout;
|
||||
|
||||
export default UsersEditPage;
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import NoSSR from "@calcom/core/components/NoSSR";
|
||||
import { Button, Meta } from "@calcom/ui";
|
||||
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
import { UsersTable } from "../components/UsersTable";
|
||||
|
||||
const DeploymentUsersListPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title="Users"
|
||||
description="A list of all the users in your account including their name, title, email and role."
|
||||
CTA={
|
||||
<div className="mt-4 space-x-5 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
{/* TODO: Add import users functionality */}
|
||||
{/* <Button disabled>Import users</Button> */}
|
||||
<Button href="/settings/admin/users/add">Add user</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<NoSSR>
|
||||
<UsersTable />
|
||||
</NoSSR>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DeploymentUsersListPage.getLayout = getLayout;
|
||||
|
||||
export default DeploymentUsersListPage;
|
||||
13
calcom/packages/features/ee/users/schemas/userBodySchema.ts
Normal file
13
calcom/packages/features/ee/users/schemas/userBodySchema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { optionToValueSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const userBodySchema = z
|
||||
.object({
|
||||
locale: optionToValueSchema(z.string()),
|
||||
role: optionToValueSchema(z.enum(["USER", "ADMIN"])),
|
||||
weekStart: optionToValueSchema(z.string()),
|
||||
timeFormat: optionToValueSchema(z.number()),
|
||||
identityProvider: optionToValueSchema(z.enum(["CAL", "GOOGLE", "SAML"])),
|
||||
})
|
||||
.passthrough();
|
||||
148
calcom/packages/features/ee/users/server/trpc-router.ts
Normal file
148
calcom/packages/features/ee/users/server/trpc-router.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
import { _UserModel as User } from "@calcom/prisma/zod";
|
||||
import type { inferRouterOutputs } from "@calcom/trpc";
|
||||
import { TRPCError } from "@calcom/trpc";
|
||||
import { authedAdminProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
|
||||
export type UserAdminRouter = typeof userAdminRouter;
|
||||
export type UserAdminRouterOutputs = inferRouterOutputs<UserAdminRouter>;
|
||||
|
||||
const userIdSchema = z.object({ userId: z.coerce.number() });
|
||||
|
||||
const userBodySchema = User.pick({
|
||||
name: true,
|
||||
email: true,
|
||||
username: true,
|
||||
bio: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
theme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
timeFormat: true,
|
||||
// brandColor: true,
|
||||
// darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
identityProvider: true,
|
||||
role: true,
|
||||
avatarUrl: true,
|
||||
});
|
||||
|
||||
/** Reusable logic that checks for admin permissions and if the requested user exists */
|
||||
//const authedAdminWithUserMiddleware = middleware();
|
||||
|
||||
const authedAdminProcedureWithRequestedUser = authedAdminProcedure.use(async ({ ctx, next, getRawInput }) => {
|
||||
const { prisma } = ctx;
|
||||
const parsed = userIdSchema.safeParse(await getRawInput());
|
||||
if (!parsed.success) throw new TRPCError({ code: "BAD_REQUEST", message: "User id is required" });
|
||||
const { userId: id } = parsed.data;
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
return next({
|
||||
ctx: {
|
||||
user: ctx.user,
|
||||
requestedUser: user,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const userAdminRouter = router({
|
||||
get: authedAdminProcedureWithRequestedUser.input(userIdSchema).query(async ({ ctx }) => {
|
||||
const { requestedUser } = ctx;
|
||||
return { user: requestedUser };
|
||||
}),
|
||||
list: authedAdminProcedure.query(async ({ ctx }) => {
|
||||
const { prisma } = ctx;
|
||||
// TODO: Add search, pagination, etc.
|
||||
const users = await prisma.user.findMany();
|
||||
return users;
|
||||
}),
|
||||
add: authedAdminProcedure.input(userBodySchema).mutation(async ({ ctx, input }) => {
|
||||
const { prisma } = ctx;
|
||||
const user = await prisma.user.create({ data: input });
|
||||
return { user, message: `User with id: ${user.id} added successfully` };
|
||||
}),
|
||||
update: authedAdminProcedureWithRequestedUser
|
||||
.input(userBodySchema.partial())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { prisma, requestedUser } = ctx;
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const userInternal = await tx.user.update({ where: { id: requestedUser.id }, data: input });
|
||||
|
||||
// If the profile has been moved to an Org -> we can easily access the profile we need to update
|
||||
if (requestedUser.movedToProfileId && input.username) {
|
||||
const profile = await tx.profile.update({
|
||||
where: {
|
||||
id: requestedUser.movedToProfileId,
|
||||
},
|
||||
data: {
|
||||
username: input.username,
|
||||
},
|
||||
});
|
||||
|
||||
// Update all of this users tempOrgRedirectUrls
|
||||
if (requestedUser.username && profile.organizationId) {
|
||||
const data = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: profile.organizationId,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
|
||||
// We should never hit this
|
||||
if (!data?.slug) {
|
||||
throw new Error("Team has no attached slug.");
|
||||
}
|
||||
|
||||
const orgUrlPrefix = getOrgFullOrigin(data.slug);
|
||||
|
||||
const toUrl = `${orgUrlPrefix}/${input.username}`;
|
||||
|
||||
await prisma.tempOrgRedirect.updateMany({
|
||||
where: {
|
||||
type: RedirectType.User,
|
||||
from: requestedUser.username, // Old username
|
||||
},
|
||||
data: {
|
||||
toUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return userInternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO (Sean/Hariom): Change this to profile specific when we have a way for a user to have > 1 orgs
|
||||
* If the user wasnt a CAL account before being moved to an org they dont have the movedToProfileId value
|
||||
* So we update all of their profiles to this new username - this tx will rollback if there is a username
|
||||
* conflict here somehow (Note for now users only have ONE profile.)
|
||||
**/
|
||||
if (input.username) {
|
||||
await tx.profile.updateMany({
|
||||
where: {
|
||||
userId: requestedUser.id,
|
||||
},
|
||||
data: {
|
||||
username: input.username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return userInternal;
|
||||
});
|
||||
return { user, message: `User with id: ${user.id} updated successfully` };
|
||||
}),
|
||||
delete: authedAdminProcedureWithRequestedUser.input(userIdSchema).mutation(async ({ ctx }) => {
|
||||
const { prisma, requestedUser } = ctx;
|
||||
await prisma.user.delete({ where: { id: requestedUser.id } });
|
||||
return { message: `User with id: ${requestedUser.id} deleted successfully` };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user