From 10ffd9bacd7b7e93754103b79777975e53b7da01 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 15 Aug 2023 22:07:38 +0100 Subject: [PATCH] feat: Org user table - bulk actions (#10504) Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Hariom Balhara Co-authored-by: Leo Giovanetti Co-authored-by: Alex van Andel Co-authored-by: CarinaWolli Co-authored-by: zomars Co-authored-by: Peer Richelsen Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Udit Takkar Co-authored-by: Keith Williams Co-authored-by: Peer Richelsen Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: gitstart-calcom Co-authored-by: Shivam Kalra Co-authored-by: cherish2003 Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: rkreddy99 Co-authored-by: varun thummar Co-authored-by: Crowdin Bot Co-authored-by: Pradumn Kumar <47187878+Pradumn27@users.noreply.github.com> Co-authored-by: Richard Poelderl Co-authored-by: mohammed hussam 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 Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com> Co-authored-by: sajanlamsal Co-authored-by: Cherish <88829894+cherish2003@users.noreply.github.com> Co-authored-by: Danila 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 Co-authored-by: Pratik Kumar Co-authored-by: Ritesh Patil --- apps/web/package.json | 1 + apps/web/public/static/locales/en/common.json | 6 + apps/web/tailwind.config.js | 1 + .../UserTable/BulkActions/DeleteBulkUsers.tsx | 49 +++ .../UserTable/BulkActions/TeamList.tsx | 126 +++++++ .../UserTable/EditSheet/DisplayInfo.tsx | 70 ++++ .../UserTable/EditSheet/EditUserForm.tsx | 142 +++++++ .../UserTable/EditSheet/EditUserSheet.tsx | 111 ++++++ .../EditSheet/SheetFooterControls.tsx | 60 +++ .../components/UserTable/EditSheet/store.ts | 15 + .../components/UserTable/UserListTable.tsx | 76 ++-- .../components/UserTable/UserTableActions.tsx | 4 +- packages/lib/hooks/useCopy.ts | 27 ++ .../routers/viewer/organizations/_router.tsx | 346 +++++------------- .../organizations/addBulkTeams.handler.ts | 90 +++++ .../organizations/addBulkTeams.schema.ts | 8 + .../adminGetUnverified.handler.ts | 2 + .../organizations/adminVerify.handler.ts | 2 + .../organizations/bulkDeleteUsers.handler.ts | 67 ++++ .../organizations/bulkDeleteUsers.schema..ts | 7 + .../checkIfOrgNeedsUpgrade.handler.ts | 2 + .../viewer/organizations/create.handler.ts | 2 + .../organizations/createTeams.handler.ts | 2 + .../viewer/organizations/getBrand.handler.ts | 2 + .../organizations/getMembers.handler.ts | 2 + .../organizations/getOtherTeam.handler.ts | 2 + .../viewer/organizations/getTeams.handler.ts | 36 ++ .../viewer/organizations/getUser.handler.ts | 89 +++++ .../viewer/organizations/getUser.schema.ts | 7 + .../viewer/organizations/list.handler.ts | 2 + .../organizations/listMembers.handler.ts | 2 + .../listOtherTeamMembers.handler.ts | 2 + .../organizations/listOtherTeams.handler.ts | 2 + .../viewer/organizations/publish.handler.ts | 2 + .../organizations/setPassword.handler.ts | 2 + .../viewer/organizations/update.handler.ts | 2 + .../organizations/updateUser.handler.ts | 69 ++++ .../viewer/organizations/updateUser.schema.ts | 12 + .../organizations/verifyCode.handler.ts | 2 + packages/trpc/server/trpc.ts | 38 ++ packages/ui/components/avatar/Avatar.tsx | 4 +- packages/ui/components/command/index.tsx | 2 +- .../data-table/DataTableSelectionBar.tsx | 33 +- packages/ui/components/data-table/index.tsx | 9 +- packages/ui/components/sheet/sheet.tsx | 12 +- packages/ui/index.tsx | 23 ++ yarn.lock | 46 +-- 47 files changed, 1282 insertions(+), 336 deletions(-) create mode 100644 packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx create mode 100644 packages/features/users/components/UserTable/BulkActions/TeamList.tsx create mode 100644 packages/features/users/components/UserTable/EditSheet/DisplayInfo.tsx create mode 100644 packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx create mode 100644 packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx create mode 100644 packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx create mode 100644 packages/features/users/components/UserTable/EditSheet/store.ts create mode 100644 packages/lib/hooks/useCopy.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/addBulkTeams.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/addBulkTeams.schema.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.schema..ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getTeams.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getUser.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getUser.schema.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts diff --git a/apps/web/package.json b/apps/web/package.json index d441bf3dd7..6b22d81240 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" }, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fb7f06f6f8..c2888f9ce3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 9aed99b57d..70c598a7db 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -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")], }; diff --git a/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx b/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx new file mode 100644 index 0000000000..639618f4c6 --- /dev/null +++ b/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx @@ -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 ( + + + + + { + deleteMutation.mutateAsync({ + userIds: selectedRows.map((user) => user.id), + }); + }}> +

+ {t("remove_users_from_org_confirm", { + userCount: selectedRows.length, + })} +

+
+
+ ); +} diff --git a/packages/features/users/components/UserTable/BulkActions/TeamList.tsx b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx new file mode 100644 index 0000000000..82992229c2 --- /dev/null +++ b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx @@ -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; +} + +export function TeamListBulkAction({ table }: Props) { + const { data: teams } = trpc.viewer.organizations.getTeams.useQuery(); + const [selectedValues, setSelectedValues] = useState>(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 ( + <> + + + + + {/* We dont really use shadows much - but its needed here */} + + + + + No results found. + + {teams && + teams.map((option) => { + const isSelected = selectedValues.has(option.id); + return ( + { + if (!isSelected) { + addValue(option.id); + } else { + removeValue(option.id); + } + }}> + {option.name} +
+ +
+
+ ); + })} +
+
+
+
+ +
+
+
+ + ); +} diff --git a/packages/features/users/components/UserTable/EditSheet/DisplayInfo.tsx b/packages/features/users/components/UserTable/EditSheet/DisplayInfo.tsx new file mode 100644 index 0000000000..68370c2dd5 --- /dev/null +++ b/packages/features/users/components/UserTable/EditSheet/DisplayInfo.tsx @@ -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 = { + 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({ + label, + value, + asBadge, + isArray, + displayCopy, + displayCount, + badgeColor, +}: DisplayInfoType) { + const { copyToClipboard, isCopied } = useCopy(); + const values = (isArray ? value : [value]) as string[]; + + return ( +
+ +
+ <> + {values.map((v) => { + const content = ( + + {v} + {displayCopy && ( +
+
+ ); +} diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx new file mode 100644 index 0000000000..805210ae37 --- /dev/null +++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx @@ -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; + +export function EditForm({ + selectedUser, + avatarUrl, + domainUrl, + dispatch, +}: { + selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"]; + avatarUrl: string; + domainUrl: string; + dispatch: Dispatch; +}) { + 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 ( +
{ + 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, + }); + }}> +
+ +
+ {selectedUser?.name ?? "Nameless User"} +

+ {domainUrl}/{selectedUser?.username} +

+
+
+
+ + + + +
+ + { + form.setValue("role", value); + }} + /> +
+
+ + +
+
+
+ ); +} diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx new file mode 100644 index 0000000000..5fba289e1b --- /dev/null +++ b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx @@ -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 }) { + 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 ( + { + setEditMode(false); + dispatch({ type: "CLOSE_MODAL" }); + }}> + + {!isLoading && loadedUser ? ( +
+ {!editMode ? ( +
+
+ +
+ + + {loadedUser?.name ?? "Nameless User"} + + + +

+ {orgBranding?.fullDomain ?? WEBAPP_URL}/{loadedUser?.username} +

+
+
+
+
+ + + + + + 0} + /> +
+
+ ) : ( +
+ +
+ )} + + + +
+ ) : ( + + )} +
+
+ ); +} diff --git a/packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx b/packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx new file mode 100644 index 0000000000..faa6499654 --- /dev/null +++ b/packages/features/users/components/UserTable/EditSheet/SheetFooterControls.tsx @@ -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 ( + <> + + + + + ); +} + +function MoreInfoFooter() { + const { t } = useLocale(); + const setEditMode = useEditMode((state) => state.setEditMode); + + return ( + <> + + + + + + ); +} + +export function SheetFooterControls() { + const editMode = useEditMode((state) => state.editMode); + return <>{editMode ? : }; +} diff --git a/packages/features/users/components/UserTable/EditSheet/store.ts b/packages/features/users/components/UserTable/EditSheet/store.ts new file mode 100644 index 0000000000..e8ce08013c --- /dev/null +++ b/packages/features/users/components/UserTable/EditSheet/store.ts @@ -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((set) => ({ + editMode: false, + setEditMode: (editMode) => set({ editMode }), + mutationLoading: false, + setMutationloading: (loading) => set({ mutationLoading: loading }), +})); diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 65ff79fe81..ffc9c49aae 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -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[] = [ // Disabling select for this PR: Will work on actions etc in a follow up - // { - // id: "select", - // header: ({ table }) => ( - // table.toggleAllPageRowsSelected(!!value)} - // aria-label="Select all" - // className="translate-y-[2px]" - // /> - // ), - // cell: ({ row }) => ( - // row.toggleSelected(!!value)} - // aria-label="Select row" - // className="translate-y-[2px]" - // /> - // ), - // }, + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + 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) => , }, { - label: "Delete", - onClick: () => { - console.log("Delete"); - }, - icon: StopCircle, + type: "render", + render: (table) => ( + row.original)} /> + ), }, ]} tableContainerRef={tableContainerRef} @@ -326,6 +337,7 @@ export function UserListTable() { {state.inviteMember.showModal && } {state.impersonateMember.showModal && } {state.changeMemberRole.showModal && } + {state.editSheet.showModal && } ); } diff --git a/packages/features/users/components/UserTable/UserTableActions.tsx b/packages/features/users/components/UserTable/UserTableActions.tsx index c2cb24b4ad..6d8407ae32 100644 --- a/packages/features/users/components/UserTable/UserTableActions.tsx +++ b/packages/features/users/components/UserTable/UserTableActions.tsx @@ -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, diff --git a/packages/lib/hooks/useCopy.ts b/packages/lib/hooks/useCopy.ts new file mode 100644 index 0000000000..1c5aa0fcd7 --- /dev/null +++ b/packages/lib/hooks/useCopy.ts @@ -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 }; +} diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index e71ddff380..42c91f5c45 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -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, - }); - }), }); diff --git a/packages/trpc/server/routers/viewer/organizations/addBulkTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/addBulkTeams.handler.ts new file mode 100644 index 0000000000..ee5b11858e --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addBulkTeams.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/organizations/addBulkTeams.schema.ts b/packages/trpc/server/routers/viewer/organizations/addBulkTeams.schema.ts new file mode 100644 index 0000000000..b3ff7a94f7 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addBulkTeams.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts index 999bee0fa7..86c5e0a3f8 100644 --- a/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/adminGetUnverified.handler.ts @@ -49,3 +49,5 @@ export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptio return unVerifiedTeams; }; + +export default adminGetUnverifiedHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts index 7b3b238c46..a73e58a48e 100644 --- a/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/adminVerify.handler.ts @@ -114,3 +114,5 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => { message: `Verified Organization - Auto accepted all members ending in ${acceptedEmailDomain}`, }; }; + +export default adminVerifyHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts new file mode 100644 index 0000000000..73e1ecd003 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.schema..ts b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.schema..ts new file mode 100644 index 0000000000..552a1965b6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.schema..ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZBulkUsersDelete = z.object({ + userIds: z.array(z.number()), +}); + +export type TBulkUsersDelete = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts b/packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts index 41e5374a5a..29e46303fe 100644 --- a/packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/checkIfOrgNeedsUpgrade.handler.ts @@ -40,3 +40,5 @@ export async function checkIfOrgNeedsUpgradeHandler({ ctx }: GetUpgradeableOptio return teams; } + +export default checkIfOrgNeedsUpgradeHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index c2b9f9a5a2..7736863687 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -160,3 +160,5 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { return { checked: true }; }; + +export default createHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index 4d340bb03a..5051223bc9 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -135,3 +135,5 @@ class NoOrganizationSlugError extends TRPCError { super({ code: "BAD_REQUEST", message: "no_organization_slug" }); } } + +export default createTeamsHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/getBrand.handler.ts b/packages/trpc/server/routers/viewer/organizations/getBrand.handler.ts index 1ce33ca173..1288959dfe 100644 --- a/packages/trpc/server/routers/viewer/organizations/getBrand.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/getBrand.handler.ts @@ -14,3 +14,5 @@ export const getBrandHandler = async ({ ctx }: VerifyCodeOptions) => { return await getBrand(user.organizationId); }; + +export default getBrandHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts index d1c7cd6f47..1225ffdabf 100644 --- a/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts @@ -52,3 +52,5 @@ export const getMembersHandler = async ({ input, ctx }: CreateOptions) => { }); return teamQuery?.members || []; }; + +export default getMembersHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/getOtherTeam.handler.ts b/packages/trpc/server/routers/viewer/organizations/getOtherTeam.handler.ts index 46cb6f05a2..212a7dba49 100644 --- a/packages/trpc/server/routers/viewer/organizations/getOtherTeam.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/getOtherTeam.handler.ts @@ -52,3 +52,5 @@ export const getOtherTeamHandler = async ({ input }: GetOptions) => { safeBio: markdownToSafeHTML(team.bio), }; }; + +export default getOtherTeamHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/getTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/getTeams.handler.ts new file mode 100644 index 0000000000..d6a77dab00 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getTeams.handler.ts @@ -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; + }; +}; + +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; diff --git a/packages/trpc/server/routers/viewer/organizations/getUser.handler.ts b/packages/trpc/server/routers/viewer/organizations/getUser.handler.ts new file mode 100644 index 0000000000..5959132f69 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getUser.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/organizations/getUser.schema.ts b/packages/trpc/server/routers/viewer/organizations/getUser.schema.ts new file mode 100644 index 0000000000..7da862e0fc --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getUser.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetUserInput = z.object({ + userId: z.number().optional(), +}); + +export type TGetUserInput = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/list.handler.ts b/packages/trpc/server/routers/viewer/organizations/list.handler.ts index 227f1537df..6fc4c15b29 100644 --- a/packages/trpc/server/routers/viewer/organizations/list.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/list.handler.ts @@ -40,3 +40,5 @@ export const listHandler = async ({ ctx }: ListHandlerInput) => { metadata, }; }; + +export default listHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 6cb99ae390..2f1d7f7d26 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -102,3 +102,5 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { }, }; }; + +export default listMembersHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts index 93099cf24f..e22d02af1d 100644 --- a/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts @@ -79,3 +79,5 @@ export const listOtherTeamMembers = async ({ ctx, input }: ListOptions) => { return members; }; + +export default listOtherTeamMembers; diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts index 7fcb71dd96..14605a2bdb 100644 --- a/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeams.handler.ts @@ -41,3 +41,5 @@ export const listOtherTeamHandler = async ({ ctx }: ListOptions) => { ...team, })); }; + +export default listOtherTeamHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index e156d801ab..39c7bd95ce 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -82,3 +82,5 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { message: "Team published successfully", }; }; + +export default publishHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/setPassword.handler.ts b/packages/trpc/server/routers/viewer/organizations/setPassword.handler.ts index b2ee9bed49..bb762f1aa8 100644 --- a/packages/trpc/server/routers/viewer/organizations/setPassword.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/setPassword.handler.ts @@ -55,3 +55,5 @@ export const setPasswordHandler = async ({ ctx, input }: UpdateOptions) => { return { update: true }; }; + +export default setPasswordHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 1730fe3a9a..0573259307 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -100,3 +100,5 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { return { update: true, userId: ctx.user.id }; }; + +export default updateHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts new file mode 100644 index 0000000000..115324f1b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts b/packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts new file mode 100644 index 0000000000..3f7fcde07b --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts index eb2a814c31..3cc61bc385 100644 --- a/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/verifyCode.handler.ts @@ -38,3 +38,5 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => { return isValidToken; }; + +export default verifyCodeHandler; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 7237180faf..3f2709e3ea 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -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 = {}; + +/** + * 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 +) => { + 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"]; +}; diff --git a/packages/ui/components/avatar/Avatar.tsx b/packages/ui/components/avatar/Avatar.tsx index 2aaa376dec..7b1550612c 100644 --- a/packages/ui/components/avatar/Avatar.tsx +++ b/packages/ui/components/avatar/Avatar.tsx @@ -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; 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 = ( (({ className, ...props }, ref) => ( )); diff --git a/packages/ui/components/data-table/DataTableSelectionBar.tsx b/packages/ui/components/data-table/DataTableSelectionBar.tsx index ccd3728bce..26c7f6797d 100644 --- a/packages/ui/components/data-table/DataTableSelectionBar.tsx +++ b/packages/ui/components/data-table/DataTableSelectionBar.tsx @@ -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 = + | { + type: "action"; + label: string; + onClick: () => void; + icon?: SVGComponent; + } + | { + type: "render"; + render: (table: Table) => React.ReactNode; + }; + interface DataTableSelectionBarProps { table: Table; - actions?: { - label: string; - onClick: () => void; - icon?: SVGComponent; - }[]; + actions?: ActionItem[]; } export function DataTableSelectionBar({ table, actions }: DataTableSelectionBarProps) { @@ -21,10 +30,16 @@ export function DataTableSelectionBar({ table, actions }: DataTableSelect return (
{numberOfSelectedRows} selected
- {actions?.map((action) => ( - + {actions?.map((action, index) => ( + + {action.type === "action" ? ( + + ) : action.type === "render" ? ( + action.render(table) + ) : null} + ))}
); diff --git a/packages/ui/components/data-table/index.tsx b/packages/ui/components/data-table/index.tsx index c9f8354f3c..0802baef44 100644 --- a/packages/ui/components/data-table/index.tsx +++ b/packages/ui/components/data-table/index.tsx @@ -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 { data: TData[]; searchKey?: string; filterableItems?: FilterableItems; - selectionOptions?: { - label: string; - onClick: () => void; - icon?: SVGComponent; - }[]; + selectionOptions?: ActionItem[]; tableCTA?: React.ReactNode; isLoading?: boolean; onScroll?: (e: React.UIEvent) => void; diff --git a/packages/ui/components/sheet/sheet.tsx b/packages/ui/components/sheet/sheet.tsx index ed8642672e..c6e9d6b584 100644 --- a/packages/ui/components/sheet/sheet.tsx +++ b/packages/ui/components/sheet/sheet.tsx @@ -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"], diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index f99efc5feb..2362eadbc2 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -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"; diff --git a/yarn.lock b/yarn.lock index c06145e873..4db66d222b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"