Files
calendar/packages/features/ee/users/components/UsersTable.tsx
T
12466fb064 feat: New Organization Schema along with the ability to invite existing users to an organization (#13002)
* Add profile table

Copy to profile table

Update schema

Migrations

* wip

* Get switcher working

* Add organization seeder

* wip

* Remove dead code

* Fix unit tests

* relevantProfile -> profile

* cache bust

* One more comment

* Squash migrations

* Copy to profile table

* Update schema

* Fix existing user invite flow

* Add profile table

* Copy to profile table

* Update schema

* Format

* Add profile table

Copy to profile table

Update schema

Migrations

* Format

* wip

Get switcher working

Add organization seeder

wip

Remove dead code

Fix unit tests

relevantProfile -> profile

cache bust

One more comment

Squash migrations

Copy to profile table

Update schema

Fix existing user invite flow

Add profile table

Copy to profile table

Update schema

chore: small improvements to signup (#13227)

* removed features on mobile, better background color

* nit

* dark mode nits

New Crowdin translations by Github Action

fix: event reschedule workflow trigger (#13122)

* fix event reschedule workflow trigger

* uncomment previously failed test

* review fix

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>

chore: added community support to help (#13213)

refactor: Abstract `handleSeats` from `handleNewBooking` (#12062)

* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Erik <erik@erosemberg.com>

fix: Dynamic group booking  org-migration redirect (#13223)

* fix: Dynamic group booking redirect

* Fix flaky private team test

test: Check availability tab and all your functions (teste2e-availability) (#12434)

* add changes

* add changes

* add changes

* add changes

* Requested changes

* Change way to grab element

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

fix: Reschedule a cancelled booking (#13170)

* fix: Reschedule a cancelled booking

* canceled instead of cancelled

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

v3.7.0

test: In Team Event type when location is Organizer Default App and U… (#12939)

* test: In Team Event type when location is Organizer Default App and User Metadata is emoty

* chore: use variable

* fix: type error

test: add test for invalid cal video (#13103)

* test: add test for invalid cal video

* chore: add confirm

* chore: fix type error

* chore: update code

* test: add unit test

* chore: type error

* chore

* chore: type

* chore: fix test

* chore

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

chore: add error message for no availability (#13230)

* chore: add error message for no default user availability

* chore: check only availability

* chore: change message

* chore: add eventType

fix: Team revert with members (#13228)

refactor: Refactor seats logic (#12905)

* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

* Move handleSeats

* Abstract lastAttendeeDeleteBooking

* Create function for rescheduling seated events

* Fix imports on reschedule seats function

* Fix imports

* Import handleSeats function

* Fix rescheduleUid type

* Refactor owner reschedule to new time slot

* Refactor combine two booking times together

* Reschedule as an attendee

* Refactor createNewSeat

* Remove old handleSeats

* Remove lastAttendeeDeleteBooking from handleNewBooking

* Test for new attendee right params are passed

* Unit test params for reschedule

* Typo fix

* Clean up

* Create new seat test

* Test when attendee already signs up for booking

* Type fix

* Test reschedule move attendee to existing booking

* On reschedule create new booking

* Test on last attendee cancel booking

* Owner reschedule to new time slot

* Owner rescheduling, merge two bookings together

* Test: when merging more than available seats, then fail

* Test: fail when event is full

* Remove duplicate E2E tests

* Clean up

* Rename `addVideoCallDataToEvt` to `addVideoCallDataToEvent`

* Refactor `calcAppsStatus`

* Assign `evt` to resutl of `addVideoCallDataToEvent`

* Use prisma.transaction when moving attendees

* Clean create seat call

* Use ErrorCode enum

* Use attendeeRescheduledSeatedBooking function

* Await function

* Prevent double triggering of workflows

* Use inviteeToAdd in createNewSeat

* Remove unused error code

* Remove old handleSeats file

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Erik <erik@erosemberg.com>

feat: Allow admins to filter bookings only by attendee emails (#13236)

* chore: Allow admins to filter bookings only by attendee emails

* Fix type error

Fixed [CAL-2930] Org branding isnt centered in shell #13225 (#13237)

Add profile table

Copy to profile table

Update schema

Migrations

Format

* wip

* Add profile relation everywhere

* Fix schema

* Fix invite handler

* Add profileId during eventTypec reation

* Get dashboard working with profile relatioon

* Add workflow create repository

* Add profileId to host

* Add profileId to credential table

* Add destinationCalendar repository

* Add profileId to membership

* Revert all profile related changes

* Add acme and dunder

* Fix membership profileId usgae

* Various fixes

* Some renaming

* Fix failing tests

* Debugging next-aut

* Consolidate migrations

* Testing fixes

* Fix unit tests

* Self review feedback

* PR feedback

* Fix orgMigration as well

* Add profile schema and copy users to it

* Remove migrations from here

* Fix type issues

* Fix type error after main merge

* Remove movedFromUserId

* Add missing migration file

* Self review addressed

* Joe's review addressed

* Fix profile switcher

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-02-02 21:34:13 +00:00

312 lines
11 KiB
TypeScript

import { keepPreviousData } from "@tanstack/react-query";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Avatar,
Badge,
Button,
ConfirmationDialogContent,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DropdownActions,
showToast,
Table,
TextField,
} from "@calcom/ui";
import { Edit, Lock, Trash, User, VenetianMask } from "@calcom/ui/components/icon";
import { withLicenseRequired } from "../../common/components/LicenseRequired";
const { Cell, ColumnTitle, Header, Row } = Table;
const FETCH_LIMIT = 25;
function UsersTableBare() {
const { t } = useLocale();
const tableContainerRef = useRef<HTMLDivElement>(null);
const utils = trpc.useContext();
const [searchTerm, setSearchTerm] = useState<string>("");
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const router = useRouter();
const mutation = trpc.viewer.users.delete.useMutation({
onSuccess: async () => {
showToast("User has been deleted", "success");
// Lets not invalidated the whole cache, just remove the user from the cache.
// usefull cause in prod this will be fetching 100k+ users
// FIXME: Tested locally and it doesnt't work, need to investigate
utils.viewer.admin.listPaginated.setInfiniteData({ limit: FETCH_LIMIT }, (cachedData) => {
if (!cachedData) {
return {
pages: [],
pageParams: [],
};
}
return {
...cachedData,
pages: cachedData.pages.map((page) => ({
...page,
rows: page.rows.filter((row) => row.id !== userToDelete),
})),
};
});
},
onError: (err) => {
console.error(err.message);
showToast("There has been an error deleting this user.", "error");
},
onSettled: () => {
setUserToDelete(null);
},
});
const { data, fetchNextPage, isFetching } = trpc.viewer.admin.listPaginated.useInfiniteQuery(
{
limit: FETCH_LIMIT,
searchTerm: debouncedSearchTerm,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
}
);
const sendPasswordResetEmail = trpc.viewer.admin.sendPasswordReset.useMutation({
onSuccess: () => {
showToast("Password reset email has been sent", "success");
},
});
const lockUserAccount = trpc.viewer.admin.lockUserAccount.useMutation({
onSuccess: ({ userId, locked }) => {
showToast(locked ? "User was locked" : "User was unlocked", "success");
utils.viewer.admin.listPaginated.setInfiniteData({ limit: FETCH_LIMIT }, (cachedData) => {
if (!cachedData) {
return {
pages: [],
pageParams: [],
};
}
return {
...cachedData,
pages: cachedData.pages.map((page) => ({
...page,
rows: page.rows.map((row) => {
const newUser = row;
if (row.id === userId) newUser.locked = locked;
return newUser;
}),
})),
};
});
},
});
const handleImpersonateUser = async (username: string | null) => {
await signIn("impersonation-auth", { redirect: false, username: username });
router.push(`/event-types`);
};
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]);
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
const totalFetched = flatData.length;
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
const fetchMoreOnBottomReached = useCallback(
(containerRefElement?: HTMLDivElement | null) => {
if (containerRefElement) {
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
fetchNextPage();
}
}
},
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
);
useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);
const [userToDelete, setUserToDelete] = useState<number | null>(null);
return (
<>
<TextField
placeholder="username or email"
label="Search"
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div
className="border-subtle rounded-md border"
ref={tableContainerRef}
onScroll={() => fetchMoreOnBottomReached()}
style={{
height: "calc(100vh - 30vh)",
overflow: "auto",
}}>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">User</ColumnTitle>
<ColumnTitle>Timezone</ColumnTitle>
<ColumnTitle>Role</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">Edit</span>
</ColumnTitle>
</Header>
<tbody className="divide-subtle divide-y rounded-md">
{flatData.map((user) => (
<Row key={user.email}>
<Cell widthClassNames="w-auto">
<div className="min-h-10 flex ">
<Avatar
size="md"
alt={`Avatar of ${user.username || "Nameless"}`}
// @ts-expect-error - Figure it out later. Ideally we should show all the profiles here for the user.
imageSrc={`${WEBAPP_URL}/${user.username}/avatar.png?orgId=${user.organizationId}`}
/>
<div className="text-subtle ml-4 font-medium">
<span className="text-default">{user.name}</span>
<span className="ml-3">/{user.username}</span>
{user.locked && (
<span className="ml-3">
<Lock />
</span>
)}
<br />
<span className="break-all">{user.email}</span>
</div>
</div>
</Cell>
<Cell>{user.timeZone}</Cell>
<Cell>
<Badge className="capitalize" variant={user.role === "ADMIN" ? "red" : "gray"}>
{user.role.toLowerCase()}
</Badge>
</Cell>
<Cell widthClassNames="w-auto">
<div className="flex w-full justify-end">
<DropdownActions
actions={[
{
id: "edit",
label: "Edit",
href: `/settings/admin/users/${user.id}/edit`,
icon: Edit,
},
{
id: "reset-password",
label: "Reset Password",
onClick: () => sendPasswordResetEmail.mutate({ userId: user.id }),
icon: Lock,
},
{
id: "impersonate-user",
label: "Impersonate User",
onClick: () => handleImpersonateUser(user?.username),
icon: User,
},
{
id: "lock-user",
label: user.locked ? "Unlock User Account" : "Lock User Account",
onClick: () => lockUserAccount.mutate({ userId: user.id, locked: !user.locked }),
icon: Lock,
},
{
id: "impersonation",
label: "Impersonate",
onClick: () => {
setSelectedUser(user.username);
setShowImpersonateModal(true);
},
icon: VenetianMask,
},
{
id: "delete",
label: "Delete",
color: "destructive",
onClick: () => setUserToDelete(user.id),
icon: Trash,
},
]}
/>
</div>
</Cell>
</Row>
))}
</tbody>
</Table>
<DeleteUserDialog
user={userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={() => {
if (!userToDelete) return;
mutation.mutate({ userId: userToDelete });
}}
/>
</div>
{showImpersonateModal && selectedUser && (
<Dialog open={showImpersonateModal} onOpenChange={() => setShowImpersonateModal(false)}>
<DialogContent type="creation" title={t("impersonate")} description={t("impersonation_user_tip")}>
<form
onSubmit={async (e) => {
e.preventDefault();
await signIn("impersonation-auth", { redirect: false, username: selectedUser });
setShowImpersonateModal(false);
router.replace("/settings/my-account/profile");
}}>
<DialogFooter showDivider className="mt-8">
<DialogClose color="secondary">{t("cancel")}</DialogClose>
<Button color="primary" type="submit">
{t("impersonate")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</>
);
}
const DeleteUserDialog = ({
user,
onConfirm,
onClose,
}: {
user: number | null;
onConfirm: () => void;
onClose: () => void;
}) => {
return (
// eslint-disable-next-line @typescript-eslint/no-empty-function -- noop
<Dialog name="delete-user" open={!!user} onOpenChange={(open) => (open ? () => {} : onClose())}>
<ConfirmationDialogContent
title="Delete User"
confirmBtnText="Delete"
cancelBtnText="Cancel"
variety="danger"
onConfirm={onConfirm}>
<p>Are you sure you want to delete this user?</p>
</ConfirmationDialogContent>
</Dialog>
);
};
export const UsersTable = withLicenseRequired(UsersTableBare);