Skip to content

Commit 5a59bb8

Browse files
Udit-takkarCarinaWolli
andauthored
feat: blocklist table (calcom#24459)
* feat: blocklist table * feat: blocklist table * refactor: feedback * chore: add select * UI improvements * chore: remove un unsed --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com>
1 parent 17ab088 commit 5a59bb8

30 files changed

Lines changed: 2209 additions & 24 deletions

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
9898
]
9999
: []),
100100
{
101-
name: "privacy",
101+
name: "privacy_and_security",
102102
href: "/settings/organizations/privacy",
103103
},
104104

@@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
170170
// The following keys are assigned to admin only
171171
const adminRequiredKeys = ["admin"];
172172
const organizationRequiredKeys = ["organization"];
173-
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
173+
const organizationAdminKeys = [
174+
"privacy",
175+
"privacy_and_security",
176+
"OAuth Clients",
177+
"SSO",
178+
"directory_sync",
179+
"delegation_credential",
180+
];
174181

175182
export interface SettingsPermissions {
176183
canViewRoles?: boolean;

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,16 @@ import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
1111

1212
export const generateMetadata = async () =>
1313
await _generateMetadata(
14-
(t) => t("privacy"),
14+
(t) => t("privacy_and_security"),
1515
(t) => t("privacy_organization_description"),
1616
undefined,
1717
undefined,
1818
"/settings/organizations/privacy"
1919
);
2020

2121
const Page = async () => {
22-
const t = await getTranslate();
23-
2422
const session = await validateUserHasOrg();
23+
const t = await getTranslate();
2524

2625
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
2726
return redirect("/settings/profile");
@@ -42,13 +41,31 @@ const Page = async () => {
4241
},
4342
});
4443

44+
const watchlistPermissions = await getResourcePermissions({
45+
userId: session.user.id,
46+
teamId: session.user.profile.organizationId,
47+
resource: Resource.Watchlist,
48+
userRole: session.user.org.role,
49+
fallbackRoles: {
50+
read: {
51+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
52+
},
53+
create: {
54+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
55+
},
56+
delete: {
57+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
58+
},
59+
},
60+
});
61+
4562
if (!canRead) {
4663
return redirect("/settings/profile");
4764
}
4865

4966
return (
50-
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
51-
<PrivacyView permissions={{ canRead, canEdit }} />
67+
<SettingsHeader title={t("privacy_and_security")} description={t("privacy_organization_description")}>
68+
<PrivacyView permissions={{ canRead, canEdit }} watchlistPermissions={watchlistPermissions} />
5269
</SettingsHeader>
5370
);
5471
};
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"use client";
2+
3+
import { keepPreviousData } from "@tanstack/react-query";
4+
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
5+
import { useMemo, useState } from "react";
6+
7+
import { DataTableWrapper, DataTableToolbar, useDataTable } from "@calcom/features/data-table";
8+
import { useLocale } from "@calcom/lib/hooks/useLocale";
9+
import { trpc } from "@calcom/trpc";
10+
import type { RouterOutputs } from "@calcom/trpc/react";
11+
import { Badge } from "@calcom/ui/components/badge";
12+
import { Button } from "@calcom/ui/components/button";
13+
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
14+
import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog";
15+
import { showToast } from "@calcom/ui/components/toast";
16+
17+
import { BlocklistEntryDetailsSheet } from "./components/blocklist-entry-details-sheet";
18+
import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal";
19+
20+
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
21+
22+
interface BlocklistTableProps {
23+
permissions?: {
24+
canRead: boolean;
25+
canCreate: boolean;
26+
canDelete: boolean;
27+
};
28+
}
29+
30+
export function BlocklistTable({ permissions }: BlocklistTableProps) {
31+
const { t } = useLocale();
32+
const { limit, offset, searchTerm } = useDataTable();
33+
34+
const [showCreateModal, setShowCreateModal] = useState(false);
35+
const [showDetailsSheet, setShowDetailsSheet] = useState(false);
36+
const [selectedEntry, setSelectedEntry] = useState<BlocklistEntry | null>(null);
37+
const [entryToDelete, setEntryToDelete] = useState<BlocklistEntry | null>(null);
38+
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
39+
40+
const { data, isPending } = trpc.viewer.organizations.listWatchlistEntries.useQuery(
41+
{
42+
limit,
43+
offset,
44+
searchTerm,
45+
},
46+
{
47+
placeholderData: keepPreviousData,
48+
}
49+
);
50+
51+
const utils = trpc.useUtils();
52+
53+
const deleteWatchlistEntry = trpc.viewer.organizations.deleteWatchlistEntry.useMutation({
54+
onSuccess: async () => {
55+
await utils.viewer.organizations.listWatchlistEntries.invalidate();
56+
showToast(t("blocklist_entry_deleted"), "success");
57+
setShowDeleteDialog(false);
58+
setEntryToDelete(null);
59+
},
60+
onError: (error) => {
61+
showToast(error.message, "error");
62+
},
63+
});
64+
65+
const handleDelete = (entry: BlocklistEntry) => {
66+
setEntryToDelete(entry);
67+
setShowDeleteDialog(true);
68+
};
69+
70+
const confirmDelete = () => {
71+
if (entryToDelete) {
72+
deleteWatchlistEntry.mutate({ id: entryToDelete.id });
73+
}
74+
};
75+
76+
const handleViewDetails = (entry: BlocklistEntry) => {
77+
setSelectedEntry(entry);
78+
setShowDetailsSheet(true);
79+
};
80+
81+
const totalRowCount = data?.meta?.totalRowCount ?? 0;
82+
const flatData = useMemo<BlocklistEntry[]>(() => data?.rows ?? [], [data]);
83+
84+
const columns = useMemo<ColumnDef<BlocklistEntry>[]>(
85+
() => [
86+
{
87+
id: "value",
88+
header: t("value"),
89+
accessorKey: "value",
90+
enableHiding: false,
91+
cell: ({ row }) => <span className="text-emphasis">{row.original.value}</span>,
92+
},
93+
{
94+
id: "type",
95+
header: t("type"),
96+
accessorKey: "type",
97+
size: 100,
98+
cell: ({ row }) => (
99+
<Badge variant="blue">{row.original.type === "EMAIL" ? t("email") : t("domain")}</Badge>
100+
),
101+
},
102+
{
103+
id: "createdBy",
104+
header: t("blocked_by"),
105+
size: 180,
106+
cell: ({ row }) => {
107+
const audit = row.original.audits?.[0] as
108+
| { changedByUserId: number | null }
109+
| {
110+
changedByUser?: { id: number; email: string; name: string | null } | undefined;
111+
changedByUserId: number | null;
112+
}
113+
| undefined;
114+
const email =
115+
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
116+
return <span className="text-default">{email ?? "—"}</span>;
117+
},
118+
},
119+
{
120+
id: "actions",
121+
header: "",
122+
size: 120,
123+
enableHiding: false,
124+
enableSorting: false,
125+
enableResizing: false,
126+
cell: ({ row }) => {
127+
const entry = row.original;
128+
return (
129+
<div className="flex items-center justify-end">
130+
<ButtonGroup combined containerProps={{ className: "border-default" }}>
131+
<Button
132+
color="secondary"
133+
variant="icon"
134+
StartIcon="eye"
135+
onClick={() => handleViewDetails(entry)}
136+
tooltip={t("view")}
137+
/>
138+
{permissions?.canDelete && (
139+
<Button
140+
color="destructive"
141+
variant="icon"
142+
StartIcon="trash"
143+
onClick={() => handleDelete(entry)}
144+
tooltip={t("delete")}
145+
/>
146+
)}
147+
</ButtonGroup>
148+
</div>
149+
);
150+
},
151+
},
152+
],
153+
[t, permissions?.canDelete]
154+
);
155+
156+
const table = useReactTable({
157+
data: flatData,
158+
columns,
159+
getCoreRowModel: getCoreRowModel(),
160+
getSortedRowModel: getSortedRowModel(),
161+
manualPagination: true,
162+
pageCount: Math.ceil(totalRowCount / limit),
163+
});
164+
165+
return (
166+
<>
167+
<DataTableWrapper
168+
table={table}
169+
isPending={isPending}
170+
variant="default"
171+
paginationMode="standard"
172+
totalRowCount={totalRowCount}>
173+
<div className="flex items-center justify-between">
174+
<DataTableToolbar.SearchBar />
175+
<div className="flex items-center gap-2">
176+
{permissions?.canCreate && (
177+
<Button color="primary" StartIcon="plus" onClick={() => setShowCreateModal(true)}>
178+
{t("create_block_entry")}
179+
</Button>
180+
)}
181+
</div>
182+
</div>
183+
</DataTableWrapper>
184+
185+
<CreateBlocklistEntryModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} />
186+
187+
<BlocklistEntryDetailsSheet
188+
entry={selectedEntry}
189+
isOpen={showDetailsSheet}
190+
onClose={() => {
191+
setShowDetailsSheet(false);
192+
setSelectedEntry(null);
193+
}}
194+
/>
195+
196+
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
197+
<ConfirmationDialogContent
198+
variety="danger"
199+
title={t("delete_blocklist_entry")}
200+
confirmBtnText={t("delete")}
201+
onConfirm={confirmDelete}>
202+
{t("delete_blocklist_entry_confirmation", { value: entryToDelete?.value })}
203+
</ConfirmationDialogContent>
204+
</Dialog>
205+
</>
206+
);
207+
}

0 commit comments

Comments
 (0)