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 39b11ab8c1..4319041729 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 @@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { ] : []), { - name: "privacy", + name: "privacy_and_security", href: "/settings/organizations/privacy", }, @@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { // The following keys are assigned to admin only const adminRequiredKeys = ["admin"]; const organizationRequiredKeys = ["organization"]; -const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"]; +const organizationAdminKeys = [ + "privacy", + "privacy_and_security", + "OAuth Clients", + "SSO", + "directory_sync", + "delegation_credential", +]; export interface SettingsPermissions { canViewRoles?: boolean; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx index 8200641230..641e9be469 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx @@ -11,7 +11,7 @@ import { validateUserHasOrg } from "../../actions/validateUserHasOrg"; export const generateMetadata = async () => await _generateMetadata( - (t) => t("privacy"), + (t) => t("privacy_and_security"), (t) => t("privacy_organization_description"), undefined, undefined, @@ -19,9 +19,8 @@ export const generateMetadata = async () => ); const Page = async () => { - const t = await getTranslate(); - const session = await validateUserHasOrg(); + const t = await getTranslate(); if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { return redirect("/settings/profile"); @@ -42,13 +41,31 @@ const Page = async () => { }, }); + const watchlistPermissions = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Watchlist, + userRole: session.user.org.role, + fallbackRoles: { + read: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + create: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + if (!canRead) { return redirect("/settings/profile"); } return ( - - + + ); }; diff --git a/apps/web/modules/settings/organizations/privacy/blocklist-table.tsx b/apps/web/modules/settings/organizations/privacy/blocklist-table.tsx new file mode 100644 index 0000000000..34fe92f86f --- /dev/null +++ b/apps/web/modules/settings/organizations/privacy/blocklist-table.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { keepPreviousData } from "@tanstack/react-query"; +import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; +import { useMemo, useState } from "react"; + +import { DataTableWrapper, DataTableToolbar, useDataTable } from "@calcom/features/data-table"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { ButtonGroup } from "@calcom/ui/components/buttonGroup"; +import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog"; +import { showToast } from "@calcom/ui/components/toast"; + +import { BlocklistEntryDetailsSheet } from "./components/blocklist-entry-details-sheet"; +import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal"; + +type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number]; + +interface BlocklistTableProps { + permissions?: { + canRead: boolean; + canCreate: boolean; + canDelete: boolean; + }; +} + +export function BlocklistTable({ permissions }: BlocklistTableProps) { + const { t } = useLocale(); + const { limit, offset, searchTerm } = useDataTable(); + + const [showCreateModal, setShowCreateModal] = useState(false); + 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 handleDelete = (entry: BlocklistEntry) => { + setEntryToDelete(entry); + setShowDeleteDialog(true); + }; + + const confirmDelete = () => { + if (entryToDelete) { + deleteWatchlistEntry.mutate({ id: entryToDelete.id }); + } + }; + + const handleViewDetails = (entry: BlocklistEntry) => { + setSelectedEntry(entry); + setShowDetailsSheet(true); + }; + + const totalRowCount = data?.meta?.totalRowCount ?? 0; + const flatData = useMemo(() => data?.rows ?? [], [data]); + + const columns = useMemo[]>( + () => [ + { + id: "value", + header: t("value"), + 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.audits?.[0] as + | { changedByUserId: number | null } + | { + changedByUser?: { id: number; email: string; name: string | null } | undefined; + changedByUserId: number | null; + } + | undefined; + const email = + (audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined; + return {email ?? "—"}; + }, + }, + { + id: "actions", + header: "", + size: 120, + enableHiding: false, + enableSorting: false, + enableResizing: false, + cell: ({ row }) => { + const entry = row.original; + return ( +
+ +
+ ); + }, + }, + ], + [t, permissions?.canDelete] + ); + + const table = useReactTable({ + data: flatData, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalRowCount / limit), + }); + + return ( + <> + +
+ +
+ {permissions?.canCreate && ( + + )} +
+
+
+ + setShowCreateModal(false)} /> + + { + setShowDetailsSheet(false); + setSelectedEntry(null); + }} + /> + + + + {t("delete_blocklist_entry_confirmation", { value: entryToDelete?.value })} + + + + ); +} diff --git a/apps/web/modules/settings/organizations/privacy/components/blocklist-entry-details-sheet.tsx b/apps/web/modules/settings/organizations/privacy/components/blocklist-entry-details-sheet.tsx new file mode 100644 index 0000000000..3ac4140fa2 --- /dev/null +++ b/apps/web/modules/settings/organizations/privacy/components/blocklist-entry-details-sheet.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { format } from "date-fns"; + +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 { Sheet, SheetContent, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; + +type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number]; + +interface BlocklistEntryDetailsSheetProps { + entry: BlocklistEntry | null; + isOpen: boolean; + onClose: () => void; +} + +export function BlocklistEntryDetailsSheet({ entry, isOpen, onClose }: BlocklistEntryDetailsSheetProps) { + const { t } = useLocale(); + + const { data, isLoading } = trpc.viewer.organizations.getWatchlistEntryDetails.useQuery( + { id: entry?.id ?? "" }, + { + enabled: !!entry?.id && isOpen, + } + ); + + const getActionVariant = (action: string) => { + switch (action) { + case "BLOCK": + return "red"; + case "REPORT": + return "orange"; + case "ALERT": + return "blue"; + default: + return "gray"; + } + }; + + return ( + { if (!open) onClose(); }}> + + + {t("blocklist_entry_details")} + + + {isLoading ? ( +
+ + + + + + +
+ ) : data?.entry ? ( +
+
+
+ + + {data.entry.type === "EMAIL" ? t("email") : t("domain")} + +
+ +
+ +

{data.entry.value}

+
+ +
+ + + {t(data.entry.action.toLowerCase())} + +
+ +
+ +

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

+
+ +
+ +

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

+
+
+ +
+ +
+

{t("audit_history")}

+ {data.auditHistory.length > 0 ? ( +
+ {data.auditHistory.map((audit, index) => ( +
+
+
+ {t("by")}: + {audit.changedByUser?.email || "—"} +
+ + {format(new Date(audit.changedAt), "MMM d, h:mm a")} + +
+
+ {t("added")}: + {audit.value} +
+
+ ))} +
+ ) : ( +

{t("no_audit_history")}

+ )} +
+
+ ) : ( +
{t("blocklist_entry_not_found")}
+ )} + + + ); +} diff --git a/apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx b/apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx new file mode 100644 index 0000000000..f1e3323ad9 --- /dev/null +++ b/apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { Controller, useForm } from "react-hook-form"; + +import { domainWithAtRegex, 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 { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Input, Select, Label, TextArea } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +interface CreateBlocklistEntryModalProps { + isOpen: boolean; + onClose: () => void; +} + +interface FormData { + type: WatchlistType; + value: string; + description?: string; +} + +export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEntryModalProps) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const { + control, + handleSubmit, + watch, + setValue, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + type: WatchlistType.EMAIL, + value: "", + description: "", + }, + }); + + 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(); + reset(); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const onSubmit = (data: FormData) => { + createWatchlistEntry.mutate({ + type: data.type, + value: data.value, + description: data.description, + }); + }; + + const validateValue = (value: string) => { + if (!value) return t("required"); + + if (watchType === WatchlistType.EMAIL) { + if (!emailRegex.test(value)) { + return t("invalid_email_address"); + } + } else if (watchType === WatchlistType.DOMAIN) { + if (!domainWithAtRegex.test(value)) { + return t("invalid_domain_format"); + } + } + + return true; + }; + + const typeOptions = [ + { label: t("email"), value: WatchlistType.EMAIL }, + { label: t("domain"), value: WatchlistType.DOMAIN }, + ]; + + return ( + + + +
+
+
+ + ( + + )} + /> + {errors.value &&

{errors.value.message}

} +
+ +
+ + ( +