* 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>
312 lines
11 KiB
TypeScript
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);
|