* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main: - Rebrand Cal.com to Cal.diy across the entire codebase - Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions - Switch license from AGPL-3.0 to MIT - Remove docs/ directory (migrated to Nextra at cal.diy) - Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc. - Clean up .env.example for self-hosted Cal.diy - Update Docker image references to calcom/cal.diy - Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork - Add PR welcome bot for Cal.diy contributors - Fix API v2 breaking changes oasdiff ignore entries - Replace Blacksmith CI runners with default GitHub Actions 3893 files changed, 20789 insertions(+), 411020 deletions(-) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * rip out org related comments in api v2 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
242 lines
8.3 KiB
TypeScript
242 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
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 } from "@calcom/ui/components/avatar";
|
|
import { Badge } from "@calcom/ui/components/badge";
|
|
import { Button } from "@calcom/ui/components/button";
|
|
import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog";
|
|
import { TextField } from "@calcom/ui/components/form";
|
|
import { DropdownActions, Table } from "@calcom/ui/components/table";
|
|
import { showToast } from "@calcom/ui/components/toast";
|
|
import { keepPreviousData } from "@tanstack/react-query";
|
|
import { useRouter } from "next/navigation";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
const { Cell, ColumnTitle, Header, Row } = Table;
|
|
|
|
const FETCH_LIMIT = 25;
|
|
|
|
export function UsersTable() {
|
|
const { t } = useLocale();
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
const utils = trpc.useUtils();
|
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
|
const router = useRouter();
|
|
|
|
const [userToDelete, setUserToDelete] = useState<number | null>(null);
|
|
|
|
const deleteMutation = trpc.viewer.users.delete.useMutation({
|
|
onSuccess: async () => {
|
|
showToast(t("user_has_been_deleted"), "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.filter((row) => row.id !== userToDelete),
|
|
})),
|
|
};
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
console.error(err.message);
|
|
showToast(t("error_deleting_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(t("password_reset_email_sent"), "success");
|
|
},
|
|
});
|
|
|
|
const removeTwoFactor = trpc.viewer.admin.removeTwoFactor.useMutation({
|
|
onSuccess: () => {
|
|
showToast(t("two_factor_removed"), "success");
|
|
},
|
|
});
|
|
|
|
const lockUserAccount = trpc.viewer.admin.lockUserAccount.useMutation({
|
|
onSuccess: ({ locked }) => {
|
|
showToast(locked ? t("user_locked") : t("user_unlocked"), "success");
|
|
utils.viewer.admin.listPaginated.invalidate();
|
|
},
|
|
});
|
|
|
|
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]);
|
|
const totalRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
|
|
const totalFetched = flatData.length;
|
|
|
|
const fetchMoreOnBottomReached = useCallback(
|
|
(containerRefElement?: HTMLDivElement | null) => {
|
|
if (containerRefElement) {
|
|
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
|
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalRowCount) {
|
|
fetchNextPage();
|
|
}
|
|
}
|
|
},
|
|
[fetchNextPage, isFetching, totalFetched, totalRowCount]
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchMoreOnBottomReached(tableContainerRef.current);
|
|
}, [fetchMoreOnBottomReached]);
|
|
|
|
return (
|
|
<div>
|
|
<TextField
|
|
placeholder="username or email"
|
|
label={t("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">{t("user")}</ColumnTitle>
|
|
<ColumnTitle>{t("timezone")}</ColumnTitle>
|
|
<ColumnTitle>{t("role")}</ColumnTitle>
|
|
<ColumnTitle widthClassNames="w-auto">
|
|
<span className="sr-only">{t("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"}`}
|
|
imageSrc={`${WEBAPP_URL}/${user.username}/avatar.png`}
|
|
/>
|
|
<div className="text-subtle ml-4 font-medium">
|
|
<div className="flex gap-3">
|
|
<span className="text-default">{user.name}</span>
|
|
<span>/{user.username}</span>
|
|
{user.locked && <Badge variant="red">{t("locked")}</Badge>}
|
|
</div>
|
|
<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: t("edit"),
|
|
href: `/settings/admin/users/${user.id}/edit`,
|
|
icon: "pencil" as const,
|
|
},
|
|
{
|
|
id: "reset-password",
|
|
label: t("reset_password"),
|
|
onClick: () => sendPasswordResetEmail.mutate({ userId: user.id }),
|
|
icon: "lock" as const,
|
|
},
|
|
{
|
|
id: "lock-user",
|
|
label: user.locked ? t("unlock_user_account") : t("lock_user_account"),
|
|
onClick: () => lockUserAccount.mutate({ userId: user.id, locked: !user.locked }),
|
|
icon: "lock" as const,
|
|
},
|
|
{
|
|
id: "remove-2fa",
|
|
label: t("remove_2fa"),
|
|
color: "destructive" as const,
|
|
onClick: () => removeTwoFactor.mutate({ userId: user.id }),
|
|
icon: "shield" as const,
|
|
},
|
|
{
|
|
id: "delete",
|
|
label: t("delete"),
|
|
color: "destructive" as const,
|
|
onClick: () => setUserToDelete(user.id),
|
|
icon: "trash" as const,
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</Cell>
|
|
</Row>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
<DeleteUserDialog
|
|
user={userToDelete}
|
|
onClose={() => setUserToDelete(null)}
|
|
onConfirm={() => {
|
|
if (!userToDelete) return;
|
|
deleteMutation.mutate({ userId: userToDelete });
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeleteUserDialog({
|
|
user,
|
|
onConfirm,
|
|
onClose,
|
|
}: {
|
|
user: number | null;
|
|
onConfirm: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useLocale();
|
|
return (
|
|
<Dialog open={!!user} onOpenChange={(open) => (open ? undefined : onClose())}>
|
|
<ConfirmationDialogContent
|
|
title={t("delete_user")}
|
|
confirmBtnText={t("delete")}
|
|
cancelBtnText={t("cancel")}
|
|
variety="danger"
|
|
onConfirm={onConfirm}>
|
|
<p>{t("delete_user_confirmation")}</p>
|
|
</ConfirmationDialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|