diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/blocklist/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/blocklist/page.tsx new file mode 100644 index 0000000000..436fb7ef57 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/blocklist/page.tsx @@ -0,0 +1,24 @@ +import { _generateMetadata, getTranslate } from "app/_utils"; + +import SystemBlocklistView from "@calcom/features/ee/admin/pages/settings/blocklist"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("system_blocklist"), + (t) => t("system_blocklist_description"), + undefined, + undefined, + "/settings/admin/blocklist" + ); + +const Page = async () => { + const t = await getTranslate(); + return ( + + + + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index ac36a33164..a861c2d51c 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -240,6 +240,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { href: "/settings/admin/lockedSMS", trackingMetadata: { section: "admin", page: "locked_sms" }, }, + { + name: "pbac_resource_blocklist", + href: "/settings/admin/blocklist", + trackingMetadata: { section: "admin", page: "blocklist" }, + }, { name: "oAuth", href: "/settings/admin/oAuth", diff --git a/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx b/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx index f3f1900e63..da9fad6b73 100644 --- a/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx +++ b/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx @@ -1,16 +1,21 @@ "use client"; +import { keepPreviousData } from "@tanstack/react-query"; import { useState } from "react"; +import { + BlockedEntriesTable, + CreateBlocklistEntryModal, + PendingReportsBadge, + PendingReportsTable, +} from "@calcom/features/blocklist"; +import { useDataTable } from "@calcom/features/data-table"; import { DataTableToolbar } from "~/data-table/components"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; import { ToggleGroup } from "@calcom/ui/components/form"; - -import PendingReportsBadge from "./components/PendingReportsBadge"; -import { BlockedEntriesTable } from "./components/blocked-entries-table"; -import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal"; -import { PendingReportsTable } from "./components/pending-reports-table"; +import { showToast } from "@calcom/ui/components/toast"; type ViewType = "blocked" | "pending"; @@ -24,8 +29,86 @@ interface BlocklistTableProps { export function BlocklistTable({ permissions }: BlocklistTableProps) { const { t } = useLocale(); + const { limit, offset, searchTerm } = useDataTable(); const [activeView, setActiveView] = useState("blocked"); const [showCreateModal, setShowCreateModal] = useState(false); + const [selectedEntryId, setSelectedEntryId] = useState(null); + + const utils = trpc.useUtils(); + + const { data: blockedData, isPending: isBlockedPending } = + trpc.viewer.organizations.listWatchlistEntries.useQuery( + { limit, offset, searchTerm }, + { placeholderData: keepPreviousData, enabled: activeView === "blocked" } + ); + + const { data: reportsData, isPending: isReportsPending } = + trpc.viewer.organizations.listBookingReports.useQuery( + { + limit, + offset, + searchTerm, + filters: { hasWatchlist: false, status: ["PENDING"] }, + }, + { placeholderData: keepPreviousData, enabled: activeView === "pending" } + ); + + const { data: pendingReportsCount } = + trpc.viewer.organizations.pendingReportsCount.useQuery(); + + const { data: entryDetails, isLoading: isDetailsLoading } = + trpc.viewer.organizations.getWatchlistEntryDetails.useQuery( + { id: selectedEntryId ?? "" }, + { enabled: !!selectedEntryId } + ); + + const createEntry = + trpc.viewer.organizations.createWatchlistEntry.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listWatchlistEntries.invalidate(); + showToast(t("blocklist_entry_created"), "success"); + setShowCreateModal(false); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const deleteEntry = + trpc.viewer.organizations.deleteWatchlistEntry.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listWatchlistEntries.invalidate(); + setSelectedEntryId(null); + showToast(t("blocklist_entry_deleted"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const addToWatchlist = trpc.viewer.organizations.addToWatchlist.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listBookingReports.invalidate(); + await utils.viewer.organizations.listWatchlistEntries.invalidate(); + await utils.viewer.organizations.pendingReportsCount.invalidate(); + showToast(t("blocklist_entry_created"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const dismissReport = + trpc.viewer.organizations.dismissBookingReport.useMutation({ + onSuccess: async () => { + await utils.viewer.organizations.listBookingReports.invalidate(); + await utils.viewer.organizations.pendingReportsCount.invalidate(); + showToast(t("booking_report_dismissed"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); return ( <> @@ -43,7 +126,7 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) { label: ( {t("pending")} - + ), }, @@ -53,7 +136,11 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) {
{permissions?.canCreate && ( - )} @@ -61,12 +148,46 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) {
{activeView === "blocked" ? ( - setShowCreateModal(true)} /> + setShowCreateModal(true)} + onDelete={(entry) => deleteEntry.mutate({ id: entry.id })} + isDeleting={deleteEntry.isPending} + detailsQuery={{ data: entryDetails, isLoading: isDetailsLoading }} + selectedEntryId={selectedEntryId ?? undefined} + onSelectEntry={setSelectedEntryId} + /> ) : ( - + + addToWatchlist.mutate({ reportIds, type }, { onSuccess }) + } + onDismiss={(reportId, onSuccess) => + dismissReport.mutate({ reportId }, { onSuccess }) + } + isAddingToBlocklist={addToWatchlist.isPending} + isDismissing={dismissReport.isPending} + /> )} - setShowCreateModal(false)} /> + setShowCreateModal(false)} + scope="organization" + onCreateEntry={(data) => createEntry.mutate(data)} + isPending={createEntry.isPending} + /> ); } diff --git a/apps/web/modules/ee/organizations/privacy/components/PendingReportsBadge.tsx b/apps/web/modules/ee/organizations/privacy/components/PendingReportsBadge.tsx deleted file mode 100644 index aec0f3781e..0000000000 --- a/apps/web/modules/ee/organizations/privacy/components/PendingReportsBadge.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { trpc } from "@calcom/trpc/react"; -import { Badge } from "@calcom/ui/components/badge"; - -export default function PendingReportsBadge() { - const { data: pendingReportsCount } = trpc.viewer.organizations.pendingReportsCount.useQuery(); - if (!pendingReportsCount) return null; - return ( - - {pendingReportsCount} - - ); -} diff --git a/apps/web/modules/ee/organizations/privacy/components/blocked-entries-columns.tsx b/apps/web/modules/ee/organizations/privacy/components/blocked-entries-columns.tsx deleted file mode 100644 index 1f876cbbab..0000000000 --- a/apps/web/modules/ee/organizations/privacy/components/blocked-entries-columns.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table"; -import { useMemo } from "react"; - -import type { RouterOutputs } from "@calcom/trpc/react"; -import { Badge } from "@calcom/ui/components/badge"; -import { Button } from "@calcom/ui/components/button"; -import { - Dropdown, - DropdownItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@calcom/ui/components/dropdown"; - -type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number]; - -interface UseBlockedEntriesColumnsProps { - t: (key: string) => string; - canDelete?: boolean; - onViewDetails: (entry: BlocklistEntry) => void; - onDelete: (entry: BlocklistEntry) => void; -} - -export function useBlockedEntriesColumns({ - t, - canDelete, - onViewDetails, - onDelete, -}: UseBlockedEntriesColumnsProps) { - return useMemo[]>( - () => [ - { - id: "email_slash_domain", - header: t("email_slash_domain"), - accessorKey: "value", - enableHiding: false, - cell: ({ row }) => {row.original.value}, - }, - { - id: "type", - header: t("type"), - accessorKey: "type", - size: 100, - cell: ({ row }) => ( - {row.original.type === "EMAIL" ? t("email") : t("domain")} - ), - }, - { - id: "createdBy", - header: t("blocked_by"), - size: 180, - cell: ({ row }) => { - const audit = row.original.latestAudit as - | { changedByUserId: number | null } - | { - changedByUser?: { id: number; email: string; name: string | null } | undefined; - changedByUserId: number | null; - } - | null; - const email = - (audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined; - return {email ?? "—"}; - }, - }, - { - id: "actions", - header: "", - size: 90, - minSize: 90, - maxSize: 90, - enableHiding: false, - enableSorting: false, - enableResizing: false, - cell: ({ row }) => { - const entry = row.original; - return ( -
- - -
- ); - }, - }, - ], - [t, canDelete, onViewDetails, onDelete] - ); -} diff --git a/apps/web/modules/ee/organizations/privacy/components/blocked-entries-table.tsx b/apps/web/modules/ee/organizations/privacy/components/blocked-entries-table.tsx deleted file mode 100644 index 6da3f56b9a..0000000000 --- a/apps/web/modules/ee/organizations/privacy/components/blocked-entries-table.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client"; - -import { keepPreviousData } from "@tanstack/react-query"; -import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; -import { useMemo, useState } from "react"; - -import { useDataTable } from "@calcom/features/data-table"; -import { DataTableWrapper } from "~/data-table/components"; -import { IS_CALCOM } from "@calcom/lib/constants"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc/react"; -import type { RouterOutputs } from "@calcom/trpc/react"; -import { Button } from "@calcom/ui/components/button"; -import { ConfirmationDialogContent, Dialog, DialogClose } from "@calcom/ui/components/dialog"; -import { EmptyScreen } from "@calcom/ui/components/empty-screen"; -import { showToast } from "@calcom/ui/components/toast"; - -import { useBlockedEntriesColumns } from "./blocked-entries-columns"; -import { BlocklistEntryDetailsSheet } from "./blocklist-entry-details-sheet"; - -type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number]; - -interface BlockedEntriesTableProps { - permissions?: { - canRead: boolean; - canCreate: boolean; - canDelete: boolean; - }; - onAddClick: () => void; -} - -export function BlockedEntriesTable({ permissions, onAddClick }: BlockedEntriesTableProps) { - const { t } = useLocale(); - const { limit, offset, searchTerm } = useDataTable(); - - const [showDetailsSheet, setShowDetailsSheet] = useState(false); - const [selectedEntry, setSelectedEntry] = useState(null); - const [entryToDelete, setEntryToDelete] = useState(null); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - const { data, isPending } = trpc.viewer.organizations.listWatchlistEntries.useQuery( - { - limit, - offset, - searchTerm, - }, - { - placeholderData: keepPreviousData, - } - ); - - const utils = trpc.useUtils(); - - const deleteWatchlistEntry = trpc.viewer.organizations.deleteWatchlistEntry.useMutation({ - onSuccess: async () => { - await utils.viewer.organizations.listWatchlistEntries.invalidate(); - showToast(t("blocklist_entry_deleted"), "success"); - setShowDeleteDialog(false); - setEntryToDelete(null); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const handleViewDetails = (entry: BlocklistEntry) => { - setSelectedEntry(entry); - setShowDetailsSheet(true); - }; - - const handleDelete = (entry: BlocklistEntry) => { - setEntryToDelete(entry); - setShowDeleteDialog(true); - }; - - const confirmDelete = () => { - if (entryToDelete) { - deleteWatchlistEntry.mutate({ id: entryToDelete.id }); - } - }; - - const columns = useBlockedEntriesColumns({ - t, - canDelete: permissions?.canDelete, - onViewDetails: handleViewDetails, - onDelete: handleDelete, - }); - - const totalRowCount = data?.meta?.totalRowCount ?? 0; - const flatData = useMemo(() => data?.rows ?? [], [data]); - - const table = useReactTable({ - data: flatData, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - manualPagination: true, - pageCount: Math.ceil(totalRowCount / limit), - }); - - return ( - <> - } - headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("pbac_resource_blocklist")} - description={t("add_people_to_blocklist")} - className="bg-muted mb-16" - iconWrapperClassName="bg-default" - dashedBorder={false} - buttonRaw={ -
- - {IS_CALCOM && ( - - )} -
- } - /> - } - totalRowCount={totalRowCount} - /> - - { - setShowDetailsSheet(false); - setSelectedEntry(null); - }} - handleDeleteBlocklistEntry={handleDelete} - /> - - - - {t("remove")} - - }> - {t("remove_value_from_blocklist_description")} - - - - ); -} diff --git a/apps/web/modules/ee/organizations/privacy/components/pending-reports-columns.tsx b/apps/web/modules/ee/organizations/privacy/components/pending-reports-columns.tsx deleted file mode 100644 index bfd822ce91..0000000000 --- a/apps/web/modules/ee/organizations/privacy/components/pending-reports-columns.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table"; -import { useMemo } from "react"; - -import type { RouterOutputs } from "@calcom/trpc/react"; -import { Badge } from "@calcom/ui/components/badge"; -import { Button } from "@calcom/ui/components/button"; -import { - Dropdown, - DropdownItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@calcom/ui/components/dropdown"; - -type BookingReport = RouterOutputs["viewer"]["organizations"]["listBookingReports"]["rows"][number]; - -interface UsePendingReportsColumnsProps { - t: (key: string) => string; - onViewDetails: (entry: BookingReport) => void; -} - -export function usePendingReportsColumns({ t, onViewDetails }: UsePendingReportsColumnsProps) { - return useMemo[]>( - () => [ - { - id: "emailOrDomain", - header: t("email_slash_domain"), - accessorKey: "bookerEmail", - enableHiding: false, - size: 250, - cell: ({ row }) => { - const email = row.original.bookerEmail; - return ( -
- {email} -
- ); - }, - }, - { - id: "reportedBy", - header: t("reported_by"), - accessorFn: (row) => row.reporter?.email ?? "-", - size: 250, - cell: ({ row }) => {row.original.reporter?.email ?? "-"}, - }, - { - id: "reason", - header: t("reason"), - size: 150, - cell: ({ row }) => { - const reason = t(row.original.reason.toLowerCase()); - const capitalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1); - - return {capitalizedReason}; - }, - }, - { - id: "actions", - header: "", - size: 90, - minSize: 90, - maxSize: 90, - enableHiding: false, - enableSorting: false, - enableResizing: false, - cell: ({ row }) => { - const entry = row.original; - return ( -
- - -
- ); - }, - }, - ], - [t, onViewDetails] - ); -} diff --git a/apps/web/modules/ee/organizations/privacy/components/pending-reports-table.tsx b/apps/web/modules/ee/organizations/privacy/components/pending-reports-table.tsx deleted file mode 100644 index d4ab742aa3..0000000000 --- a/apps/web/modules/ee/organizations/privacy/components/pending-reports-table.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client"; - -import { keepPreviousData } from "@tanstack/react-query"; -import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; -import { useMemo, useState } from "react"; - -import { useDataTable } from "@calcom/features/data-table"; -import { DataTableWrapper } from "~/data-table/components"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc/react"; -import type { RouterOutputs } from "@calcom/trpc/react"; -import { EmptyScreen } from "@calcom/ui/components/empty-screen"; - -import { BookingReportEntryDetailsModal } from "./booking-report-entry-details-modal"; -import { usePendingReportsColumns } from "./pending-reports-columns"; - -type BookingReport = RouterOutputs["viewer"]["organizations"]["listBookingReports"]["rows"][number]; - -export function PendingReportsTable() { - const { t } = useLocale(); - const { limit, offset, searchTerm } = useDataTable(); - - const [showReviewDialog, setShowReviewDialog] = useState(false); - const [selectedReport, setSelectedReport] = useState(null); - - const { data, isPending } = trpc.viewer.organizations.listBookingReports.useQuery( - { - limit, - offset, - searchTerm, - filters: { hasWatchlist: false, status: ["PENDING"] }, - }, - { - placeholderData: keepPreviousData, - } - ); - - const handleViewDetails = (entry: BookingReport) => { - setSelectedReport(entry); - setShowReviewDialog(true); - }; - - const columns = usePendingReportsColumns({ - t, - onViewDetails: handleViewDetails, - }); - - const totalRowCount = data?.meta?.totalRowCount ?? 0; - const flatData = useMemo(() => data?.rows ?? [], [data]); - - const table = useReactTable({ - data: flatData, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - manualPagination: true, - pageCount: Math.ceil(totalRowCount / limit), - }); - - return ( - <> - } - headline={t("no_pending_reports")} - className="bg-muted mb-16" - iconWrapperClassName="bg-default" - dashedBorder={false} - /> - } - totalRowCount={totalRowCount} - /> - - { - setShowReviewDialog(false); - setSelectedReport(null); - }} - /> - - ); -} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a28f369c92..a72987685c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1029,6 +1029,7 @@ "teams": "Teams", "team": "Team", "organization": "Organization", + "org": "Org", "team_billing": "Team Billing", "team_billing_description": "Manage billing for your team", "upgrade_to_flexible_pro_title": "We've changed billing for teams", @@ -4010,6 +4011,36 @@ "block_all_from_domain": "All from this domain", "review_report": "Review report", "block": "Block", + "system_blocklist": "System Blocklist", + "system_blocklist_description": "Manage blocked emails and domains for all bookings on the platform", + "system_blocklist_entry_created": "System blocklist entry created successfully", + "system_blocklist_entry_deleted": "System blocklist entry deleted successfully", + "blocked_by_system_admin": "Blocked By (System Admin)", + "add_to_system_blocklist": "Add to System Blocklist", + "remove_from_system_blocklist": "Remove from System Blocklist", + "add_people_to_system_blocklist": "Block emails and domains platform-wide for all users and teams", + "remove_value_from_system_blocklist": "Remove {{value}} from system blocklist?", + "remove_value_from_system_blocklist_description": "This will unblock the entry platform-wide for all users and teams. This action is irreversible.", + "system_wide": "System-wide", + "system_blocked": "System blocked", + "system_wide_all_organizations": "System-wide", + "system_wide_blocklist_warning": "System-wide blocklist entry", + "system_wide_blocklist_warning_description": "This entry will block bookings for All users and teams on the platform.", + "source": "Source", + "manual": "Manual", + "free_domain_policy": "Free Domain Policy", + "automatic": "Automatic", + "scope": "Scope", + "bulk_delete": "Delete", + "bulk_dont_block": "Don't Block", + "entries_deleted_successfully": "{{count}} entries deleted successfully", + "entries_deleted_with_errors": "{{success}} entries deleted, {{failed}} failed", + "reports_dismissed_successfully": "{{count}} reports dismissed successfully", + "reports_dismissed_with_errors": "{{success}} reports dismissed, {{failed}} failed", + "remove_entries_from_system_blocklist": "Remove entries from system blocklist", + "remove_entries_confirm": "Are you sure you want to remove {{count}} entries from the system blocklist? This will unblock them platform-wide for all users and teams. This action cannot be undone.", + "dismiss_reports_title": "Dismiss reports", + "dismiss_reports_confirm": "Are you sure you want to dismiss {{count}} reports? They will no longer appear in the pending reports list.", "onboarding_org_details_title": "Add your Organization's details", "onboarding_org_details_subtitle": "Customize your booking pages with your Organization's name and bio", "onboarding_org_bio_label": "Organization bio", diff --git a/packages/features/blocklist/components/BlockedEntriesColumns.tsx b/packages/features/blocklist/components/BlockedEntriesColumns.tsx new file mode 100644 index 0000000000..73271abc18 --- /dev/null +++ b/packages/features/blocklist/components/BlockedEntriesColumns.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { useMemo } from "react"; + +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@calcom/ui/components/dropdown"; +import { Checkbox } from "@calcom/ui/components/form"; + +import type { BlocklistEntry, BlocklistScope } from "../types"; + +interface UseBlockedEntriesColumnsProps { + t: (key: string) => string; + scope: BlocklistScope; + canDelete?: boolean; + enableRowSelection?: boolean; + onViewDetails: (entry: T) => void; + onDelete: (entry: T) => void; +} + +export function useBlockedEntriesColumns({ + t, + scope, + canDelete = true, + enableRowSelection = false, + onViewDetails, + onDelete, +}: UseBlockedEntriesColumnsProps) { + const isSystem = scope === "system"; + + return useMemo[]>(() => { + const columns: ColumnDef[] = []; + + if (enableRowSelection) { + columns.push({ + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 24, + minSize: 24, + maxSize: 24, + }); + } + + columns.push( + { + id: "email_slash_domain", + header: t("email_slash_domain"), + accessorKey: "value", + enableHiding: false, + cell: ({ row }) => ( +
+ {row.original.value} + {row.original.isGlobal && !isSystem && ( + + {t("system_blocked")} + + )} +
+ ), + }, + { + id: "type", + header: t("type"), + accessorKey: "type", + size: 100, + cell: ({ row }) => ( + {row.original.type === "EMAIL" ? t("email") : t("domain")} + ), + } + ); + + if (isSystem) { + columns.push({ + id: "source", + header: t("source"), + accessorKey: "source", + size: 120, + cell: ({ row }) => { + const source = row.original.source; + let label = t("manual"); + if (source === "FREE_DOMAIN_POLICY") { + label = t("free_domain_policy"); + } + return {label}; + }, + }); + } + + columns.push({ + id: "createdBy", + header: t("blocked_by"), + size: 180, + cell: ({ row }) => { + const audit = row.original.latestAudit; + const email = audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined; + return {email ?? "—"}; + }, + }); + + columns.push({ + id: "actions", + header: "", + size: 90, + minSize: 90, + maxSize: 90, + enableHiding: false, + enableSorting: false, + enableResizing: false, + cell: ({ row }) => { + const entry = row.original; + const isEntryReadOnly = entry.isReadOnly === true; + const showDeleteOption = canDelete && !isEntryReadOnly; + return ( +
+ + +
+ ); + }, + }); + + return columns; + }, [t, scope, isSystem, canDelete, enableRowSelection, onViewDetails, onDelete]); +} diff --git a/packages/features/blocklist/components/BlockedEntriesTable.tsx b/packages/features/blocklist/components/BlockedEntriesTable.tsx new file mode 100644 index 0000000000..5e701e6e11 --- /dev/null +++ b/packages/features/blocklist/components/BlockedEntriesTable.tsx @@ -0,0 +1,217 @@ +"use client"; + +import type { RowSelectionState } from "@tanstack/react-table"; +import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; +import type { ReactNode } from "react"; +import { useMemo, useState } from "react"; + +import { DataTableSelectionBar, DataTableWrapper } from "~/data-table/components"; +import { IS_CALCOM } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; + +import type { BlocklistEntry, BlocklistPermissions, BlocklistScope } from "../types"; +import { useBlockedEntriesColumns } from "./BlockedEntriesColumns"; +import { BlocklistEntryDetailsSheet } from "./BlocklistEntryDetailsSheet"; + +export interface BlockedEntriesTableProps { + scope: BlocklistScope; + data: T[]; + totalRowCount: number; + isPending: boolean; + limit: number; + searchTerm?: string; + permissions?: BlocklistPermissions; + onAddClick: () => void; + onDelete: (entry: T) => void; + isDeleting?: boolean; + detailsQuery?: { + data?: { + entry: { + id: string; + value: string; + type: import("@calcom/prisma/enums").WatchlistType; + description: string | null; + source?: string; + bookingReports?: Array<{ booking: { uid: string; title: string | null } }>; + }; + auditHistory: Array<{ + id: string; + value: string; + changedAt: Date | string; + changedByUser?: { name: string | null; email: string } | null; + }>; + }; + isLoading: boolean; + }; + selectedEntryId?: string; + onSelectEntry?: (id: string | null) => void; + enableRowSelection?: boolean; + renderBulkActions?: (selectedEntries: T[], clearSelection: () => void) => ReactNode; +} + +export function BlockedEntriesTable({ + scope, + data, + totalRowCount, + isPending, + limit, + searchTerm, + permissions, + onAddClick, + onDelete, + isDeleting = false, + detailsQuery, + selectedEntryId, + onSelectEntry, + enableRowSelection = false, + renderBulkActions, +}: BlockedEntriesTableProps) { + const { t } = useLocale(); + const isSystem = scope === "system"; + + const [rowSelection, setRowSelection] = useState({}); + const [showDetailsSheet, setShowDetailsSheet] = useState(false); + const [selectedEntry, setSelectedEntry] = useState(null); + const [entryToDelete, setEntryToDelete] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleViewDetails = (entry: T) => { + setSelectedEntry(entry); + setShowDetailsSheet(true); + onSelectEntry?.(entry.id); + }; + + const handleDelete = (entry: T) => { + setEntryToDelete(entry); + setShowDeleteDialog(true); + }; + + const confirmDelete = () => { + if (entryToDelete) { + onDelete(entryToDelete); + setShowDeleteDialog(false); + setEntryToDelete(null); + } + }; + + const handleCloseDetailsSheet = () => { + setShowDetailsSheet(false); + setSelectedEntry(null); + onSelectEntry?.(null); + }; + + const canDelete = isSystem || permissions?.canDelete; + + const columns = useBlockedEntriesColumns({ + t, + scope, + canDelete, + enableRowSelection, + onViewDetails: handleViewDetails, + onDelete: handleDelete, + }); + + const flatData = useMemo(() => data ?? [], [data]); + + const table = useReactTable({ + data: flatData, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalRowCount / limit), + enableRowSelection, + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + getRowId: (row) => row.id, + }); + + const numberOfSelectedRows = table.getFilteredSelectedRowModel().rows.length; + const selectedEntries = table.getSelectedRowModel().flatRows.map((row) => row.original); + const clearSelection = () => table.toggleAllPageRowsSelected(false); + + return ( + <> + } + headline={ + searchTerm + ? t("no_result_found_for", { searchTerm }) + : t(isSystem ? "system_blocklist" : "pbac_resource_blocklist") + } + description={t(isSystem ? "add_people_to_system_blocklist" : "add_people_to_blocklist")} + className="bg-muted mb-16" + iconWrapperClassName="bg-default" + dashedBorder={false} + buttonRaw={ +
+ + {IS_CALCOM && ( + + )} +
+ } + /> + } + totalRowCount={totalRowCount}> + {enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && ( + +

+ {t("number_selected", { count: numberOfSelectedRows })} +

+ {renderBulkActions(selectedEntries, clearSelection)} +
+ )} +
+ + + + + + {t( + isSystem ? "remove_value_from_system_blocklist_description" : "remove_value_from_blocklist_description" + )} + + + + ); +} diff --git a/apps/web/modules/ee/organizations/privacy/components/blocklist-entry-details-sheet.tsx b/packages/features/blocklist/components/BlocklistEntryDetailsSheet.tsx similarity index 59% rename from apps/web/modules/ee/organizations/privacy/components/blocklist-entry-details-sheet.tsx rename to packages/features/blocklist/components/BlocklistEntryDetailsSheet.tsx index cbef51cc0d..e043af5e48 100644 --- a/apps/web/modules/ee/organizations/privacy/components/blocklist-entry-details-sheet.tsx +++ b/packages/features/blocklist/components/BlocklistEntryDetailsSheet.tsx @@ -4,8 +4,7 @@ import { format } from "date-fns"; import Link from "next/link"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { RouterOutputs } from "@calcom/trpc/react"; -import { trpc } from "@calcom/trpc/react"; +import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { Icon } from "@calcom/ui/components/icon"; import { @@ -18,29 +17,29 @@ import { } from "@calcom/ui/components/sheet"; import { SkeletonText } from "@calcom/ui/components/skeleton"; -type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number]; +import type { BlocklistEntry, BlocklistEntryDetails, BlocklistScope } from "../types"; -interface BlocklistEntryDetailsSheetProps { - entry: BlocklistEntry | null; +export interface BlocklistEntryDetailsSheetProps { + scope: BlocklistScope; + entry: T | null; isOpen: boolean; onClose: () => void; - handleDeleteBlocklistEntry: (entry: BlocklistEntry) => void; + handleDeleteBlocklistEntry: (entry: T) => void; + detailsData?: BlocklistEntryDetails; + isLoading: boolean; } -export function BlocklistEntryDetailsSheet({ +export function BlocklistEntryDetailsSheet({ + scope, entry, isOpen, onClose, handleDeleteBlocklistEntry, -}: BlocklistEntryDetailsSheetProps) { + detailsData, + isLoading, +}: BlocklistEntryDetailsSheetProps) { const { t } = useLocale(); - - const { data, isLoading } = trpc.viewer.organizations.getWatchlistEntryDetails.useQuery( - { id: entry?.id ?? "" }, - { - enabled: !!entry?.id && isOpen, - } - ); + const isSystem = scope === "system"; return ( - {entry?.value} + {isSystem ? ( +
+ {entry?.value} + {t("system_wide")} +
+ ) : ( + {entry?.value} + )}
{isLoading ? ( -
+
- ) : data?.entry ? ( -
+ ) : detailsData?.entry ? ( +

{t("details")}

-
+
-

{data.entry.value}

+

{detailsData.entry.value}

- {data.entry.type === "EMAIL" ? t("email") : t("domain")} + {detailsData.entry.type === "EMAIL" ? t("email") : t("domain")}

+ {isSystem && ( + <> +
+ + {t("system_wide_all_organizations")} +
+ +
+ +

+ {detailsData.entry.source === "MANUAL" + ? t("manual") + : detailsData.entry.source === "FREE_DOMAIN_POLICY" + ? t("free_domain_policy") + : t("automatic")} +

+
+ + )} +
- -

{data.auditHistory[0]?.changedByUser?.email || "—"}

+ +

+ {detailsData.auditHistory[0]?.changedByUser?.email || "—"} +

- {data.entry.description || t("no_description_provided")} + {detailsData.entry.description || t("no_description_provided")}

- {data.entry.bookingReports && data.entry.bookingReports.length > 0 && ( + {detailsData.entry.bookingReports && detailsData.entry.bookingReports.length > 0 && (
- {data.entry.bookingReports.map((report) => { + {detailsData.entry.bookingReports.map((report) => { return (
@@ -114,9 +144,9 @@ export function BlocklistEntryDetailsSheet({

{t("history")}

- {data.auditHistory.length > 0 ? ( -
- {data.auditHistory.map((audit) => ( + {detailsData.auditHistory.length > 0 ? ( +
+ {detailsData.auditHistory.map((audit) => (
@@ -148,7 +178,7 @@ export function BlocklistEntryDetailsSheet({ color="secondary" onClick={() => entry && handleDeleteBlocklistEntry(entry)} disabled={!entry}> - {t("remove_from_blocklist")} + {t(isSystem ? "remove_from_system_blocklist" : "remove_from_blocklist")} diff --git a/apps/web/modules/ee/organizations/privacy/components/booking-report-entry-details-modal.tsx b/packages/features/blocklist/components/BookingReportDetailsModal.tsx similarity index 70% rename from apps/web/modules/ee/organizations/privacy/components/booking-report-entry-details-modal.tsx rename to packages/features/blocklist/components/BookingReportDetailsModal.tsx index 2d71e0611a..cd26b81eec 100644 --- a/apps/web/modules/ee/organizations/privacy/components/booking-report-entry-details-modal.tsx +++ b/packages/features/blocklist/components/BookingReportDetailsModal.tsx @@ -5,33 +5,40 @@ import { Controller, useForm } from "react-hook-form"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { WatchlistType } from "@calcom/prisma/enums"; -import type { RouterOutputs } from "@calcom/trpc/react"; -import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; import { ToggleGroup } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; -import { showToast } from "@calcom/ui/components/toast"; -type BookingReport = RouterOutputs["viewer"]["organizations"]["listBookingReports"]["rows"][number]; - -interface BookingReportEntryDetailsModalProps { - entry: BookingReport | null; - isOpen: boolean; - onClose: () => void; -} +import type { BookingReport, BlocklistScope } from "../types"; interface FormData { blockType: WatchlistType; } -export function BookingReportEntryDetailsModal({ +export interface BookingReportDetailsModalProps { + scope: BlocklistScope; + entry: T | null; + isOpen: boolean; + onClose: () => void; + onAddToBlocklist: (reportIds: string[], type: WatchlistType) => void; + onDismiss: (reportId: string) => void; + isAddingToBlocklist?: boolean; + isDismissing?: boolean; +} + +export function BookingReportDetailsModal({ + scope, entry, isOpen, onClose, -}: BookingReportEntryDetailsModalProps) { + onAddToBlocklist, + onDismiss, + isAddingToBlocklist = false, + isDismissing = false, +}: BookingReportDetailsModalProps) { const { t } = useLocale(); - const utils = trpc.useUtils(); + const isSystem = scope === "system"; const { control, @@ -44,46 +51,14 @@ export function BookingReportEntryDetailsModal({ }, }); - const addToWatchlist = trpc.viewer.organizations.addToWatchlist.useMutation({ - onSuccess: async () => { - await utils.viewer.organizations.listBookingReports.invalidate(); - await utils.viewer.organizations.listWatchlistEntries.invalidate(); - showToast(t("blocklist_entry_created"), "success"); - onClose(); - reset(); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - - const dismissBookingReport = trpc.viewer.organizations.dismissBookingReport.useMutation({ - onSuccess: async () => { - await utils.viewer.organizations.listBookingReports.invalidate(); - showToast(t("booking_report_dismissed"), "success"); - onClose(); - reset(); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); - const onSubmit = (data: FormData) => { if (!entry) return; - - addToWatchlist.mutate({ - reportIds: [entry.id], - type: data.blockType, - }); + onAddToBlocklist([entry.id], data.blockType); }; const handleDismiss = () => { if (!entry) return; - - dismissBookingReport.mutate({ - reportId: entry.id, - }); + onDismiss(entry.id); }; const handleGoBack = () => { @@ -104,15 +79,33 @@ export function BookingReportEntryDetailsModal({
-
+

{t("details")}

-
+

{entry.bookerEmail}

+ {isSystem && entry.organization && ( +
+ +

{entry.organization.name}

+
+ )} + + {isSystem && !entry.organization && ( +
+ +

{t("individual")}

+
+ )} +

{reasonMap[entry.reason] || entry.reason}

@@ -146,6 +139,7 @@ export function BookingReportEntryDetailsModal({

{t("what_would_you_like_to_block")}

+ {isSystem &&

{t("system_wide_blocklist_warning")}

} + disabled={isSubmitting || isAddingToBlocklist || isDismissing}> {t("go_back")} @@ -188,15 +182,15 @@ export function BookingReportEntryDetailsModal({ type="button" color="secondary" onClick={handleDismiss} - loading={dismissBookingReport.isPending} - disabled={isSubmitting || addToWatchlist.isPending || dismissBookingReport.isPending}> + loading={isDismissing} + disabled={isSubmitting || isAddingToBlocklist || isDismissing}> {t("dont_block")}
diff --git a/apps/web/modules/ee/organizations/privacy/components/create-blocklist-entry-modal.tsx b/packages/features/blocklist/components/CreateBlocklistEntryModal.tsx similarity index 73% rename from apps/web/modules/ee/organizations/privacy/components/create-blocklist-entry-modal.tsx rename to packages/features/blocklist/components/CreateBlocklistEntryModal.tsx index 3fe4ab7257..49f444bec8 100644 --- a/apps/web/modules/ee/organizations/privacy/components/create-blocklist-entry-modal.tsx +++ b/packages/features/blocklist/components/CreateBlocklistEntryModal.tsx @@ -1,31 +1,36 @@ "use client"; +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { domainRegex, emailRegex } from "@calcom/lib/emailSchema"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { WatchlistType } from "@calcom/prisma/enums"; -import { trpc } from "@calcom/trpc/react"; +import { Alert } from "@calcom/ui/components/alert"; import { Button } from "@calcom/ui/components/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; import { Input, Label, TextArea, ToggleGroup } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; -import { showToast } from "@calcom/ui/components/toast"; -interface CreateBlocklistEntryModalProps { +import type { BlocklistScope, CreateBlocklistEntryFormData } from "../types"; + +export interface CreateBlocklistEntryModalProps { isOpen: boolean; onClose: () => void; + scope: BlocklistScope; + onCreateEntry: (data: CreateBlocklistEntryFormData) => void; + isPending: boolean; } -interface FormData { - type: WatchlistType; - value: string; - description?: string; -} - -export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEntryModalProps) { +export function CreateBlocklistEntryModal({ + isOpen, + onClose, + scope, + onCreateEntry, + isPending, +}: CreateBlocklistEntryModalProps) { const { t } = useLocale(); - const utils = trpc.useUtils(); + const isSystem = scope === "system"; const { control, @@ -34,7 +39,7 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn setValue, reset, formState: { errors, isSubmitting }, - } = useForm({ + } = useForm({ defaultValues: { type: WatchlistType.EMAIL, value: "", @@ -44,24 +49,19 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn const watchType = watch("type"); - const createWatchlistEntry = trpc.viewer.organizations.createWatchlistEntry.useMutation({ - onSuccess: async () => { - await utils.viewer.organizations.listWatchlistEntries.invalidate(); - showToast(t("blocklist_entry_created"), "success"); - onClose(); + useEffect(() => { + if (isOpen) { reset(); - }, - onError: (error) => { - showToast(error.message, "error"); - }, - }); + } + }, [isOpen, reset]); - const onSubmit = (data: FormData) => { - createWatchlistEntry.mutate({ - type: data.type, - value: data.value, - description: data.description, - }); + const onSubmit = (data: CreateBlocklistEntryFormData) => { + onCreateEntry(data); + }; + + const handleClose = () => { + onClose(); + reset(); }; const validateValue = (value: string) => { @@ -86,11 +86,19 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn ]; return ( - + - + -
+
+ {isSystem && ( + + )} +
- - diff --git a/packages/features/blocklist/components/PendingReportsBadge.tsx b/packages/features/blocklist/components/PendingReportsBadge.tsx new file mode 100644 index 0000000000..6e67bba6ac --- /dev/null +++ b/packages/features/blocklist/components/PendingReportsBadge.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { Badge } from "@calcom/ui/components/badge"; + +export interface PendingReportsBadgeProps { + count: number | undefined; +} + +export function PendingReportsBadge({ count }: PendingReportsBadgeProps) { + if (!count) return null; + return ( + + {count} + + ); +} diff --git a/packages/features/blocklist/components/PendingReportsColumns.tsx b/packages/features/blocklist/components/PendingReportsColumns.tsx new file mode 100644 index 0000000000..1b50c93ac6 --- /dev/null +++ b/packages/features/blocklist/components/PendingReportsColumns.tsx @@ -0,0 +1,155 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; +import { useMemo } from "react"; + +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@calcom/ui/components/dropdown"; +import { Checkbox } from "@calcom/ui/components/form"; + +import type { BookingReport, BlocklistScope } from "../types"; + +interface UsePendingReportsColumnsProps { + t: (key: string) => string; + scope: BlocklistScope; + enableRowSelection?: boolean; + onViewDetails: (entry: T) => void; +} + +export function usePendingReportsColumns({ + t, + scope, + enableRowSelection = false, + onViewDetails, +}: UsePendingReportsColumnsProps) { + const isSystem = scope === "system"; + + return useMemo[]>(() => { + const columns: ColumnDef[] = []; + + if (enableRowSelection) { + columns.push({ + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 24, + minSize: 24, + maxSize: 24, + }); + } + + columns.push({ + id: "emailOrDomain", + header: t("email_slash_domain"), + accessorKey: "bookerEmail", + enableHiding: false, + size: isSystem ? 200 : 250, + cell: ({ row }) => { + const email = row.original.bookerEmail; + return ( +
+ + {email} + +
+ ); + }, + }); + + if (isSystem) { + columns.push({ + id: "organization", + header: t("org"), + size: 140, + cell: ({ row }) => { + const org = row.original.organization; + if (!org) { + return {t("individual")}; + } + return {org.name}; + }, + }); + } + + columns.push({ + id: "reportedBy", + header: t("reported_by"), + accessorFn: (row) => row.reporter?.email ?? "-", + size: isSystem ? 180 : 250, + cell: ({ row }) => ( + + {row.original.reporter?.email ?? "-"} + + ), + }); + + columns.push({ + id: "reason", + header: t("reason"), + size: isSystem ? 120 : 150, + cell: ({ row }) => { + const reason = t(row.original.reason.toLowerCase()); + const capitalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1); + + return ( + + {capitalizedReason} + + ); + }, + }); + + columns.push({ + id: "actions", + header: "", + size: isSystem ? 60 : 90, + minSize: isSystem ? 60 : 90, + maxSize: isSystem ? 60 : 90, + enableHiding: false, + enableSorting: false, + enableResizing: false, + cell: ({ row }) => { + const entry = row.original; + return ( +
+ + +
+ ); + }, + }); + + return columns; + }, [t, scope, isSystem, enableRowSelection, onViewDetails]); +} diff --git a/packages/features/blocklist/components/PendingReportsTable.tsx b/packages/features/blocklist/components/PendingReportsTable.tsx new file mode 100644 index 0000000000..f7caa27599 --- /dev/null +++ b/packages/features/blocklist/components/PendingReportsTable.tsx @@ -0,0 +1,127 @@ +"use client"; + +import type { RowSelectionState } from "@tanstack/react-table"; +import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; +import type { ReactNode } from "react"; +import { useMemo, useState } from "react"; + +import { DataTableSelectionBar, DataTableWrapper } from "~/data-table/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { WatchlistType } from "@calcom/prisma/enums"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; + +import type { BookingReport, BlocklistScope } from "../types"; +import { BookingReportDetailsModal } from "./BookingReportDetailsModal"; +import { usePendingReportsColumns } from "./PendingReportsColumns"; + +export interface PendingReportsTableProps { + scope: BlocklistScope; + data: T[]; + totalRowCount: number; + isPending: boolean; + limit: number; + onAddToBlocklist: (reportIds: string[], type: WatchlistType, onSuccess: () => void) => void; + onDismiss: (reportId: string, onSuccess: () => void) => void; + isAddingToBlocklist?: boolean; + isDismissing?: boolean; + enableRowSelection?: boolean; + renderBulkActions?: (selectedReports: T[], clearSelection: () => void) => ReactNode; +} + +export function PendingReportsTable({ + scope, + data, + totalRowCount, + isPending, + limit, + onAddToBlocklist, + onDismiss, + isAddingToBlocklist = false, + isDismissing = false, + enableRowSelection = false, + renderBulkActions, +}: PendingReportsTableProps) { + const { t } = useLocale(); + + const [rowSelection, setRowSelection] = useState({}); + const [showReviewDialog, setShowReviewDialog] = useState(false); + const [selectedReport, setSelectedReport] = useState(null); + + const handleViewDetails = (entry: T) => { + setSelectedReport(entry); + setShowReviewDialog(true); + }; + + const handleCloseModal = () => { + setShowReviewDialog(false); + setSelectedReport(null); + }; + + const columns = usePendingReportsColumns({ + t, + scope, + enableRowSelection, + onViewDetails: handleViewDetails, + }); + + const flatData = useMemo(() => data ?? [], [data]); + + const table = useReactTable({ + data: flatData, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalRowCount / limit), + enableRowSelection, + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + getRowId: (row) => row.id, + }); + + const numberOfSelectedRows = table.getFilteredSelectedRowModel().rows.length; + const selectedReports = table.getSelectedRowModel().flatRows.map((row) => row.original); + const clearSelection = () => table.toggleAllPageRowsSelected(false); + + return ( + <> + } + headline={t("no_pending_reports")} + className="bg-muted mb-16" + iconWrapperClassName="bg-default" + dashedBorder={false} + /> + } + totalRowCount={totalRowCount}> + {enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && ( + +

+ {t("number_selected", { count: numberOfSelectedRows })} +

+ {renderBulkActions(selectedReports, clearSelection)} +
+ )} +
+ + onAddToBlocklist(reportIds, type, handleCloseModal)} + onDismiss={(reportId) => onDismiss(reportId, handleCloseModal)} + isAddingToBlocklist={isAddingToBlocklist} + isDismissing={isDismissing} + /> + + ); +} diff --git a/packages/features/blocklist/index.ts b/packages/features/blocklist/index.ts new file mode 100644 index 0000000000..30e88479f2 --- /dev/null +++ b/packages/features/blocklist/index.ts @@ -0,0 +1,9 @@ +export * from "./types"; +export * from "./components/CreateBlocklistEntryModal"; +export * from "./components/BlockedEntriesTable"; +export * from "./components/BlockedEntriesColumns"; +export * from "./components/BlocklistEntryDetailsSheet"; +export * from "./components/PendingReportsTable"; +export * from "./components/PendingReportsColumns"; +export * from "./components/BookingReportDetailsModal"; +export * from "./components/PendingReportsBadge"; diff --git a/packages/features/blocklist/types.ts b/packages/features/blocklist/types.ts new file mode 100644 index 0000000000..1cecc50a89 --- /dev/null +++ b/packages/features/blocklist/types.ts @@ -0,0 +1,66 @@ +"use client"; + +import type { WatchlistType } from "@calcom/prisma/enums"; + +export type BlocklistScope = "organization" | "system"; + +export interface BlocklistPermissions { + canRead: boolean; + canCreate: boolean; + canDelete: boolean; +} + +export interface BlocklistEntry { + id: string; + value: string; + type: WatchlistType; + description: string | null; + source?: string; + isGlobal?: boolean; + isReadOnly?: boolean; + latestAudit?: { + changedByUserId: number | null; + changedByUser?: { id: number; email: string; name: string | null }; + } | null; +} + +export interface BlocklistEntryDetails { + entry: { + id: string; + value: string; + type: WatchlistType; + description: string | null; + source?: string; + bookingReports?: Array<{ + booking: { + uid: string; + title: string | null; + }; + }>; + }; + auditHistory: Array<{ + id: string; + value: string; + changedAt: Date | string; + changedByUser?: { name: string | null; email: string } | null; + }>; +} + +export interface BookingReport { + id: string; + bookerEmail: string; + reason: string; + description: string | null; + reporter?: { email: string } | null; + booking: { + uid: string; + title: string | null; + }; + organization?: { name: string } | null; +} + +export interface CreateBlocklistEntryFormData { + type: WatchlistType; + value: string; + description?: string; +} diff --git a/packages/features/bookingReport/repositories/IBookingReportRepository.ts b/packages/features/bookingReport/repositories/IBookingReportRepository.ts index 9ec81b037e..4d7fd01c1f 100644 --- a/packages/features/bookingReport/repositories/IBookingReportRepository.ts +++ b/packages/features/bookingReport/repositories/IBookingReportRepository.ts @@ -1,4 +1,4 @@ -import type { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums"; +import type { BookingReportReason, BookingReportStatus, SystemReportStatus } from "@calcom/prisma/enums"; export interface CreateBookingReportInput { bookingUid: string; @@ -25,6 +25,10 @@ export interface ListBookingReportsFilters { status?: BookingReportStatus[]; } +export interface SystemBookingReportsFilters { + systemStatus?: SystemReportStatus[]; +} + export interface BookingReportWithDetails { id: string; bookingUid: string; @@ -35,7 +39,10 @@ export interface BookingReportWithDetails { cancelled: boolean; createdAt: Date; status: BookingReportStatus; + systemStatus: SystemReportStatus; watchlistId: string | null; + globalWatchlistId: string | null; + organizationId: number | null; reporter: { id: number; email: string; @@ -55,6 +62,13 @@ export interface BookingReportWithDetails { action: string; description: string | null; } | null; + globalWatchlist: { + id: string; + type: string; + value: string; + action: string; + description: string | null; + } | null; organization: { id: number; name: string; @@ -100,5 +114,24 @@ export interface IBookingReportRepository { status: BookingReportStatus; }): Promise; + bulkLinkGlobalWatchlistWithSystemStatus(params: { + links: Array<{ reportId: string; globalWatchlistId: string }>; + systemStatus: SystemReportStatus; + }): Promise; + countPendingReports(params: { organizationId: number }): Promise; + + updateSystemReportStatus(params: { + reportId: string; + systemStatus: SystemReportStatus; + globalWatchlistId?: string | null; + }): Promise; + + bulkUpdateSystemReportStatus(params: { + reportIds: string[]; + systemStatus: SystemReportStatus; + globalWatchlistId?: string | null; + }): Promise<{ updated: number }>; + + countSystemPendingReports(): Promise; } diff --git a/packages/features/bookingReport/repositories/PrismaBookingReportRepository.test.ts b/packages/features/bookingReport/repositories/PrismaBookingReportRepository.test.ts index 5c99a9c3b9..9bf1fc5026 100644 --- a/packages/features/bookingReport/repositories/PrismaBookingReportRepository.test.ts +++ b/packages/features/bookingReport/repositories/PrismaBookingReportRepository.test.ts @@ -19,6 +19,7 @@ describe("PrismaBookingReportRepository", () => { findUnique: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), + update: vi.fn(), updateMany: vi.fn(), count: vi.fn(), }, @@ -104,7 +105,10 @@ describe("PrismaBookingReportRepository", () => { cancelled: false, createdAt: new Date("2025-01-01T10:00:00Z"), status: "PENDING", + systemStatus: "PENDING", watchlistId: null, + globalWatchlistId: null, + organizationId: null, reportedBy: null, booking: { id: 1, @@ -114,6 +118,8 @@ describe("PrismaBookingReportRepository", () => { endTime: new Date(), }, watchlist: null, + globalWatchlist: null, + organization: null, }, { id: "report-2", @@ -125,7 +131,10 @@ describe("PrismaBookingReportRepository", () => { cancelled: false, createdAt: new Date("2025-01-01T11:00:00Z"), status: "PENDING", + systemStatus: "PENDING", watchlistId: null, + globalWatchlistId: null, + organizationId: null, reportedBy: null, booking: { id: 2, @@ -135,6 +144,8 @@ describe("PrismaBookingReportRepository", () => { endTime: new Date(), }, watchlist: null, + globalWatchlist: null, + organization: null, }, ]; @@ -205,4 +216,152 @@ describe("PrismaBookingReportRepository", () => { }); }); }); + + describe("updateSystemReportStatus", () => { + it("should update systemStatus with globalWatchlistId", async () => { + mockPrisma.bookingReport.update.mockResolvedValue({ id: "report-1" }); + + await repository.updateSystemReportStatus({ + reportId: "report-123", + systemStatus: "BLOCKED", + globalWatchlistId: "watchlist-456", + }); + + expect(mockPrisma.bookingReport.update).toHaveBeenCalledWith({ + where: { id: "report-123" }, + data: { + systemStatus: "BLOCKED", + globalWatchlistId: "watchlist-456", + }, + }); + }); + + it("should update systemStatus without globalWatchlistId", async () => { + mockPrisma.bookingReport.update.mockResolvedValue({ id: "report-1" }); + + await repository.updateSystemReportStatus({ + reportId: "report-456", + systemStatus: "DISMISSED", + }); + + expect(mockPrisma.bookingReport.update).toHaveBeenCalledWith({ + where: { id: "report-456" }, + data: { + systemStatus: "DISMISSED", + }, + }); + }); + }); + + describe("bulkUpdateSystemReportStatus", () => { + it("should bulk update systemStatus with globalWatchlistId", async () => { + mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 3 }); + + const result = await repository.bulkUpdateSystemReportStatus({ + reportIds: ["r1", "r2", "r3"], + systemStatus: "BLOCKED", + globalWatchlistId: "global-1", + }); + + expect(result).toEqual({ updated: 3 }); + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["r1", "r2", "r3"] } }, + data: { + systemStatus: "BLOCKED", + globalWatchlistId: "global-1", + }, + }); + }); + + it("should bulk update systemStatus without globalWatchlistId", async () => { + mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 2 }); + + const result = await repository.bulkUpdateSystemReportStatus({ + reportIds: ["r1", "r2"], + systemStatus: "DISMISSED", + }); + + expect(result).toEqual({ updated: 2 }); + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["r1", "r2"] } }, + data: { + systemStatus: "DISMISSED", + }, + }); + }); + }); + + describe("countSystemPendingReports", () => { + it("should count reports with systemStatus PENDING", async () => { + mockPrisma.bookingReport.count.mockResolvedValue(5); + + const result = await repository.countSystemPendingReports(); + + expect(result).toBe(5); + expect(mockPrisma.bookingReport.count).toHaveBeenCalledWith({ + where: { systemStatus: "PENDING" }, + }); + }); + }); + + describe("bulkLinkGlobalWatchlistWithSystemStatus", () => { + it("should do nothing when links array is empty", async () => { + await repository.bulkLinkGlobalWatchlistWithSystemStatus({ + links: [], + systemStatus: "BLOCKED", + }); + + expect(mockPrisma.bookingReport.updateMany).not.toHaveBeenCalled(); + }); + + it("should link each report to its corresponding globalWatchlistId", async () => { + mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 }); + + await repository.bulkLinkGlobalWatchlistWithSystemStatus({ + links: [ + { reportId: "r1", globalWatchlistId: "w1" }, + { reportId: "r2", globalWatchlistId: "w2" }, + { reportId: "r3", globalWatchlistId: "w3" }, + ], + systemStatus: "BLOCKED", + }); + + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledTimes(3); + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["r1"] } }, + data: { globalWatchlistId: "w1", systemStatus: "BLOCKED" }, + }); + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["r2"] } }, + data: { globalWatchlistId: "w2", systemStatus: "BLOCKED" }, + }); + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["r3"] } }, + data: { globalWatchlistId: "w3", systemStatus: "BLOCKED" }, + }); + }); + + it("should group reports with the same globalWatchlistId", async () => { + mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 2 }); + + await repository.bulkLinkGlobalWatchlistWithSystemStatus({ + links: [ + { reportId: "r1", globalWatchlistId: "w1" }, + { reportId: "r2", globalWatchlistId: "w1" }, + { reportId: "r3", globalWatchlistId: "w2" }, + ], + systemStatus: "BLOCKED", + }); + + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledTimes(2); + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["r1", "r2"] } }, + data: { globalWatchlistId: "w1", systemStatus: "BLOCKED" }, + }); + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["r3"] } }, + data: { globalWatchlistId: "w2", systemStatus: "BLOCKED" }, + }); + }); + }); }); diff --git a/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts b/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts index 85430518f0..be6e6aeab8 100644 --- a/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts +++ b/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts @@ -1,5 +1,5 @@ import type { PrismaClient } from "@calcom/prisma"; -import type { BookingReportStatus } from "@calcom/prisma/enums"; +import type { BookingReportStatus, SystemReportStatus } from "@calcom/prisma/enums"; import type { Prisma } from "@calcom/prisma/generated/prisma/client"; import type { @@ -7,6 +7,7 @@ import type { CreateBookingReportInput, BookingReportWithDetails, ListBookingReportsFilters, + SystemBookingReportsFilters, } from "./IBookingReportRepository"; export class PrismaBookingReportRepository implements IBookingReportRepository { @@ -34,6 +35,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { take?: number; searchTerm?: string; filters?: ListBookingReportsFilters; + systemFilters?: SystemBookingReportsFilters; }): Promise<{ rows: BookingReportWithDetails[]; meta: { totalRowCount: number }; @@ -71,6 +73,10 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { where.status = { in: params.filters.status }; } + if (params.systemFilters?.systemStatus && params.systemFilters.systemStatus.length > 0) { + where.systemStatus = { in: params.systemFilters.systemStatus }; + } + const [reports, totalCount] = await Promise.all([ this.prismaClient.bookingReport.findMany({ where, @@ -86,7 +92,9 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { cancelled: true, createdAt: true, status: true, + systemStatus: true, watchlistId: true, + globalWatchlistId: true, organizationId: true, reportedBy: { select: { @@ -113,6 +121,15 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { description: true, }, }, + globalWatchlist: { + select: { + id: true, + type: true, + value: true, + action: true, + description: true, + }, + }, organization: { select: { id: true, @@ -161,7 +178,9 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { cancelled: true, createdAt: true, status: true, + systemStatus: true, watchlistId: true, + globalWatchlistId: true, organizationId: true, reportedBy: { select: { @@ -188,6 +207,15 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { description: true, }, }, + globalWatchlist: { + select: { + id: true, + type: true, + value: true, + action: true, + description: true, + }, + }, organization: { select: { id: true, @@ -274,6 +302,29 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { ); } + async bulkLinkGlobalWatchlistWithSystemStatus(params: { + links: Array<{ reportId: string; globalWatchlistId: string }>; + systemStatus: SystemReportStatus; + }): Promise { + if (params.links.length === 0) return; + + const groupedByWatchlist = new Map(); + for (const link of params.links) { + const reportIds = groupedByWatchlist.get(link.globalWatchlistId) || []; + reportIds.push(link.reportId); + groupedByWatchlist.set(link.globalWatchlistId, reportIds); + } + + await Promise.all( + Array.from(groupedByWatchlist.entries()).map(([globalWatchlistId, reportIds]) => + this.prismaClient.bookingReport.updateMany({ + where: { id: { in: reportIds } }, + data: { globalWatchlistId, systemStatus: params.systemStatus }, + }) + ) + ); + } + async countPendingReports(params: { organizationId: number }): Promise { return this.prismaClient.bookingReport.count({ where: { @@ -283,4 +334,41 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { }, }); } + + async updateSystemReportStatus(params: { + reportId: string; + systemStatus: SystemReportStatus; + globalWatchlistId?: string | null; + }): Promise { + await this.prismaClient.bookingReport.update({ + where: { id: params.reportId }, + data: { + systemStatus: params.systemStatus, + ...(params.globalWatchlistId !== undefined && { globalWatchlistId: params.globalWatchlistId }), + }, + }); + } + + async bulkUpdateSystemReportStatus(params: { + reportIds: string[]; + systemStatus: SystemReportStatus; + globalWatchlistId?: string | null; + }): Promise<{ updated: number }> { + const result = await this.prismaClient.bookingReport.updateMany({ + where: { id: { in: params.reportIds } }, + data: { + systemStatus: params.systemStatus, + ...(params.globalWatchlistId !== undefined && { globalWatchlistId: params.globalWatchlistId }), + }, + }); + return { updated: result.count }; + } + + async countSystemPendingReports(): Promise { + return this.prismaClient.bookingReport.count({ + where: { + systemStatus: "PENDING", + }, + }); + } } diff --git a/packages/features/ee/admin/pages/settings/blocklist.tsx b/packages/features/ee/admin/pages/settings/blocklist.tsx new file mode 100644 index 0000000000..b60eafccae --- /dev/null +++ b/packages/features/ee/admin/pages/settings/blocklist.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { keepPreviousData } from "@tanstack/react-query"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; + +import { + BlockedEntriesTable, + CreateBlocklistEntryModal, + PendingReportsBadge, + PendingReportsTable, +} from "@calcom/features/blocklist"; +import { DataTableProvider, useDataTable } from "@calcom/features/data-table"; +import { DataTableToolbar } from "~/data-table/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { ToggleGroup } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +import { BulkDeleteBlocklistEntries } from "./blocklist/BulkDeleteBlocklistEntries"; +import { BulkDismissReports } from "./blocklist/BulkDismissReports"; + +type ViewType = "blocked" | "pending"; + +function SystemBlocklistContent() { + const { t } = useLocale(); + const { limit, offset, searchTerm } = useDataTable(); + const [activeView, setActiveView] = useState("blocked"); + const [showCreateModal, setShowCreateModal] = useState(false); + const [selectedEntryId, setSelectedEntryId] = useState(null); + + const utils = trpc.useUtils(); + + const { data: blockedData, isPending: isBlockedPending } = + trpc.viewer.admin.watchlist.list.useQuery( + { limit, offset, searchTerm }, + { placeholderData: keepPreviousData, enabled: activeView === "blocked" } + ); + + const { data: reportsData, isPending: isReportsPending } = + trpc.viewer.admin.watchlist.listReports.useQuery( + { + limit, + offset, + searchTerm, + systemFilters: { systemStatus: ["PENDING"] }, + }, + { placeholderData: keepPreviousData, enabled: activeView === "pending" } + ); + + const { data: pendingReportsCount } = + trpc.viewer.admin.watchlist.pendingReportsCount.useQuery(); + + const { data: entryDetails, isLoading: isDetailsLoading } = + trpc.viewer.admin.watchlist.getDetails.useQuery( + { id: selectedEntryId ?? "" }, + { enabled: !!selectedEntryId } + ); + + const createEntry = trpc.viewer.admin.watchlist.create.useMutation({ + onSuccess: async () => { + await utils.viewer.admin.watchlist.list.invalidate(); + showToast(t("system_blocklist_entry_created"), "success"); + setShowCreateModal(false); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const deleteEntry = trpc.viewer.admin.watchlist.delete.useMutation({ + onSuccess: async () => { + await utils.viewer.admin.watchlist.list.invalidate(); + setSelectedEntryId(null); + showToast(t("system_blocklist_entry_deleted"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const addToWatchlist = trpc.viewer.admin.watchlist.addToWatchlist.useMutation( + { + onSuccess: async () => { + await utils.viewer.admin.watchlist.listReports.invalidate(); + await utils.viewer.admin.watchlist.list.invalidate(); + await utils.viewer.admin.watchlist.pendingReportsCount.invalidate(); + showToast(t("system_blocklist_entry_created"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + } + ); + + const dismissReport = trpc.viewer.admin.watchlist.dismissReport.useMutation({ + onSuccess: async () => { + await utils.viewer.admin.watchlist.listReports.invalidate(); + await utils.viewer.admin.watchlist.pendingReportsCount.invalidate(); + showToast(t("booking_report_dismissed"), "success"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + return ( + <> +
+
+ { + if (value) setActiveView(value as ViewType); + }} + options={[ + { value: "blocked", label: t("blocked") }, + { + value: "pending", + label: ( + + {t("pending")} + + + ), + }, + ]} + /> + +
+
+ {activeView === "blocked" && ( + + )} +
+
+ + {activeView === "blocked" ? ( + setShowCreateModal(true)} + onDelete={(entry) => deleteEntry.mutate({ id: entry.id })} + isDeleting={deleteEntry.isPending} + detailsQuery={{ data: entryDetails, isLoading: isDetailsLoading }} + selectedEntryId={selectedEntryId ?? undefined} + onSelectEntry={setSelectedEntryId} + enableRowSelection + renderBulkActions={(selectedEntries, clearSelection) => ( + + )} + /> + ) : ( + + addToWatchlist.mutate({ reportIds, type }, { onSuccess }) + } + onDismiss={(reportId, onSuccess) => + dismissReport.mutate({ reportId }, { onSuccess }) + } + isAddingToBlocklist={addToWatchlist.isPending} + isDismissing={dismissReport.isPending} + enableRowSelection + renderBulkActions={(selectedReports, clearSelection) => ( + + )} + /> + )} + + setShowCreateModal(false)} + scope="system" + onCreateEntry={(data) => createEntry.mutate(data)} + isPending={createEntry.isPending} + /> + + ); +} + +export default function SystemBlocklistView() { + const pathname = usePathname(); + + return ( + + + + ); +} diff --git a/packages/features/ee/admin/pages/settings/blocklist/BulkDeleteBlocklistEntries.tsx b/packages/features/ee/admin/pages/settings/blocklist/BulkDeleteBlocklistEntries.tsx new file mode 100644 index 0000000000..6350714bf6 --- /dev/null +++ b/packages/features/ee/admin/pages/settings/blocklist/BulkDeleteBlocklistEntries.tsx @@ -0,0 +1,69 @@ +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { DataTableSelectionBar } from "~/data-table/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { + DialogTrigger, + ConfirmationDialogContent, +} from "@calcom/ui/components/dialog"; +import { showToast } from "@calcom/ui/components/toast"; + +type BlocklistEntry = + RouterOutputs["viewer"]["admin"]["watchlist"]["list"]["rows"][number]; + +interface Props { + entries: BlocklistEntry[]; + onRemove: () => void; +} + +export function BulkDeleteBlocklistEntries({ entries, onRemove }: Props) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const bulkDeleteMutation = trpc.viewer.admin.watchlist.bulkDelete.useMutation( + { + onSuccess: async (data) => { + await utils.viewer.admin.watchlist.list.invalidate(); + showToast( + data.failed === 0 + ? t("entries_deleted_successfully", { count: data.success }) + : t("entries_deleted_with_errors", { + success: data.success, + failed: data.failed, + }), + data.failed === 0 ? "success" : "warning" + ); + onRemove(); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + } + ); + + return ( + + + + {t("remove_from_blocklist")} + + + { + bulkDeleteMutation.mutate({ + ids: entries.map((entry) => entry.id), + }); + }} + > +

+ {t("remove_entries_confirm", { count: entries.length })} +

+
+
+ ); +} diff --git a/packages/features/ee/admin/pages/settings/blocklist/BulkDismissReports.tsx b/packages/features/ee/admin/pages/settings/blocklist/BulkDismissReports.tsx new file mode 100644 index 0000000000..257fcd7118 --- /dev/null +++ b/packages/features/ee/admin/pages/settings/blocklist/BulkDismissReports.tsx @@ -0,0 +1,69 @@ +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { DataTableSelectionBar } from "~/data-table/components"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { + DialogTrigger, + ConfirmationDialogContent, +} from "@calcom/ui/components/dialog"; +import { showToast } from "@calcom/ui/components/toast"; + +type BookingReport = + RouterOutputs["viewer"]["admin"]["watchlist"]["listReports"]["rows"][number]; + +interface Props { + reports: BookingReport[]; + onRemove: () => void; +} + +export function BulkDismissReports({ reports, onRemove }: Props) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const bulkDismissMutation = + trpc.viewer.admin.watchlist.bulkDismiss.useMutation({ + onSuccess: async (data) => { + await utils.viewer.admin.watchlist.listReports.invalidate(); + await utils.viewer.admin.watchlist.pendingReportsCount.invalidate(); + showToast( + data.failed === 0 + ? t("reports_dismissed_successfully", { count: data.success }) + : t("reports_dismissed_with_errors", { + success: data.success, + failed: data.failed, + }), + data.failed === 0 ? "success" : "warning" + ); + onRemove(); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + return ( + + + + {t("dismiss")} + + + { + bulkDismissMutation.mutate({ + reportIds: reports.map((report) => report.id), + }); + }} + > +

+ {t("dismiss_reports_confirm", { count: reports.length })} +

+
+
+ ); +} diff --git a/packages/features/watchlist/lib/repository/WatchlistRepository.ts b/packages/features/watchlist/lib/repository/WatchlistRepository.ts index 2010060c60..a365e810b6 100644 --- a/packages/features/watchlist/lib/repository/WatchlistRepository.ts +++ b/packages/features/watchlist/lib/repository/WatchlistRepository.ts @@ -335,4 +335,84 @@ export class WatchlistRepository implements IWatchlistRepository { return { watchlistEntry, value: params.value }; } + + async findOrgAndGlobalEntries(params: { + organizationId: number; + limit: number; + offset: number; + searchTerm?: string; + filters?: { + type?: NonNullable["type"]; + source?: NonNullable["source"]; + }; + }): Promise<{ + rows: (WatchlistEntry & { + latestAudit: { changedByUserId: number | null } | null; + isReadOnly: boolean; + })[]; + meta: { totalRowCount: number }; + }> { + const where = { + OR: [ + { organizationId: params.organizationId, isGlobal: false }, + { isGlobal: true, organizationId: null }, + ], + ...(params.searchTerm && { + value: { + contains: params.searchTerm, + mode: "insensitive" as const, + }, + }), + ...(params.filters?.type && { + type: params.filters.type, + }), + ...(params.filters?.source && { + source: params.filters.source, + }), + }; + + const [entries, totalRowCount] = await Promise.all([ + this.prismaClient.watchlist.findMany({ + where, + take: params.limit, + skip: params.offset, + orderBy: { + lastUpdatedAt: "desc", + }, + select: { + id: true, + type: true, + value: true, + action: true, + description: true, + organizationId: true, + isGlobal: true, + source: true, + lastUpdatedAt: true, + audits: { + take: 1, + orderBy: { + changedAt: "desc", + }, + select: { + changedByUserId: true, + }, + }, + }, + }), + this.prismaClient.watchlist.count({ where }), + ]); + + const rows = entries.map((entry) => ({ + ...entry, + latestAudit: entry.audits[0] ?? null, + audits: undefined, + isReadOnly: entry.isGlobal, + })); + + return { + rows, + meta: { totalRowCount }, + }; + } } diff --git a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts index 7351dc76fe..0b393f624f 100644 --- a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts +++ b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts @@ -1,10 +1,13 @@ import type { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"; import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository"; import logger from "@calcom/lib/logger"; -import { BookingReportStatus } from "@calcom/prisma/enums"; +import { SystemReportStatus, WatchlistType } from "@calcom/prisma/enums"; import { WatchlistErrors } from "../errors/WatchlistErrors"; +import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization"; import type { + AddReportsToWatchlistInput, + AddReportsToWatchlistResult, DeleteWatchlistEntryInput, DeleteWatchlistEntryResult, WatchlistOperationsScope, @@ -61,7 +64,9 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService protected async findReports( reportIds: string[] - ): Promise> { + ): Promise< + Array<{ id: string; bookerEmail: string; watchlistId: string | null; globalWatchlistId: string | null }> + > { const reports = await this.deps.bookingReportRepo.findReportsByIds({ reportIds, }); @@ -69,6 +74,77 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService return reports; } + async addReportsToWatchlist(input: AddReportsToWatchlistInput): Promise { + const scope = this.getScope(); + + const validReports = await this.findReports(input.reportIds); + + if (validReports.length !== input.reportIds.length) { + const foundIds = new Set(validReports.map((r) => r.id)); + const missingIds = input.reportIds.filter((id) => !foundIds.has(id)); + throw WatchlistErrors.notFound(`Report(s) not found: ${missingIds.join(", ")}`); + } + + const reportsToAdd = validReports.filter((report) => !report.globalWatchlistId); + + if (reportsToAdd.length === 0) { + throw WatchlistErrors.alreadyInWatchlist("All selected reports are already in the global blocklist"); + } + + const normalizedValues = new Map(); + try { + for (const report of reportsToAdd) { + const value = + input.type === WatchlistType.EMAIL + ? normalizeEmail(report.bookerEmail) + : extractDomainFromEmail(report.bookerEmail); + normalizedValues.set(report.id, value); + } + } catch (error) { + this.adminLog.error("Email normalization failed", { + error: error instanceof Error ? error.message : String(error), + reportIds: reportsToAdd.map((r) => r.id), + }); + throw WatchlistErrors.invalidEmail(error instanceof Error ? error.message : "Invalid email format"); + } + + const results = await Promise.all( + reportsToAdd.map(async (report) => { + const normalizedValue = normalizedValues.get(report.id); + if (!normalizedValue) { + throw new Error("Unable to process the selected report. Please try again."); + } + + const { watchlistEntry } = await this.deps.watchlistRepo.createEntryFromReport({ + type: input.type, + value: normalizedValue, + organizationId: scope.organizationId, + isGlobal: scope.isGlobal, + userId: input.userId, + description: input.description, + }); + + return { + reportId: report.id, + watchlistId: watchlistEntry.id, + }; + }) + ); + + await this.deps.bookingReportRepo.bulkLinkGlobalWatchlistWithSystemStatus({ + links: results.map((r) => ({ reportId: r.reportId, globalWatchlistId: r.watchlistId })), + systemStatus: SystemReportStatus.BLOCKED, + }); + + return { + success: true, + message: `Successfully added ${results.length} report(s) to global blocklist`, + addedCount: results.length, + skippedCount: validReports.length - reportsToAdd.length, + results, + }; + } + async bulkDeleteWatchlistEntries( input: BulkDeleteWatchlistEntriesInput ): Promise { @@ -149,15 +225,15 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService const report = reports[0]; - if (report.watchlistId) { + if (report.globalWatchlistId) { throw WatchlistErrors.validationError( - "Cannot dismiss a report that has already been added to the blocklist" + "Cannot dismiss a report that has already been added to the global blocklist" ); } - await this.deps.bookingReportRepo.updateReportStatus({ + await this.deps.bookingReportRepo.updateSystemReportStatus({ reportId: input.reportId, - status: BookingReportStatus.DISMISSED, + systemStatus: SystemReportStatus.DISMISSED, }); return { success: true }; @@ -180,8 +256,8 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService continue; } - if (report.watchlistId) { - failed.push({ id: reportId, reason: "Already added to blocklist" }); + if (report.globalWatchlistId) { + failed.push({ id: reportId, reason: "Already added to global blocklist" }); continue; } @@ -190,9 +266,9 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService let successCount = 0; if (validReportIds.length > 0) { - const result = await this.deps.bookingReportRepo.bulkUpdateReportStatus({ + const result = await this.deps.bookingReportRepo.bulkUpdateSystemReportStatus({ reportIds: validReportIds, - status: BookingReportStatus.DISMISSED, + systemStatus: SystemReportStatus.DISMISSED, }); successCount = result.updated; } diff --git a/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts b/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts index 71ca610060..213ec7b63a 100644 --- a/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts +++ b/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts @@ -1,4 +1,7 @@ -import type { ListBookingReportsFilters } from "@calcom/features/bookingReport/repositories/IBookingReportRepository"; +import type { + ListBookingReportsFilters, + SystemBookingReportsFilters, +} from "@calcom/features/bookingReport/repositories/IBookingReportRepository"; import type { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"; import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository"; @@ -26,6 +29,7 @@ export interface ListBookingReportsInput { offset: number; searchTerm?: string; filters?: ListBookingReportsFilters; + systemFilters?: SystemBookingReportsFilters; } type Deps = { @@ -116,17 +120,13 @@ export class AdminWatchlistQueryService { take: input.limit, searchTerm: input.searchTerm, filters: input.filters, + systemFilters: input.systemFilters, }); return result; } async getPendingReportsCount(): Promise { - return this.deps.prisma.bookingReport.count({ - where: { - status: "PENDING", - watchlistId: null, - }, - }); + return this.deps.bookingReportRepo.countSystemPendingReports(); } } diff --git a/packages/features/watchlist/lib/service/OrganizationWatchlistQueryService.ts b/packages/features/watchlist/lib/service/OrganizationWatchlistQueryService.ts index ea3976341e..a9ca5d13e0 100644 --- a/packages/features/watchlist/lib/service/OrganizationWatchlistQueryService.ts +++ b/packages/features/watchlist/lib/service/OrganizationWatchlistQueryService.ts @@ -48,7 +48,7 @@ export class OrganizationWatchlistQueryService { async listWatchlistEntries(input: ListWatchlistEntriesInput) { await this.checkReadPermission(input.userId, input.organizationId); - const result = await this.deps.watchlistRepo.findAllEntriesWithLatestAudit({ + const result = await this.deps.watchlistRepo.findOrgAndGlobalEntries({ organizationId: input.organizationId, limit: input.limit, offset: input.offset, @@ -95,8 +95,13 @@ export class OrganizationWatchlistQueryService { throw WatchlistErrors.notFound("Blocklist entry not found"); } - if (result.entry.organizationId !== input.organizationId) { - throw WatchlistErrors.permissionDenied("You can only view blocklist entries from your organization"); + const isOrgEntry = result.entry.organizationId === input.organizationId; + const isGlobalEntry = result.entry.isGlobal && result.entry.organizationId === null; + + if (!isOrgEntry && !isGlobalEntry) { + throw WatchlistErrors.permissionDenied( + "You can only view blocklist entries from your organization or global entries" + ); } const userIds = result.auditHistory @@ -117,6 +122,7 @@ export class OrganizationWatchlistQueryService { return { entry: result.entry, auditHistory: auditHistoryWithUsers, + isReadOnly: isGlobalEntry, }; } } diff --git a/packages/prisma/migrations/20251218173119_add_global_watchlist/migration.sql b/packages/prisma/migrations/20251218173119_add_global_watchlist/migration.sql new file mode 100644 index 0000000000..5f8efdbcf3 --- /dev/null +++ b/packages/prisma/migrations/20251218173119_add_global_watchlist/migration.sql @@ -0,0 +1,15 @@ +-- CreateEnum +CREATE TYPE "public"."SystemReportStatus" AS ENUM ('PENDING', 'BLOCKED', 'DISMISSED'); + +-- AlterTable +ALTER TABLE "public"."BookingReport" ADD COLUMN "globalWatchlistId" UUID, +ADD COLUMN "systemStatus" "public"."SystemReportStatus" NOT NULL DEFAULT 'PENDING'; + +-- CreateIndex +CREATE INDEX "BookingReport_globalWatchlistId_idx" ON "public"."BookingReport"("globalWatchlistId"); + +-- CreateIndex +CREATE INDEX "BookingReport_systemStatus_idx" ON "public"."BookingReport"("systemStatus"); + +-- AddForeignKey +ALTER TABLE "public"."BookingReport" ADD CONSTRAINT "BookingReport_globalWatchlistId_fkey" FOREIGN KEY ("globalWatchlistId") REFERENCES "public"."Watchlist"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2098c6b5b6..aaf4e265d8 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2408,8 +2408,9 @@ model Watchlist { source WatchlistSource @default(MANUAL) lastUpdatedAt DateTime @default(now()) - bookingReports BookingReport[] - audits WatchlistAudit[] + bookingReports BookingReport[] + globalBookingReports BookingReport[] @relation("GlobalBlocklist") + audits WatchlistAudit[] @@unique([type, value, organizationId]) @@index([isGlobal, action, organizationId, type, value]) @@ -2452,6 +2453,12 @@ enum BookingReportStatus { BLOCKED // Added to watchlist } +enum SystemReportStatus { + PENDING // Not yet reviewed by system admin + BLOCKED // Added to global blocklist + DISMISSED // System admin dismissed (won't see again) +} + model BookingReport { id String @id @default(uuid()) @db.Uuid bookingUid String @unique @@ -2465,17 +2472,22 @@ model BookingReport { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt status BookingReportStatus @default(PENDING) + systemStatus SystemReportStatus @default(PENDING) - watchlistId String? @db.Uuid + watchlistId String? @db.Uuid + globalWatchlistId String? @db.Uuid - reportedBy User? @relation("ReportedBy", fields: [reportedById], references: [id], onDelete: SetNull) - organization Team? @relation(fields: [organizationId], references: [id], onDelete: Cascade) - watchlist Watchlist? @relation(fields: [watchlistId], references: [id], onDelete: SetNull) + reportedBy User? @relation("ReportedBy", fields: [reportedById], references: [id], onDelete: SetNull) + organization Team? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + watchlist Watchlist? @relation(fields: [watchlistId], references: [id], onDelete: SetNull) + globalWatchlist Watchlist? @relation("GlobalBlocklist", fields: [globalWatchlistId], references: [id], onDelete: SetNull) @@index([bookerEmail]) @@index([reportedById]) @@index([organizationId]) @@index([watchlistId]) + @@index([globalWatchlistId]) + @@index([systemStatus]) @@index([createdAt]) } diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts index a0b70ddde9..289751cba1 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts @@ -18,5 +18,6 @@ export const listReportsHandler = async ({ input }: ListReportsOptions) => { offset: input.offset, searchTerm: input.searchTerm, filters: input.filters, + systemFilters: input.systemFilters, }); }; diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts index 9af677042e..bf27566955 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums"; +import { BookingReportReason, BookingReportStatus, SystemReportStatus } from "@calcom/prisma/enums"; export const ZListReportsInputSchema = z.object({ limit: z.number().min(1).max(100).default(25), @@ -12,6 +12,11 @@ export const ZListReportsInputSchema = z.object({ reason: z.array(z.nativeEnum(BookingReportReason)).optional(), }) .optional(), + systemFilters: z + .object({ + systemStatus: z.array(z.nativeEnum(SystemReportStatus)).optional(), + }) + .optional(), }); export type TListReportsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts index df41b2e476..5f65823598 100644 --- a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { MembershipRole, BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums"; +import { + MembershipRole, + BookingReportReason, + BookingReportStatus, + SystemReportStatus, +} from "@calcom/prisma/enums"; import { listBookingReportsHandler } from "./listBookingReports.handler"; @@ -29,7 +34,10 @@ describe("listBookingReportsHandler (Organization)", () => { cancelled: false, createdAt: new Date("2025-01-01"), status: BookingReportStatus.PENDING, + systemStatus: SystemReportStatus.PENDING, watchlistId: null, + globalWatchlistId: null, + organizationId: 100, reporter: { id: 1, name: "Admin", email: "admin@example.com" }, booking: { id: 1, @@ -39,6 +47,8 @@ describe("listBookingReportsHandler (Organization)", () => { uid: "uid-1", }, watchlist: null, + globalWatchlist: null, + organization: { id: 100, name: "Test Org", slug: "test-org" }, }, { id: "report-2", @@ -50,7 +60,10 @@ describe("listBookingReportsHandler (Organization)", () => { cancelled: true, createdAt: new Date("2025-01-02"), status: BookingReportStatus.BLOCKED, + systemStatus: SystemReportStatus.PENDING, watchlistId: "watchlist-123", + globalWatchlistId: null, + organizationId: 100, reporter: { id: 1, name: "Admin", email: "admin@example.com" }, booking: { id: 2, @@ -66,6 +79,8 @@ describe("listBookingReportsHandler (Organization)", () => { action: "REPORT" as const, description: null, }, + globalWatchlist: null, + organization: { id: 100, name: "Test Org", slug: "test-org" }, }, ], meta: { totalRowCount: 2 },