feat: Org user table - bulk actions (#10504)

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: cherish2003 <saicherissh90@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: rkreddy99 <rreddy@e2clouds.com>
Co-authored-by: varun thummar <Varun>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Pradumn Kumar <47187878+Pradumn27@users.noreply.github.com>
Co-authored-by: Richard Poelderl <richard.poelderl@gmail.com>
Co-authored-by: mohammed hussam <hussamkhatib20@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: sydwardrae <94979838+sydwardrae@users.noreply.github.com>
Co-authored-by: Janakiram Yellapu <jyellapu@vmware.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: sajanlamsal <saznlamsal@gmail.com>
Co-authored-by: Cherish <88829894+cherish2003@users.noreply.github.com>
Co-authored-by: Danila <daniil.demidovich@gmail.com>
Co-authored-by: Neel Patel <29038590+N-NeelPatel@users.noreply.github.com>
Co-authored-by: Rama Krishna Reddy <49095575+rkreddy99@users.noreply.github.com>
Co-authored-by: Varun Thummar <110765105+VARUN949@users.noreply.github.com>
Co-authored-by: Bhargav <bhargavtenali@gmail.com>
Co-authored-by: Pratik Kumar <kpratik1929@gmail.com>
Co-authored-by: Ritesh Patil <riteshsp2000@gmail.com>
This commit is contained in:
sean-brydon 2023-08-15 22:07:38 +01:00 committed by GitHub
parent 20c45b5d88
commit 10ffd9bacd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1282 additions and 336 deletions

View File

@ -166,6 +166,7 @@
"msw": "^0.42.3",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.1",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
},

View File

@ -1265,6 +1265,7 @@
"error_updating_settings": "Error updating settings",
"personal_cal_url": "My personal {{appName}} URL",
"bio_hint": "A few sentences about yourself. this will appear on your personal url page.",
"user_has_no_bio": "This user has not added a bio yet.",
"delete_account_modal_title": "Delete Account",
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
"delete_my_account": "Delete my account",
@ -1982,6 +1983,11 @@
"org_team_names_example_5": "e.g. Data Analytics Team",
"org_max_team_warnings": "You will be able to add more teams later on.",
"what_is_this_meeting_about": "What is this meeting about?",
"add_to_team":"Add to team",
"remove_users_from_org": "Remove users from organization",
"remove_users_from_org_confirm":"Are you sure you want to remove {{userCount}} users from this organization?",
"user_has_no_schedules":"This user has not setup any schedules yet",
"user_isnt_in_any_teams":"This user is not in any teams",
"requires_booker_email_verification": "Requires booker email verification",
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",

View File

@ -3,4 +3,5 @@ const base = require("@calcom/config/tailwind-preset");
module.exports = {
...base,
content: [...base.content, "../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}"],
plugins: [...base.plugins, require("tailwindcss-animate")],
};

View File

@ -0,0 +1,49 @@
import { BanIcon } from "lucide-react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogTrigger, ConfirmationDialogContent, Button, showToast } from "@calcom/ui";
import type { User } from "../UserListTable";
interface Props {
users: User[];
}
export function DeleteBulkUsers({ users }: Props) {
const { t } = useLocale();
const selectedRows = users; // Get selected rows from table
const utils = trpc.useContext();
const deleteMutation = trpc.viewer.organizations.bulkDeleteUsers.useMutation({
onSuccess: () => {
utils.viewer.organizations.listMembers.invalidate();
showToast("Deleted Users", "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button StartIcon={BanIcon}>{t("Delete")}</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_users_from_org")}
confirmBtnText={t("remove")}
isLoading={deleteMutation.isLoading}
onConfirm={() => {
deleteMutation.mutateAsync({
userIds: selectedRows.map((user) => user.id),
});
}}>
<p className="mt-5">
{t("remove_users_from_org_confirm", {
userCount: selectedRows.length,
})}
</p>
</ConfirmationDialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,126 @@
import type { Table } from "@tanstack/react-table";
import { Users, Check } from "lucide-react";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import {
Command,
CommandInput,
CommandList,
CommandItem,
CommandEmpty,
CommandGroup,
Button,
Popover,
PopoverContent,
PopoverTrigger,
showToast,
} from "@calcom/ui";
import type { User } from "../UserListTable";
interface Props {
table: Table<User>;
}
export function TeamListBulkAction({ table }: Props) {
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
const [selectedValues, setSelectedValues] = useState<Set<number>>(new Set());
const utils = trpc.useContext();
const mutation = trpc.viewer.organizations.bulkAddToTeams.useMutation({
onError: (error) => {
showToast(error.message, "error");
},
onSuccess: (res) => {
showToast(
`${res.invitedTotalUsers} Users invited to ${Array.from(selectedValues).length} teams`,
"success"
);
// Optimistically update the data from query trpc cache listMembers
// We may need to set this data instread of invalidating. Will see how performance handles it
utils.viewer.organizations.listMembers.invalidate();
// Clear the selected values
setSelectedValues(new Set());
table.toggleAllRowsSelected(false);
},
});
const { t } = useLocale();
// Add a value to the set
const addValue = (value: number) => {
const updatedSet = new Set(selectedValues);
updatedSet.add(value);
setSelectedValues(updatedSet);
};
// Remove a value from the set
const removeValue = (value: number) => {
const updatedSet = new Set(selectedValues);
updatedSet.delete(value);
setSelectedValues(updatedSet);
};
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button StartIcon={Users}>{t("add_to_team")}</Button>
</PopoverTrigger>
{/* We dont really use shadows much - but its needed here */}
<PopoverContent className="w-[200px] p-0 shadow-md" align="start" sideOffset={12}>
<Command>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{teams &&
teams.map((option) => {
const isSelected = selectedValues.has(option.id);
return (
<CommandItem
key={option.id}
onSelect={() => {
if (!isSelected) {
addValue(option.id);
} else {
removeValue(option.id);
}
}}>
<span>{option.name}</span>
<div
className={classNames(
"border-subtle ml-auto flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Check className={classNames("h-4 w-4")} />
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
<div className="my-1.5 flex w-full">
<Button
loading={mutation.isLoading}
className="ml-auto mr-1.5 rounded-md"
size="sm"
onClick={async () => {
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original);
mutation.mutateAsync({
userIds: selectedRows.map((row) => row.id),
teamIds: Array.from(selectedValues),
});
}}>
{t("apply")}
</Button>
</div>
</PopoverContent>
</Popover>
</>
);
}

View File

@ -0,0 +1,70 @@
import { classNames } from "@calcom/lib";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import type { BadgeProps } from "@calcom/ui";
import { Badge, Button, Label } from "@calcom/ui";
import { ClipboardCheck, Clipboard } from "@calcom/ui/components/icon";
type DisplayInfoType<T extends boolean> = {
label: string;
value: T extends true ? string[] : string;
asBadge?: boolean;
isArray?: T;
displayCopy?: boolean;
badgeColor?: BadgeProps["variant"];
} & (T extends false
? { displayCopy?: boolean; displayCount?: never }
: { displayCopy?: never; displayCount?: number }); // Only show displayCopy if its not an array is false
export function DisplayInfo<T extends boolean>({
label,
value,
asBadge,
isArray,
displayCopy,
displayCount,
badgeColor,
}: DisplayInfoType<T>) {
const { copyToClipboard, isCopied } = useCopy();
const values = (isArray ? value : [value]) as string[];
return (
<div className="flex flex-col">
<Label className="text-subtle mb-1 text-xs font-semibold uppercase leading-none">
{label} {displayCount && `(${displayCount})`}
</Label>
<div className={classNames(asBadge ? "mt-0.5 flex space-x-2" : "flex flex-col")}>
<>
{values.map((v) => {
const content = (
<span
className={classNames(
"text-emphasis inline-flex items-center gap-1 font-normal leading-5",
asBadge ? "text-xs" : "text-sm"
)}>
{v}
{displayCopy && (
<Button
size="sm"
variant="icon"
onClick={() => copyToClipboard(v)}
color="minimal"
className="text-subtle rounded-md"
StartIcon={isCopied ? ClipboardCheck : Clipboard}
/>
)}
</span>
);
return asBadge ? (
<Badge variant={badgeColor} size="sm">
{content}
</Badge>
) : (
content
);
})}
</>
</div>
</div>
);
}

View File

@ -0,0 +1,142 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { Dispatch } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import shallow from "zustand/shallow";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc, type RouterOutputs } from "@calcom/trpc/react";
import {
Form,
TextField,
ToggleGroup,
TextAreaField,
TimezoneSelect,
Label,
showToast,
Avatar,
} from "@calcom/ui";
import type { Action } from "../UserListTable";
import { useEditMode } from "./store";
const editSchema = z.object({
name: z.string(),
email: z.string().email(),
bio: z.string(),
role: z.enum(["ADMIN", "MEMBER"]),
timeZone: z.string(),
// schedules: z.array(z.string()),
// teams: z.array(z.string()),
});
type EditSchema = z.infer<typeof editSchema>;
export function EditForm({
selectedUser,
avatarUrl,
domainUrl,
dispatch,
}: {
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
avatarUrl: string;
domainUrl: string;
dispatch: Dispatch<Action>;
}) {
const [setMutationLoading] = useEditMode((state) => [state.setMutationloading], shallow);
const { t } = useLocale();
const utils = trpc.useContext();
const form = useForm({
resolver: zodResolver(editSchema),
defaultValues: {
name: selectedUser?.name ?? "",
email: selectedUser?.email ?? "",
bio: selectedUser?.bio ?? "",
role: selectedUser?.role ?? "",
timeZone: selectedUser?.timeZone ?? "",
},
});
const mutation = trpc.viewer.organizations.updateUser.useMutation({
onSuccess: () => {
dispatch({ type: "CLOSE_MODAL" });
utils.viewer.organizations.listMembers.invalidate();
showToast(t("profile_updated_successfully"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
onSettled: () => {
/**
* /We need to do this as the submit button lives out side
* the form for some complicated reason so we can't relay on mutationState
*/
setMutationLoading(false);
},
});
const watchTimezone = form.watch("timeZone");
return (
<Form
form={form}
id="edit-user-form"
handleSubmit={(values) => {
setMutationLoading(true);
mutation.mutate({
userId: selectedUser?.id ?? "",
role: values.role as "ADMIN" | "MEMBER", // Cast needed as we dont provide an option for owner
name: values.name,
email: values.email,
bio: values.bio,
timeZone: values.timeZone,
});
}}>
<div className="mt-4 flex items-center gap-2">
<Avatar
size="lg"
alt={`${selectedUser?.name} avatar`}
imageSrc={avatarUrl}
gravatarFallbackMd5="fallback"
/>
<div className="space-between flex flex-col leading-none">
<span className="text-emphasis text-lg font-semibold">{selectedUser?.name ?? "Nameless User"}</span>
<p className="subtle text-sm font-normal">
{domainUrl}/{selectedUser?.username}
</p>
</div>
</div>
<div className="mt-6 flex flex-col space-y-3">
<TextField label={t("name")} {...form.register("name")} />
<TextField label={t("email")} {...form.register("email")} />
<TextAreaField label={t("bio")} {...form.register("bio")} className="min-h-52" />
<div>
<Label>{t("role")}</Label>
<ToggleGroup
isFullWidth
defaultValue={selectedUser?.role ?? "MEMBER"}
value={form.watch("role")}
options={[
{
value: "MEMBER",
label: t("member"),
},
{
value: "ADMIN",
label: t("admin"),
},
]}
onValueChange={(value: EditSchema["role"]) => {
form.setValue("role", value);
}}
/>
</div>
<div>
<Label>{t("timezone")}</Label>
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
</div>
</div>
</Form>
);
}

View File

@ -0,0 +1,111 @@
import type { Dispatch } from "react";
import { shallow } from "zustand/shallow";
import { useOrgBranding } from "@calcom/ee/organizations/context/provider";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Sheet, SheetContent, SheetFooter, Avatar, Skeleton, Loader } from "@calcom/ui";
import type { State, Action } from "../UserListTable";
import { DisplayInfo } from "./DisplayInfo";
import { EditForm } from "./EditUserForm";
import { SheetFooterControls } from "./SheetFooterControls";
import { useEditMode } from "./store";
export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
const { t } = useLocale();
const { user: selectedUser } = state.editSheet;
const orgBranding = useOrgBranding();
const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow);
const { data: loadedUser, isLoading } = trpc.viewer.organizations.getUser.useQuery({
userId: selectedUser?.id,
});
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}/avatar.png`;
const schedulesNames = loadedUser?.schedules && loadedUser?.schedules.map((s) => s.name);
const teamNames =
loadedUser?.teams && loadedUser?.teams.map((t) => `${t.name} ${!t.accepted ? "(pending)" : ""}`);
return (
<Sheet
open={true}
onOpenChange={() => {
setEditMode(false);
dispatch({ type: "CLOSE_MODAL" });
}}>
<SheetContent position="right" size="default">
{!isLoading && loadedUser ? (
<div className="flex h-full flex-col">
{!editMode ? (
<div className="flex-grow">
<div className="mt-4 flex items-center gap-2">
<Avatar
asChild
className="h-[36px] w-[36px]"
alt={`${loadedUser?.name} avatar`}
imageSrc={avatarURL}
gravatarFallbackMd5="fallback"
/>
<div className="space-between flex flex-col leading-none">
<Skeleton loading={isLoading} as="p" waitForTranslation={false}>
<span className="text-emphasis text-lg font-semibold">
{loadedUser?.name ?? "Nameless User"}
</span>
</Skeleton>
<Skeleton loading={isLoading} as="p" waitForTranslation={false}>
<p className="subtle text-sm font-normal">
{orgBranding?.fullDomain ?? WEBAPP_URL}/{loadedUser?.username}
</p>
</Skeleton>
</div>
</div>
<div className="mt-6 flex flex-col space-y-5">
<DisplayInfo label={t("email")} value={loadedUser?.email ?? ""} displayCopy />
<DisplayInfo
label={t("bio")}
badgeColor="gray"
value={loadedUser?.bio ? loadedUser?.bio : t("user_has_no_bio")}
/>
<DisplayInfo label={t("role")} value={loadedUser?.role ?? ""} asBadge badgeColor="blue" />
<DisplayInfo label={t("timezone")} value={loadedUser?.timeZone ?? ""} />
<DisplayInfo
label={t("availability_schedules")}
value={
schedulesNames && schedulesNames?.length === 0
? [t("user_has_no_schedules")]
: schedulesNames ?? "" // TS wtf
}
/>
<DisplayInfo
label={t("teams")}
displayCount={teamNames?.length ?? 0}
value={
teamNames && teamNames?.length === 0 ? [t("user_isnt_in_any_teams")] : teamNames ?? "" // TS wtf
}
asBadge={teamNames && teamNames?.length > 0}
/>
</div>
</div>
) : (
<div className="flex-grow">
<EditForm
selectedUser={loadedUser}
avatarUrl={avatarURL}
domainUrl={orgBranding?.fullDomain ?? WEBAPP_URL}
dispatch={dispatch}
/>
</div>
)}
<SheetFooter className="mt-auto">
<SheetFooterControls />
</SheetFooter>
</div>
) : (
<Loader />
)}
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,60 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SheetClose, Button } from "@calcom/ui";
import { Pencil } from "@calcom/ui/components/icon";
import { useEditMode } from "./store";
function EditModeFooter() {
const { t } = useLocale();
const setEditMode = useEditMode((state) => state.setEditMode);
const isLoading = useEditMode((state) => state.mutationLoading);
return (
<>
<Button
color="secondary"
type="button"
className="justify-center md:w-1/5"
onClick={() => {
setEditMode(false);
}}>
{t("cancel")}
</Button>
<Button type="submit" className="w-full justify-center" form="edit-user-form" loading={isLoading}>
{t("update")}
</Button>
</>
);
}
function MoreInfoFooter() {
const { t } = useLocale();
const setEditMode = useEditMode((state) => state.setEditMode);
return (
<>
<SheetClose asChild>
<Button color="secondary" type="button" className="justify-center md:w-1/5">
{t("close")}
</Button>
</SheetClose>
<Button
type="button"
onClick={() => {
setEditMode(true);
}}
className="w-full justify-center gap-2"
variant="icon"
key="EDIT_BUTTON"
StartIcon={Pencil}>
{t("edit")}
</Button>
</>
);
}
export function SheetFooterControls() {
const editMode = useEditMode((state) => state.editMode);
return <>{editMode ? <EditModeFooter /> : <MoreInfoFooter />}</>;
}

View File

@ -0,0 +1,15 @@
import { create } from "zustand";
interface EditModeState {
editMode: boolean;
setEditMode: (editMode: boolean) => void;
mutationLoading: boolean;
setMutationloading: (loading: boolean) => void;
}
export const useEditMode = create<EditModeState>((set) => ({
editMode: false,
setEditMode: (editMode) => set({ editMode }),
mutationLoading: false,
setMutationloading: (loading) => set({ mutationLoading: loading }),
}));

View File

@ -1,5 +1,5 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Plus, StopCircle, Users } from "lucide-react";
import { Plus } from "lucide-react";
import { useSession } from "next-auth/react";
import { useMemo, useRef, useCallback, useEffect, useReducer } from "react";
@ -7,11 +7,14 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { Avatar, Badge, Button, DataTable } from "@calcom/ui";
import { Avatar, Badge, Button, DataTable, Checkbox } from "@calcom/ui";
import { useOrgBranding } from "../../../ee/organizations/context/provider";
import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers";
import { TeamListBulkAction } from "./BulkActions/TeamList";
import { ChangeUserRoleModal } from "./ChangeUserRoleModal";
import { DeleteMemberModal } from "./DeleteMemberModal";
import { EditUserSheet } from "./EditSheet/EditUserSheet";
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
import { InviteMemberModal } from "./InviteMemberModal";
import { TableActions } from "./UserTableActions";
@ -42,11 +45,17 @@ export type State = {
deleteMember: Payload;
impersonateMember: Payload;
inviteMember: Payload;
editSheet: Payload;
};
export type Action =
| {
type: "SET_CHANGE_MEMBER_ROLE_ID" | "SET_DELETE_ID" | "SET_IMPERSONATE_ID" | "INVITE_MEMBER";
type:
| "SET_CHANGE_MEMBER_ROLE_ID"
| "SET_DELETE_ID"
| "SET_IMPERSONATE_ID"
| "INVITE_MEMBER"
| "EDIT_USER_SHEET";
payload: Payload;
}
| {
@ -66,6 +75,9 @@ const initialState: State = {
inviteMember: {
showModal: false,
},
editSheet: {
showModal: false,
},
};
function reducer(state: State, action: Action): State {
@ -78,6 +90,8 @@ function reducer(state: State, action: Action): State {
return { ...state, impersonateMember: action.payload };
case "INVITE_MEMBER":
return { ...state, inviteMember: action.payload };
case "EDIT_USER_SHEET":
return { ...state, editSheet: action.payload };
case "CLOSE_MODAL":
return {
...state,
@ -85,6 +99,7 @@ function reducer(state: State, action: Action): State {
deleteMember: { showModal: false },
impersonateMember: { showModal: false },
inviteMember: { showModal: false },
editSheet: { showModal: false },
};
default:
return state;
@ -121,25 +136,25 @@ export function UserListTable() {
};
const cols: ColumnDef<User>[] = [
// Disabling select for this PR: Will work on actions etc in a follow up
// {
// id: "select",
// header: ({ table }) => (
// <Checkbox
// checked={table.getIsAllPageRowsSelected()}
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
// aria-label="Select all"
// className="translate-y-[2px]"
// />
// ),
// cell: ({ row }) => (
// <Checkbox
// checked={row.getIsSelected()}
// onCheckedChange={(value) => row.toggleSelected(!!value)}
// aria-label="Select row"
// className="translate-y-[2px]"
// />
// ),
// },
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
},
{
id: "member",
accessorFn: (data) => data.email,
@ -269,18 +284,14 @@ export function UserListTable() {
searchKey="member"
selectionOptions={[
{
label: "Add To Team",
onClick: () => {
console.log("Add To Team");
},
icon: Users,
type: "render",
render: (table) => <TeamListBulkAction table={table} />,
},
{
label: "Delete",
onClick: () => {
console.log("Delete");
},
icon: StopCircle,
type: "render",
render: (table) => (
<DeleteBulkUsers users={table.getSelectedRowModel().flatRows.map((row) => row.original)} />
),
},
]}
tableContainerRef={tableContainerRef}
@ -326,6 +337,7 @@ export function UserListTable() {
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
{state.editSheet.showModal && <EditUserSheet dispatch={dispatch} state={state} />}
</>
);
}

View File

@ -66,7 +66,7 @@ export function TableActions({
type="button"
onClick={() =>
dispatch({
type: "SET_CHANGE_MEMBER_ROLE_ID",
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
@ -140,7 +140,7 @@ export function TableActions({
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,

View File

@ -0,0 +1,27 @@
import { useState, useEffect } from "react";
export function useCopy() {
const [isCopied, setIsCopied] = useState(false);
const copyToClipboard = (text: string) => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard
.writeText(text)
.then(() => setIsCopied(true))
.catch((error) => console.error("Copy to clipboard failed:", error));
}
};
const resetCopyStatus = () => {
setIsCopied(false);
};
useEffect(() => {
if (isCopied) {
const timer = setTimeout(resetCopyStatus, 3000); // Reset copy status after 3 seconds
return () => clearTimeout(timer);
}
}, [isCopied]);
return { isCopied, copyToClipboard, resetCopyStatus };
}

View File

@ -4,287 +4,123 @@ import authedProcedure, {
authedAdminProcedure,
authedOrgAdminProcedure,
} from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { importHandler, router } from "../../../trpc";
import { ZAddBulkTeams } from "./addBulkTeams.schema";
import { ZAdminVerifyInput } from "./adminVerify.schema";
import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema.";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZGetMembersInput } from "./getMembers.schema";
import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
import { ZGetUserInput } from "./getUser.schema";
import { ZListMembersSchema } from "./listMembers.schema";
import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateUserInputSchema } from "./updateUser.schema";
type OrganizationsRouterHandlerCache = {
create?: typeof import("./create.handler").createHandler;
listCurrent?: typeof import("./list.handler").listHandler;
publish?: typeof import("./publish.handler").publishHandler;
checkIfOrgNeedsUpgrade?: typeof import("./checkIfOrgNeedsUpgrade.handler").checkIfOrgNeedsUpgradeHandler;
update?: typeof import("./update.handler").updateHandler;
verifyCode?: typeof import("./verifyCode.handler").verifyCodeHandler;
createTeams?: typeof import("./createTeams.handler").createTeamsHandler;
setPassword?: typeof import("./setPassword.handler").setPasswordHandler;
adminGetUnverified?: typeof import("./adminGetUnverified.handler").adminGetUnverifiedHandler;
adminVerify?: typeof import("./adminVerify.handler").adminVerifyHandler;
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
getMembers?: typeof import("./getMembers.handler").getMembersHandler;
listOtherTeams?: typeof import("./listOtherTeams.handler").listOtherTeamHandler;
getOtherTeam?: typeof import("./getOtherTeam.handler").getOtherTeamHandler;
listOtherTeamMembers?: typeof import("./listOtherTeamMembers.handler").listOtherTeamMembers;
};
const NAMESPACE = "organizations";
const UNSTABLE_HANDLER_CACHE: OrganizationsRouterHandlerCache = {};
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
export const viewerOrganizationsRouter = router({
create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.create) {
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.create) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.create({
ctx,
input,
});
create: authedProcedure.input(ZCreateInputSchema).mutation(async (opts) => {
const handler = await importHandler(namespaced("create"), () => import("./create.handler"));
return handler(opts);
}),
update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.update) {
UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.update) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.update({
ctx,
input,
});
update: authedProcedure.input(ZUpdateInputSchema).mutation(async (opts) => {
const handler = await importHandler(namespaced("update"), () => import("./update.handler"));
return handler(opts);
}),
verifyCode: authedProcedure.input(ZVerifyCodeInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.verifyCode) {
UNSTABLE_HANDLER_CACHE.verifyCode = await import("./verifyCode.handler").then(
(mod) => mod.verifyCodeHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.verifyCode) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.verifyCode({
ctx,
input,
});
verifyCode: authedProcedure.input(ZVerifyCodeInputSchema).mutation(async (opts) => {
const handler = await importHandler(namespaced("verifyCode"), () => import("./verifyCode.handler"));
return handler(opts);
}),
createTeams: authedProcedure.input(ZCreateTeamsSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.createTeams) {
UNSTABLE_HANDLER_CACHE.createTeams = await import("./createTeams.handler").then(
(mod) => mod.createTeamsHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.createTeams) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.createTeams({
ctx,
input,
});
createTeams: authedProcedure.input(ZCreateTeamsSchema).mutation(async (opts) => {
const handler = await importHandler(namespaced("createTeams"), () => import("./createTeams.handler"));
return handler(opts);
}),
listCurrent: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.listCurrent) {
UNSTABLE_HANDLER_CACHE.listCurrent = await import("./list.handler").then((mod) => mod.listHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listCurrent) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listCurrent({
ctx,
});
listCurrent: authedProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("listCurrent"), () => import("./list.handler"));
return handler(opts);
}),
checkIfOrgNeedsUpgrade: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade) {
UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade = await import("./checkIfOrgNeedsUpgrade.handler").then(
(mod) => mod.checkIfOrgNeedsUpgradeHandler
);
}
if (!UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.checkIfOrgNeedsUpgrade({ ctx });
checkIfOrgNeedsUpgrade: authedProcedure.query(async (opts) => {
const handler = await importHandler(
namespaced("checkIfOrgNeedsUpgrade"),
() => import("./checkIfOrgNeedsUpgrade.handler")
);
return handler(opts);
}),
publish: authedProcedure.mutation(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.publish) {
UNSTABLE_HANDLER_CACHE.publish = await import("./publish.handler").then((mod) => mod.publishHandler);
}
if (!UNSTABLE_HANDLER_CACHE.publish) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.publish({ ctx });
publish: authedProcedure.mutation(async (opts) => {
const handler = await importHandler(namespaced("publish"), () => import("./publish.handler"));
return handler(opts);
}),
setPassword: authedProcedure.input(ZSetPasswordSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.setPassword) {
UNSTABLE_HANDLER_CACHE.setPassword = await import("./setPassword.handler").then(
(mod) => mod.setPasswordHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.setPassword) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.setPassword({
ctx,
input,
});
setPassword: authedProcedure.input(ZSetPasswordSchema).mutation(async (opts) => {
const handler = await importHandler(namespaced("setPassword"), () => import("./setPassword.handler"));
return handler(opts);
}),
getMembers: authedProcedure.input(ZGetMembersInput).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
UNSTABLE_HANDLER_CACHE.getMembers = await import("./getMembers.handler").then(
(mod) => mod.getMembersHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getMembers({
ctx,
input,
});
getMembers: authedProcedure.input(ZGetMembersInput).query(async (opts) => {
const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler"));
return handler(opts);
}),
adminGetUnverified: authedAdminProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.adminGetUnverified) {
UNSTABLE_HANDLER_CACHE.adminGetUnverified = await import("./adminGetUnverified.handler").then(
(mod) => mod.adminGetUnverifiedHandler
);
}
if (!UNSTABLE_HANDLER_CACHE.adminGetUnverified) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.adminGetUnverified({
ctx,
});
adminGetUnverified: authedAdminProcedure.query(async (opts) => {
const handler = await importHandler(
namespaced("adminGetUnverified"),
() => import("./adminGetUnverified.handler")
);
return handler(opts);
}),
adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.adminVerify) {
UNSTABLE_HANDLER_CACHE.adminVerify = await import("./adminVerify.handler").then(
(mod) => mod.adminVerifyHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.adminVerify) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.adminVerify({
ctx,
input,
});
adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async (opts) => {
const handler = await importHandler(namespaced("adminVerify"), () => import("./adminVerify.handler"));
return handler(opts);
}),
listMembers: authedProcedure.input(ZListMembersSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then(
(mod) => mod.listMembersHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listMembers({
ctx,
input,
});
listMembers: authedProcedure.input(ZListMembersSchema).query(async (opts) => {
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
return handler(opts);
}),
getBrand: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getBrand) {
UNSTABLE_HANDLER_CACHE.getBrand = await import("./getBrand.handler").then((mod) => mod.getBrandHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getBrand) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getBrand({
ctx,
});
getBrand: authedProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("getBrand"), () => import("./getBrand.handler"));
return handler(opts);
}),
listOtherTeams: authedOrgAdminProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.listOtherTeams) {
UNSTABLE_HANDLER_CACHE.listOtherTeams = await import("./listOtherTeams.handler").then(
(mod) => mod.listOtherTeamHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listOtherTeams) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listOtherTeams({
ctx,
});
getUser: authedProcedure.input(ZGetUserInput).query(async (opts) => {
const handler = await importHandler(namespaced("getUser"), () => import("./getUser.handler"));
return handler(opts);
}),
getOtherTeam: authedOrgAdminProcedure.input(ZGetOtherTeamInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getOtherTeam) {
UNSTABLE_HANDLER_CACHE.getOtherTeam = await import("./getOtherTeam.handler").then(
(mod) => mod.getOtherTeamHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getOtherTeam) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getOtherTeam({
ctx,
input,
});
updateUser: authedProcedure.input(ZUpdateUserInputSchema).mutation(async (opts) => {
const handler = await importHandler(namespaced("updateUser"), () => import("./updateUser.handler"));
return handler(opts);
}),
getTeams: authedProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("getTeams"), () => import("./getTeams.handler"));
return handler(opts);
}),
bulkAddToTeams: authedProcedure.input(ZAddBulkTeams).mutation(async (opts) => {
const handler = await importHandler(namespaced("addBulkTeams"), () => import("./addBulkTeams.handler"));
return handler(opts);
}),
bulkDeleteUsers: authedProcedure.input(ZBulkUsersDelete).mutation(async (opts) => {
const handler = await importHandler(
namespaced("bulkDeleteUsers"),
() => import("./bulkDeleteUsers.handler")
);
return handler(opts);
}),
listOtherTeamMembers: authedOrgAdminProcedure.input(ZListOtherTeamMembersSchema).query(async (opts) => {
const handler = await importHandler(
namespaced("listOtherTeamMembers"),
() => import("./listOtherTeamMembers.handler")
);
return handler(opts);
}),
getOtherTeam: authedOrgAdminProcedure.input(ZGetOtherTeamInputSchema).query(async (opts) => {
const handler = await importHandler(namespaced("getOtherTeam"), () => import("./getOtherTeam.handler"));
return handler(opts);
}),
listOtherTeams: authedOrgAdminProcedure.query(async (opts) => {
const handler = await importHandler(
namespaced("listOtherTeams"),
() => import("./listOtherTeams.handler")
);
return handler(opts);
}),
listOtherTeamMembers: authedOrgAdminProcedure
.input(ZListOtherTeamMembersSchema)
.query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.listOtherTeamMembers) {
UNSTABLE_HANDLER_CACHE.listOtherTeamMembers = await import("./listOtherTeamMembers.handler").then(
(mod) => mod.listOtherTeamMembers
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.listOtherTeamMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.listOtherTeamMembers({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,90 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TAddBulkTeams } from "./addBulkTeams.schema";
type AddBulkTeamsHandler = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TAddBulkTeams;
};
export async function addBulkTeamsHandler({ ctx, input }: AddBulkTeamsHandler) {
const currentUser = ctx.user;
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
// Loop over all users and check if they are already in the organization
const usersInOrganization = await prisma.membership.findMany({
where: {
teamId: currentUser.organizationId,
user: {
id: {
in: input.userIds,
},
},
},
distinct: ["userId"],
});
// Throw error if any of the users are not in the organization. They should be invited to the organization via the onboaring flow first.
if (usersInOrganization.length !== input.userIds.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more users are not in the organization",
});
}
// loop over all users and check if they are already in team they are being invited to
const usersInTeams = await prisma.membership.findMany({
where: {
userId: {
in: input.userIds,
},
teamId: {
in: input.teamIds,
},
},
});
// Filter out users who are already in teams they are being invited to
const filteredUserIds = input.userIds.filter((userId) => {
return !usersInTeams.some((membership) => membership.userId === userId);
});
// TODO: might need to come back to this is people are doing ALOT of invites with bulk actions.
// Loop over all users and add them to all teams in the array
const membershipData = filteredUserIds.flatMap((userId) =>
input.teamIds.map((teamId) => {
const userMembership = usersInOrganization.find((membership) => membership.userId === userId);
const accepted = userMembership && userMembership.accepted;
return {
userId,
teamId,
role: MembershipRole.MEMBER,
accepted: accepted || false,
} as Prisma.MembershipCreateManyInput;
})
);
await prisma.membership.createMany({
data: membershipData,
});
return {
success: true,
invitedTotalUsers: input.userIds.length,
};
}
export default addBulkTeamsHandler;

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const ZAddBulkTeams = z.object({
userIds: z.array(z.number()),
teamIds: z.array(z.number()),
});
export type TAddBulkTeams = z.infer<typeof ZAddBulkTeams>;

View File

@ -49,3 +49,5 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio
return unVerifiedTeams;
};
export default adminGetUnverifiedHandler;

View File

@ -114,3 +114,5 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
message: `Verified Organization - Auto accepted all members ending in ${acceptedEmailDomain}`,
};
};
export default adminVerifyHandler;

View File

@ -0,0 +1,67 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TBulkUsersDelete } from "./bulkDeleteUsers.schema.";
type BulkDeleteUsersHandler = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TBulkUsersDelete;
};
export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHandler) {
const currentUser = ctx.user;
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
// Loop over all users in input.userIds and remove all memberships for the organization including child teams
const deleteMany = prisma.membership.deleteMany({
where: {
userId: {
in: input.userIds,
},
team: {
OR: [
{
parentId: currentUser.organizationId,
},
{ id: currentUser.organizationId },
],
},
},
});
const removeOrgrelation = prisma.user.updateMany({
where: {
id: {
in: input.userIds,
},
},
data: {
// Remove organization relation
organizationId: null,
// Set username to null - to make sure there is no conflicts
username: null,
// Set completedOnboarding to false - to make sure the user has to complete onboarding again -> Setup a new username
completedOnboarding: false,
},
});
// We do this in a transaction to make sure that all memberships are removed before we remove the organization relation from the user
// We also do this to make sure that if one of the queries fail, the whole transaction fails
await prisma.$transaction([deleteMany, removeOrgrelation]);
return {
success: true,
usersDeleted: input.userIds.length,
};
}
export default bulkDeleteUsersHandler;

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZBulkUsersDelete = z.object({
userIds: z.array(z.number()),
});
export type TBulkUsersDelete = z.infer<typeof ZBulkUsersDelete>;

View File

@ -40,3 +40,5 @@ export async function checkIfOrgNeedsUpgradeHandler({ ctx }: GetUpgradeableOptio
return teams;
}
export default checkIfOrgNeedsUpgradeHandler;

View File

@ -160,3 +160,5 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
return { checked: true };
};
export default createHandler;

View File

@ -135,3 +135,5 @@ class NoOrganizationSlugError extends TRPCError {
super({ code: "BAD_REQUEST", message: "no_organization_slug" });
}
}
export default createTeamsHandler;

View File

@ -14,3 +14,5 @@ export const getBrandHandler = async ({ ctx }: VerifyCodeOptions) => {
return await getBrand(user.organizationId);
};
export default getBrandHandler;

View File

@ -52,3 +52,5 @@ export const getMembersHandler = async ({ input, ctx }: CreateOptions) => {
});
return teamQuery?.members || [];
};
export default getMembersHandler;

View File

@ -52,3 +52,5 @@ export const getOtherTeamHandler = async ({ input }: GetOptions) => {
safeBio: markdownToSafeHTML(team.bio),
};
};
export default getOtherTeamHandler;

View File

@ -0,0 +1,36 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
type GetTeamsHandler = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export async function getTeamsHandler({ ctx }: GetTeamsHandler) {
const currentUser = ctx.user;
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
const allOrgTeams = await prisma.team.findMany({
where: {
parentId: currentUser.organizationId,
},
select: {
id: true,
name: true,
},
});
return allOrgTeams;
}
export default getTeamsHandler;

View File

@ -0,0 +1,89 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TGetUserInput } from "./getUser.schema";
type AdminVerifyOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetUserInput;
};
export async function getUserHandler({ input, ctx }: AdminVerifyOptions) {
const currentUser = ctx.user;
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
// get requested user from database and ensure they are in the same organization
const [requestedUser, membership, teams] = await prisma.$transaction([
prisma.user.findFirst({
where: { id: input.userId, organizationId: currentUser.organizationId },
select: {
id: true,
email: true,
username: true,
name: true,
bio: true,
timeZone: true,
schedules: {
select: {
id: true,
name: true,
},
},
},
}),
// Query on accepted as we don't want the user to be able to get this much info on a user that hasn't accepted the invite
prisma.membership.findFirst({
where: {
userId: input.userId,
teamId: currentUser.organizationId,
accepted: true,
},
select: {
role: true,
},
}),
prisma.membership.findMany({
where: {
userId: input.userId,
team: {
parentId: currentUser.organizationId,
},
},
select: {
team: {
select: {
id: true,
name: true,
},
},
accepted: true,
},
}),
]);
if (!requestedUser || !membership)
throw new TRPCError({ code: "UNAUTHORIZED", message: "user_not_exist_or_not_in_org" });
const foundUser = {
...requestedUser,
teams: teams.map((team) => ({
...team.team,
accepted: team.accepted,
})),
role: membership.role,
};
return foundUser;
}
export default getUserHandler;

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetUserInput = z.object({
userId: z.number().optional(),
});
export type TGetUserInput = z.infer<typeof ZGetUserInput>;

View File

@ -40,3 +40,5 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => {
metadata,
};
};
export default listHandler;

View File

@ -102,3 +102,5 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
},
};
};
export default listMembersHandler;

View File

@ -79,3 +79,5 @@ export const listOtherTeamMembers = async ({ ctx, input }: ListOptions) => {
return members;
};
export default listOtherTeamMembers;

View File

@ -41,3 +41,5 @@ export const listOtherTeamHandler = async ({ ctx }: ListOptions) => {
...team,
}));
};
export default listOtherTeamHandler;

View File

@ -82,3 +82,5 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
message: "Team published successfully",
};
};
export default publishHandler;

View File

@ -55,3 +55,5 @@ export const setPasswordHandler = async ({ ctx, input }: UpdateOptions) => {
return { update: true };
};
export default setPasswordHandler;

View File

@ -100,3 +100,5 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
return { update: true, userId: ctx.user.id };
};
export default updateHandler;

View File

@ -0,0 +1,69 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TUpdateUserInputSchema } from "./updateUser.schema";
type UpdateUserOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TUpdateUserInputSchema;
};
export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
const { user } = ctx;
const { id: userId, organizationId } = user;
if (!organizationId)
throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be a memeber of an organizaiton" });
if (!(await isOrganisationAdmin(userId, organizationId))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Is requested user a member of the organization?
const requestedMember = await prisma.membership.findFirst({
where: {
userId: input.userId,
teamId: organizationId,
accepted: true,
},
});
if (!requestedMember)
throw new TRPCError({ code: "UNAUTHORIZED", message: "User does not belong to your organization" });
// Update user
await prisma.$transaction([
prisma.user.update({
where: {
id: input.userId,
},
data: {
bio: input.bio,
email: input.email,
name: input.name,
timeZone: input.timeZone,
},
}),
prisma.membership.update({
where: {
userId_teamId: {
userId: input.userId,
teamId: organizationId,
},
},
data: {
role: input.role,
},
}),
]);
// TODO: audit log this
return {
success: true,
};
};
export default updateUserHandler;

View File

@ -0,0 +1,12 @@
import { z } from "zod";
export const ZUpdateUserInputSchema = z.object({
userId: z.number(),
bio: z.string().optional(),
name: z.string().optional(),
email: z.string().optional(),
role: z.enum(["ADMIN", "MEMBER"]),
timeZone: z.string(),
});
export type TUpdateUserInputSchema = z.infer<typeof ZUpdateUserInputSchema>;

View File

@ -38,3 +38,5 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
return isValidToken;
};
export default verifyCodeHandler;

View File

@ -15,3 +15,41 @@ export const middleware = tRPCContext.middleware;
export const procedure = tRPCContext.procedure;
export type TrpcSessionUser = UserFromSession;
// eslint-disable-next-line @typescript-eslint/ban-types
const UNSTABLE_HANDLER_CACHE: Record<string, Function> = {};
/**
* This function will import the module defined in importer just once and then cache the default export of that module.
*
* It gives you the default export of the module.
*
* **Note: It is your job to ensure that the name provided is unique across all routes.**
* @example
* ```ts
const handler = await importHandler("myUniqueNameSpace", () => import("./getUser.handler"));
return handler({ ctx, input });
* ```
*/
export const importHandler = async <
T extends {
// eslint-disable-next-line @typescript-eslint/ban-types
default: Function;
}
>(
/**
* The name of the handler in cache. It has to be unique across all routes
*/
name: string,
importer: () => Promise<T>
) => {
const nameInCache = name as keyof typeof UNSTABLE_HANDLER_CACHE;
if (!UNSTABLE_HANDLER_CACHE[nameInCache]) {
const importedModule = await importer();
UNSTABLE_HANDLER_CACHE[nameInCache] = importedModule.default;
return importedModule.default as T["default"];
}
return UNSTABLE_HANDLER_CACHE[nameInCache] as unknown as T["default"];
};

View File

@ -12,7 +12,7 @@ import { Tooltip } from "../tooltip";
export type AvatarProps = {
className?: string;
size: "xxs" | "xs" | "xsm" | "sm" | "md" | "mdLg" | "lg" | "xl";
size?: "xxs" | "xs" | "xsm" | "sm" | "md" | "mdLg" | "lg" | "xl";
imageSrc?: Maybe<string>;
title?: string;
alt: string;
@ -35,7 +35,7 @@ const sizesPropsBySize = {
} as const;
export function Avatar(props: AvatarProps) {
const { imageSrc, gravatarFallbackMd5, size, alt, title, href } = props;
const { imageSrc, gravatarFallbackMd5, size = "md", alt, title, href } = props;
const rootClass = classNames("aspect-square rounded-full", sizesPropsBySize[size]);
let avatar = (
<AvatarPrimitive.Root

View File

@ -95,7 +95,7 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={classNames("bg-border -mx-1 h-px", className)}
className={classNames("bg-subtle -mx-1 mb-2 h-px", className)}
{...props}
/>
));

View File

@ -1,16 +1,25 @@
import type { Table } from "@tanstack/react-table";
import { Fragment } from "react";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { Button } from "../button";
export type ActionItem<TData> =
| {
type: "action";
label: string;
onClick: () => void;
icon?: SVGComponent;
}
| {
type: "render";
render: (table: Table<TData>) => React.ReactNode;
};
interface DataTableSelectionBarProps<TData> {
table: Table<TData>;
actions?: {
label: string;
onClick: () => void;
icon?: SVGComponent;
}[];
actions?: ActionItem<TData>[];
}
export function DataTableSelectionBar<TData>({ table, actions }: DataTableSelectionBarProps<TData>) {
@ -21,10 +30,16 @@ export function DataTableSelectionBar<TData>({ table, actions }: DataTableSelect
return (
<div className="bg-brand-default text-brand item-center absolute bottom-0 left-1/2 flex -translate-x-1/2 gap-4 rounded-lg p-2">
<div className="text-brand-subtle my-auto px-2">{numberOfSelectedRows} selected</div>
{actions?.map((action) => (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon} key={action.label}>
{action.label}
</Button>
{actions?.map((action, index) => (
<Fragment key={index}>
{action.type === "action" ? (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon}>
{action.label}
</Button>
) : action.type === "render" ? (
action.render(table)
) : null}
</Fragment>
))}
</div>
);

View File

@ -17,9 +17,8 @@ import {
import { useState } from "react";
import { useVirtual } from "react-virtual";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew";
import type { ActionItem } from "./DataTableSelectionBar";
import { DataTableSelectionBar } from "./DataTableSelectionBar";
import type { FilterableItems } from "./DataTableToolbar";
import { DataTableToolbar } from "./DataTableToolbar";
@ -30,11 +29,7 @@ export interface DataTableProps<TData, TValue> {
data: TData[];
searchKey?: string;
filterableItems?: FilterableItems;
selectionOptions?: {
label: string;
onClick: () => void;
icon?: SVGComponent;
}[];
selectionOptions?: ActionItem<TData>[];
tableCTA?: React.ReactNode;
isLoading?: boolean;
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;

View File

@ -52,10 +52,10 @@ const sheetVariants = cva(
{
variants: {
position: {
top: "animate-in slide-in-from-top w-full duration-300",
bottom: "animate-in slide-in-from-bottom w-full duration-300",
left: "animate-in slide-in-from-left h-full duration-300",
right: "animate-in slide-in-from-right h-full duration-300",
top: "animate-in slide-in-from-top w-full duration-200",
bottom: "animate-in slide-in-from-bottom w-full duration-200",
left: "animate-in slide-in-from-left h-full duration-200",
right: "animate-in slide-in-from-right h-full duration-200",
},
size: {
content: "",
@ -105,12 +105,12 @@ const sheetVariants = cva(
{
position: ["right", "left"],
size: "default",
class: "w-1/3 h-[calc(100vh-2rem)]",
class: "w-1/3 max-h-[calc(100vh-2rem)]",
},
{
position: ["right", "left"],
size: "sm",
class: "w-1/4 h-[calc(100vh-2rem)]",
class: "w-1/4 max-h-[calc(100vh-2rem)]",
},
{
position: ["right", "left"],

View File

@ -138,3 +138,26 @@ export { useCalcomTheme } from "./styles/useCalcomTheme";
export { ScrollableArea } from "./components/scrollable/ScrollableArea";
export { WizardLayout } from "./layouts/WizardLayout";
export { DataTable } from "./components/data-table";
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "./components/sheet/sheet";
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandInput,
CommandSeparator,
CommandShortcut,
} from "./components/command";
export { Popover, PopoverContent, PopoverTrigger } from "./components/popover";

View File

@ -4925,6 +4925,7 @@ __metadata:
stripe: ^9.16.0
superjson: 1.9.1
tailwindcss: ^3.3.1
tailwindcss-animate: ^1.0.6
tailwindcss-radix: ^2.6.0
ts-node: ^10.9.1
turndown: ^7.1.1
@ -5011,7 +5012,6 @@ __metadata:
next-collect: ^0.2.1
next-i18next: ^13.2.2
next-seo: ^6.0.0
playwright: ^1.31.2
postcss: ^8.4.18
prism-react-renderer: ^1.3.5
react: ^18.2.0
@ -15758,6 +15758,7 @@ __metadata:
jsdom: ^22.0.0
lint-staged: ^12.5.0
lucide-react: ^0.171.0
mailhog: ^4.16.0
prettier: ^2.8.6
tsc-absolute: ^1.0.0
turbo: ^1.10.1
@ -22519,7 +22520,7 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3":
"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3":
version: 0.6.3
resolution: "iconv-lite@npm:0.6.3"
dependencies:
@ -25491,6 +25492,18 @@ __metadata:
languageName: node
linkType: hard
"mailhog@npm:^4.16.0":
version: 4.16.0
resolution: "mailhog@npm:4.16.0"
dependencies:
iconv-lite: ^0.6
dependenciesMeta:
iconv-lite:
optional: true
checksum: 3fe666bd0cb4cd6998da77e4b362ad1f34e4b8e8fc06724dba72712fe8f091dbc4e6d15e315c88d83bff6b9d3e682536cab51f376752ae340504265bf0bb3dd8
languageName: node
linkType: hard
"make-dir@npm:^2.0.0, make-dir@npm:^2.1.0":
version: 2.1.0
resolution: "make-dir@npm:2.1.0"
@ -28916,26 +28929,6 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.36.2":
version: 1.36.2
resolution: "playwright-core@npm:1.36.2"
bin:
playwright-core: cli.js
checksum: 2193ce802ef93c28b9b5e11a0b1d7b60778c686015659978d1cbf0eb9cda2cdc85ec5575b887c1346e9d161cc2805bf27638d76a2f7f857dffeae968e6ceffcd
languageName: node
linkType: hard
"playwright@npm:^1.31.2":
version: 1.36.2
resolution: "playwright@npm:1.36.2"
dependencies:
playwright-core: 1.36.2
bin:
playwright: cli.js
checksum: 5876b65a0f1303e45f99c7d120706af0ab808efd5d89c514741584ff1060408b62148ae2790c2e6527642f2b8f49db682710b87d3df7b3ba510e8e847e6041ef
languageName: node
linkType: hard
"pngjs@npm:^3.0.0, pngjs@npm:^3.3.3":
version: 3.4.0
resolution: "pngjs@npm:3.4.0"
@ -34086,6 +34079,15 @@ __metadata:
languageName: node
linkType: hard
"tailwindcss-animate@npm:^1.0.6":
version: 1.0.6
resolution: "tailwindcss-animate@npm:1.0.6"
peerDependencies:
tailwindcss: "*"
checksum: 01471cb5e64d0936b3dc90bdb1d4b379e8b73b3d285553337d8576e5d458e868ddff99c6db0ae454c1be69ce8f88dc0eed44ea62e4d7bca10d7d2479fc4c8ee0
languageName: node
linkType: hard
"tailwindcss-radix@npm:^2.6.0":
version: 2.6.0
resolution: "tailwindcss-radix@npm:2.6.0"