Files
zeekay caad90bc04 rip: enterprise dirs + paywalls — full AGPL, no commercial-license code
Drop the dual-licensed `ee/` tree entirely. All features previously
locked behind the Hanzo Dataroom Commercial License are now part of the
AGPL-3.0 core and freely available — custom domains, branded viewing,
advanced analytics, watermarks, access controls, Q&A, AI vector stores,
workflows, SAML SSO, dataroom invitations, conversations, templates.

Layout changes:
* `ee/features/{ai,conversations,workflows,permissions,storage,templates,
  dataroom-invitations,access-notifications,security,conversions}/` →
  `features/<name>/` (AGPL).
* `ee/limits/` → `lib/billing/limits/` with every plan resolving to an
  unlimited profile (no users/links/documents/domains/datarooms cap,
  conversations and watermarks always on).
* `ee/stripe/` → `lib/billing/legacy/` (already a compat shim over
  `@hanzoai/commerce`; renewal-reminder webhook is now a no-op).
* `ee/features/billing/cancellation/` deleted. Pause/unpause/cancel/
  retention routes now compile against `lib/billing/cancellation.ts`
  stubs that return 410 Gone; `isTeamPaused`/`isTeamPausedById` in
  `lib/billing/paused.ts` always report active. `CancellationModal` is
  a no-op shim in `components/billing/`.
* `app/(ee)/api/*` route group folded into `app/api/*` (SAML auth,
  SCIM, AI chat, workflows, FAQ, link-upload).

Build: `npm run build` green.
2026-06-07 11:46:48 -07:00

1305 lines
48 KiB
TypeScript

import { useRouter } from "next/router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { InviteViewersModal } from "@/features/dataroom-invitations/components/invite-viewers-modal";
import { invitationEmailSchema } from "@/features/dataroom-invitations/lib/schema/dataroom-invitations";
import { PlanEnum } from "@/lib/billing/legacy/constants";
import { DocumentVersion, LinkAudienceType } from "@prisma/client";
import { isWithinInterval, subMinutes } from "date-fns";
import {
BoxesIcon,
ChevronRightIcon,
ClockFadingIcon,
Code2Icon,
CopyCheckIcon,
CopyIcon,
CopyPlusIcon,
EyeIcon,
EyeOffIcon,
FileSlidersIcon,
LinkIcon,
SendIcon,
Settings2Icon,
Square,
SquareArrowOutUpRightIcon,
SquareDashedIcon,
TimerOffIcon,
Trash2Icon,
} from "lucide-react";
import { useQueryState } from "nuqs";
import { toast } from "sonner";
import { mutate } from "swr";
import z from "zod";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { LinkWithViews, WatermarkConfig } from "@/lib/types";
import { cn, copyToClipboard, nFormatter, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/lib/utils/use-media-query";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import FileProcessStatusBar from "../documents/file-process-status-bar";
import BarChart from "../shared/icons/bar-chart";
import ChevronDown from "../shared/icons/chevron-down";
import MoreHorizontal from "../shared/icons/more-horizontal";
import { Badge } from "../ui/badge";
import { Label } from "../ui/label";
import { ButtonTooltip } from "../ui/tooltip";
import { useDeleteLinkModal } from "./delete-link-modal";
import EmbedCodeModal from "./embed-code-modal";
import LinkActiveControls, {
countActiveSettings,
} from "./link-active-controls";
import LinkSheet, {
DEFAULT_LINK_PROPS,
type DEFAULT_LINK_TYPE,
} from "./link-sheet";
import { DataroomLinkSheet } from "./link-sheet/dataroom-link-sheet";
import { PermissionsSheet } from "./link-sheet/permissions-sheet";
import { TagColumn } from "./link-sheet/tags/tag-details";
import LinksVisitors from "./links-visitors";
const isDocumentProcessing = (version?: DocumentVersion) => {
if (!version) return false;
return (
!version.hasPages &&
["pdf", "slides", "docs", "cad"].includes(version.type!)
);
};
// Full URL helper
const getFullUrl = (link: LinkWithViews) => {
if (link.domainId) {
return `https://${link.domainSlug}/${link.slug}`;
}
return `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;
};
// Display URL helper - shows the path portion that fits the cell
const getDisplayUrl = (link: LinkWithViews) => {
if (link.domainId) {
return `${link.domainSlug}/${link.slug}`;
}
return `dataroom.hanzo.ai/view/${link.id}`;
};
// Link URL cell component - displays URL with click-to-copy hover overlay
const LinkUrlCell = ({
link,
isFree,
onCopy,
isProcessing,
primaryVersion,
mutateDocument,
isPopoverOpen,
}: {
link: LinkWithViews;
isFree: boolean;
onCopy: (url: string) => void;
isProcessing: boolean;
primaryVersion?: DocumentVersion;
mutateDocument?: () => void;
isPopoverOpen?: boolean;
}) => {
const fullUrl = getFullUrl(link);
const displayUrl = getDisplayUrl(link);
return (
<div
className={cn(
"group/url relative min-w-0 flex-1 cursor-pointer overflow-hidden rounded-md px-3 py-1.5 text-sm transition-all group-hover/row:ring-1 group-hover/row:ring-gray-400 group-hover/row:dark:ring-gray-100",
link.domainId && isFree
? "bg-destructive/10 text-destructive hover:bg-red-700 hover:dark:bg-red-200"
: "bg-secondary text-secondary-foreground hover:bg-emerald-700 hover:dark:bg-emerald-200",
isPopoverOpen && "ring-1 ring-gray-400 dark:ring-gray-100",
)}
onClick={() => onCopy(fullUrl)}
>
{/* Progress bar for document processing */}
{isProcessing && primaryVersion && (
<FileProcessStatusBar
documentVersionId={primaryVersion.id}
className="absolute bottom-0 left-0 right-0 top-0 z-20 flex h-full items-center gap-x-8"
// @ts-ignore: mutateDocument is not present on datarooms but on document pages
mutateDocument={mutateDocument}
/>
)}
{/* URL text - hidden on hover */}
<span
className="block overflow-hidden text-ellipsis whitespace-nowrap group-hover/url:opacity-0"
style={{ direction: "rtl", textAlign: "left" }}
>
{displayUrl}
</span>
{/* Copy & Share overlay */}
<span className="absolute inset-0 z-10 hidden items-center justify-center whitespace-nowrap text-center text-sm text-primary-foreground group-hover/url:flex">
Copy & Share
</span>
</div>
);
};
// Link actions cell component - copy, preview, edit buttons
const LinkActionsCell = ({
link,
onCopy,
onPreview,
isProcessing,
}: {
link: LinkWithViews;
onCopy: (url: string) => void;
onPreview: (link: LinkWithViews) => void;
isProcessing: boolean;
}) => {
const [copied, setCopied] = useState(false);
const fullUrl = getFullUrl(link);
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timeout);
}
}, [copied]);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onCopy(fullUrl);
setCopied(true);
};
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onPreview(link);
};
return (
<div className="flex items-center gap-1">
<ButtonTooltip content={copied ? "Copied!" : "Copy link"}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 transition-colors hover:text-foreground group-hover/link:bg-emerald-500/10 group-hover/link:hover:bg-emerald-500/20"
onClick={handleCopy}
>
{copied ? (
<CopyCheckIcon className="h-4 w-4 text-emerald-500" />
) : (
<CopyIcon className="h-4 w-4 text-muted-foreground transition-colors hover:text-foreground group-hover/link:text-emerald-500" />
)}
</Button>
</ButtonTooltip>
<ButtonTooltip
content={isProcessing ? "Preparing preview" : "Preview link"}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:text-foreground"
onClick={handlePreview}
disabled={isProcessing}
>
{isProcessing ? (
<SquareDashedIcon className="h-4 w-4 text-muted-foreground" />
) : (
<SquareArrowOutUpRightIcon className="h-4 w-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</ButtonTooltip>
</div>
);
};
export default function LinksTable({
targetType,
links,
primaryVersion,
mutateDocument,
dataroomName,
}: {
targetType: "DOCUMENT" | "DATAROOM";
links?: LinkWithViews[];
primaryVersion?: DocumentVersion;
mutateDocument?: () => void;
dataroomName?: string;
}) {
const [tags, _] = useQueryState<string[]>("tags", {
parse: (value: string) => value.split(",").filter(Boolean),
serialize: (value: string[]) => value.join(","),
});
const selectedTagNames = useMemo(() => tags ?? [], [tags]);
const now = Date.now();
const router = useRouter();
const { isFree, isTrial } = usePlan();
const { currentTeamId } = useTeam();
const { id: targetId, groupId } = router.query as {
id: string;
groupId?: string;
};
const { isMobile } = useMediaQuery();
const { isFeatureEnabled } = useFeatureFlags();
let processedLinks = useMemo(() => {
if (!links?.length) return [];
const oneMinuteAgo = subMinutes(now, 1);
const sortedLinks = links.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return sortedLinks.map((link) => {
const createdDate = new Date(link.createdAt);
const updatedDate = new Date(link.updatedAt);
return {
...link,
isNew: isWithinInterval(createdDate, {
start: oneMinuteAgo,
end: now,
}),
isUpdated:
isWithinInterval(updatedDate, {
start: oneMinuteAgo,
end: now,
}) && updatedDate.getTime() !== createdDate.getTime(),
};
});
}, [links, now]);
processedLinks = useMemo(() => {
if (!links?.length) return [];
return processedLinks.filter((link) => {
if (selectedTagNames.length === 0) return true;
return link.tags.some((tag) => selectedTagNames.includes(tag.name));
});
}, [links, processedLinks, selectedTagNames]);
const { canAddLinks } = useLimits();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [loadingLinks, setLoadingLinks] = useState<Set<string>>(new Set());
const [isLinkSheetVisible, setIsLinkSheetVisible] = useState<boolean>(false);
const [selectedLink, setSelectedLink] = useState<DEFAULT_LINK_TYPE>(
DEFAULT_LINK_PROPS(`${targetType}_LINK`, groupId),
);
const [embedModalOpen, setEmbedModalOpen] = useState(false);
const [selectedEmbedLink, setSelectedEmbedLink] = useState<{
id: string;
name: string;
} | null>(null);
const [popoverOpen, setPopoverOpen] = useState<string | null>(null);
const hoverTimeout = useRef<NodeJS.Timeout | null>(null);
const [showPermissionsSheet, setShowPermissionsSheet] =
useState<boolean>(false);
const [editPermissionLink, setEditPermissionLink] =
useState<LinkWithViews | null>(null);
const [linkToDelete, setLinkToDelete] = useState<LinkWithViews | null>(null);
const { setShowDeleteLinkModal, DeleteLinkModal } = useDeleteLinkModal({
link: linkToDelete,
targetType,
});
const [isInviteModalOpen, setIsInviteModalOpen] = useState<boolean>(false);
const [inviteLink, setInviteLink] = useState<LinkWithViews | null>(null);
const [inviteDefaultEmails, setInviteDefaultEmails] = useState<string[]>([]);
const linksApiRoute =
currentTeamId && targetId
? groupId
? `/api/teams/${currentTeamId}/datarooms/${targetId}/groups/${groupId}/links`
: `/api/teams/${currentTeamId}/datarooms/${targetId}/links`
: null;
const dataroomDisplayName = dataroomName ?? "this dataroom";
const handleCopyToClipboard = (linkString: string) => {
copyToClipboard(`${linkString}`, "Link copied to clipboard.");
};
const handleEditLink = (link: LinkWithViews) => {
setSelectedLink({
id: link.id,
name: link.name || `Link #${link.id.slice(-5)}`,
domain: link.domainSlug,
slug: link.slug,
expiresAt: link.expiresAt,
password: link.password,
emailProtected: link.emailProtected,
emailAuthenticated: link.emailAuthenticated,
allowDownload: link.allowDownload ? link.allowDownload : false,
allowList: link.allowList,
denyList: link.denyList,
visitorGroupIds:
link.visitorGroups?.map(
(vg: { visitorGroupId: string }) => vg.visitorGroupId,
) || [],
enableNotification: link.enableNotification
? link.enableNotification
: false,
enableFeedback: link.enableFeedback ? link.enableFeedback : false,
enableScreenshotProtection: link.enableScreenshotProtection
? link.enableScreenshotProtection
: false,
enableCustomMetatag: link.enableCustomMetatag
? link.enableCustomMetatag
: false,
enableQuestion: link.enableQuestion ? link.enableQuestion : false,
questionText: link.feedback ? link.feedback.data?.question : "",
questionType: link.feedback ? link.feedback.data?.type : "",
metaTitle: link.metaTitle,
metaDescription: link.metaDescription,
metaImage: link.metaImage,
metaFavicon: link.metaFavicon,
enableAgreement: link.enableAgreement ? link.enableAgreement : false,
agreementId: link.agreementId,
showBanner: link.showBanner ?? false,
enableWatermark: link.enableWatermark ?? false,
watermarkConfig: link.watermarkConfig as WatermarkConfig | null,
audienceType: link.audienceType,
groupId: link.groupId,
customFields: link.customFields || [],
tags: link.tags.map((tag) => tag.id) || [],
enableConversation: link.enableConversation ?? false,
enableUpload: link.enableUpload ?? false,
isFileRequestOnly: link.isFileRequestOnly ?? false,
uploadFolderId: link.uploadFolderId ?? null,
uploadFolderName: link.uploadFolderName ?? "Home",
enableIndexFile: link.enableIndexFile ?? false,
permissionGroupId: link.permissionGroupId ?? null,
welcomeMessage: link.welcomeMessage ?? null,
enableAIAgents: link.enableAIAgents ?? false,
});
//wait for dropdown to close before opening the link sheet
setTimeout(() => {
setIsLinkSheetVisible(true);
}, 0);
};
const handlePreviewLink = async (link: LinkWithViews) => {
if (link.domainId && isFree) {
toast.error("You need to upgrade to preview this link");
return;
}
if (isDocumentProcessing(primaryVersion)) {
toast.error(
"Document is still processing. Please wait a moment and try again.",
);
return;
}
const response = await fetch(`/api/links/${link.id}/preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
toast.error("Failed to generate preview link");
return;
}
const { previewToken } = await response.json();
const previewLink = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}?previewToken=${previewToken}`;
window.open(previewLink, "_blank");
};
const handleDuplicateLink = async (link: LinkWithViews) => {
setIsLoading(true);
const response = await fetch(`/api/links/${link.id}/duplicate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
teamId: currentTeamId,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const duplicatedLink = await response.json();
const endpointTargetType = `${targetType.toLowerCase()}s`; // "documents" or "datarooms"
// Update the duplicated link in the list of links
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
link.documentId ?? link.dataroomId ?? "",
)}/links`,
(links || []).concat(duplicatedLink),
false,
);
// Update the group-specific links cache if this is a group link
if (!!groupId) {
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
duplicatedLink.documentId ?? duplicatedLink.dataroomId ?? "",
)}/groups/${duplicatedLink.groupId}/links`,
groupLinks.concat(duplicatedLink),
false,
);
}
toast.success("Link duplicated successfully");
setIsLoading(false);
};
const handleSendInvitations = (link: LinkWithViews) => {
if (targetType !== "DATAROOM") {
return;
}
const sanitizedEmails = Array.from(
new Set(
(link.allowList ?? []).filter(
(value) => invitationEmailSchema.safeParse(value).success,
),
),
);
setInviteDefaultEmails(sanitizedEmails);
setInviteLink(link);
setIsInviteModalOpen(true);
};
const handleEditPermissions = (link: LinkWithViews) => {
setEditPermissionLink(link);
setShowPermissionsSheet(true);
};
const handlePermissionsSave = async (permissions: any) => {
if (!editPermissionLink) return;
// Handle the case where user wants to share entire dataroom (permissions === null)
if (permissions === null && editPermissionLink.permissionGroupId) {
// Delete the permission group - database will set permissionGroupId to null automatically
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const deleteResponse = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
{
method: "DELETE",
},
);
if (!deleteResponse.ok) {
const { error } = await deleteResponse.json();
throw new Error(error ?? "Failed to delete permission group");
}
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${teamIdParsed}/${endpointTargetType}/${encodeURIComponent(
targetIdParsed,
)}/links`,
(currentLinks: LinkWithViews[] | undefined) =>
(currentLinks || []).map((link: LinkWithViews) =>
link.id === editPermissionLink.id
? { ...link, permissionGroupId: null }
: link,
),
false,
);
// Invalidate the permission group cache
mutate(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
);
setShowPermissionsSheet(false);
setEditPermissionLink(null);
toast.success("File permissions updated successfully");
} catch (error) {
console.error("Error updating file permissions:", error);
toast.error("Failed to update file permissions");
}
return;
}
if (!editPermissionLink.permissionGroupId) {
setIsLoading(true);
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const response = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
permissions: permissions,
linkId: editPermissionLink.id,
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create permission group");
}
const { permissionGroup: newPermissionGroup, _ } =
await response.json();
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
// Cache the new permission group data
if (newPermissionGroup?.id) {
mutate(
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
newPermissionGroup,
false,
);
}
setShowPermissionsSheet(false);
setEditPermissionLink(null);
toast.success("File permissions updated successfully");
} catch (error) {
console.error("Error creating permission group:", error);
toast.error("Failed to create permission group");
} finally {
setIsLoading(false);
}
} else {
try {
// Update the permissions for the existing link
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const res = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
permissions: permissions,
linkId: editPermissionLink.id,
}),
},
);
if (!res.ok) {
const { error } = await res.json();
throw new Error(error ?? "Failed to update permissions");
}
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
// Invalidate the permission group cache
if (editPermissionLink.permissionGroupId) {
mutate(
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
);
}
setShowPermissionsSheet(false);
setEditPermissionLink(null);
toast.success("File permissions updated successfully");
} catch (error) {
console.error("Error updating file permissions:", error);
toast.error("Failed to update file permissions");
}
}
};
const AddLinkButton = () => {
if (!canAddLinks) {
return (
<UpgradePlanModal
clickedPlan={isTrial ? PlanEnum.Business : PlanEnum.Pro}
trigger={"limit_add_link"}
>
<Button>Upgrade to Create Link</Button>
</UpgradePlanModal>
);
} else {
return (
<Button onClick={() => setIsLinkSheetVisible(true)}>
Create link to share
</Button>
);
}
};
const handleArchiveLink = async (
linkId: string,
targetId: string,
isArchived: boolean,
) => {
setLoadingLinks((prev) => new Set(prev).add(linkId));
try {
const response = await fetch(`/api/links/${linkId}/archive`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isArchived: !isArchived,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const archivedLink = await response.json();
const endpointTargetType = `${targetType.toLowerCase()}s`; // "documents" or "datarooms"
// Update the archived link in the list of links
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
(links || []).map((link) => (link.id === linkId ? archivedLink : link)),
false,
);
// Update the group-specific links cache if this is a group link
if (!!groupId) {
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
archivedLink.documentId ?? archivedLink.dataroomId ?? "",
)}/groups/${groupId}/links`,
groupLinks.map((link) => (link.id === linkId ? archivedLink : link)),
false,
);
}
toast.success(
!isArchived
? "Link successfully archived"
: "Link successfully reactivated",
);
} catch (error) {
console.error("Error archiving link:", error);
toast.error("Failed to update link status");
} finally {
setLoadingLinks((prev) => {
const newSet = new Set(prev);
newSet.delete(linkId);
return newSet;
});
}
};
const hasAnyTags = useMemo(
() =>
processedLinks.reduce(
(acc, link) => acc || (link?.tags && link.tags.length > 0),
false,
),
[processedLinks],
);
// Collapsible state for "All Links" section (document pages only)
const ALL_LINKS_COLLAPSED_KEY = "papermark-all-links-collapsed";
const [isAllLinksOpen, setIsAllLinksOpen] = useState<boolean>(true);
// Load collapse state from localStorage on mount
useEffect(() => {
if (targetType !== "DOCUMENT") return;
try {
const stored = localStorage.getItem(ALL_LINKS_COLLAPSED_KEY);
if (stored !== null) {
// stored value is "true" if collapsed, so we invert for isOpen
setIsAllLinksOpen(stored !== "true");
}
} catch (e) {
// localStorage might be unavailable
console.warn("Could not read from localStorage:", e);
}
}, [targetType]);
// Handle toggle and persist to localStorage
const handleAllLinksToggle = useCallback((open: boolean) => {
setIsAllLinksOpen(open);
try {
// Store "true" when collapsed, "false" when expanded
localStorage.setItem(ALL_LINKS_COLLAPSED_KEY, String(!open));
} catch (e) {
console.warn("Could not write to localStorage:", e);
}
}, []);
const isDataroom = targetType === "DATAROOM";
const linksTableContent = (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Link</TableHead>
{hasAnyTags ? (
<TableHead className="w-[250px] 2xl:w-auto">Tags</TableHead>
) : null}
<TableHead className="w-[250px] sm:w-auto">Views</TableHead>
<TableHead>Last Viewed</TableHead>
<TableHead className="w-[80px]">Active</TableHead>
<TableHead className="text-center sm:text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processedLinks && processedLinks.length > 0 ? (
processedLinks.map((link) => (
<Collapsible key={link.id} asChild>
<>
<TableRow
key={link.id}
className={cn(
"group/row",
popoverOpen === link.id && "bg-gray-100",
link.isArchived &&
"bg-gray-50 opacity-50 dark:bg-gray-700",
)}
>
<TableCell className="w-[200px] truncate font-medium md:w-[220px] lg:w-[250px] xl:w-[280px] 2xl:w-[350px]">
<div className="flex items-center gap-x-2">
{link.groupId ? (
<ButtonTooltip content="Group Link">
<BoxesIcon className="size-4 shrink-0" />
</ButtonTooltip>
) : null}
<ButtonTooltip
content={link.name || `Link #${link.id.slice(-5)}`}
>
<span className="max-w-[150px] truncate md:max-w-[170px] lg:max-w-[200px] xl:max-w-[230px] 2xl:max-w-[300px]">
{link.name || `Link #${link.id.slice(-5)}`}
</span>
</ButtonTooltip>
{link.isNew && !link.isUpdated && (
<Badge
variant="outline"
className="border-emerald-600/80 text-emerald-600/80"
>
New
</Badge>
)}
{link.isUpdated && (
<Badge
variant="outline"
className="border-blue-500/80 text-blue-500/80"
>
Updated
</Badge>
)}
{link.expiresAt &&
(new Date(link.expiresAt) < new Date() ? (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expired at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-destructive/10 p-1 text-destructive ring-1 ring-destructive/20">
<TimerOffIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
) : (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expires at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-orange-100 p-1 text-orange-700 ring-1 ring-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:ring-orange-800">
<ClockFadingIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
))}
{link.domainId && isFree ? (
<span className="ml-2 rounded-full bg-destructive px-2.5 py-0.5 text-xs text-foreground ring-1 ring-destructive">
Inactive
</span>
) : null}
</div>
</TableCell>
{/* Link URL Cell */}
<TableCell className="group/link max-w-[250px] pr-1 md:max-w-[300px] lg:max-w-[350px] xl:max-w-[400px] 2xl:max-w-[450px]">
<div className="flex flex-row gap-x-1">
<LinkUrlCell
link={link}
isFree={isFree}
onCopy={handleCopyToClipboard}
isProcessing={isDocumentProcessing(primaryVersion)}
primaryVersion={primaryVersion}
mutateDocument={mutateDocument}
isPopoverOpen={popoverOpen === link.id}
/>
<div className="flex shrink-0 items-center">
<LinkActionsCell
link={link}
onCopy={handleCopyToClipboard}
onPreview={handlePreviewLink}
isProcessing={isDocumentProcessing(primaryVersion)}
/>
{isMobile ? (
<ButtonTooltip content="Edit link">
<Button
variant="link"
size="icon"
className="group h-8 w-8"
onClick={() => handleEditLink(link)}
>
<span className="sr-only">Edit link</span>
<Settings2Icon className="text-gray-400 group-hover:text-gray-500" />
</Button>
</ButtonTooltip>
) : (
<Popover
open={popoverOpen === link.id}
onOpenChange={() => {}}
>
<PopoverTrigger asChild>
<Button
variant="link"
className={cn(
"h-8 w-8 font-normal hover:no-underline focus-visible:ring-0 focus-visible:ring-offset-0",
popoverOpen === link.id
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
size="sm"
onClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => {
hoverTimeout.current = setTimeout(
() => setPopoverOpen(link.id),
250,
);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<Settings2Icon />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-56 p-0"
onMouseEnter={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
setPopoverOpen(link.id);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<LinkActiveControls
link={link}
onEditClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
/>
</PopoverContent>
</Popover>
)}
{/* File permissions icon (dataroom only) */}
{isDataroom && (
<div className="flex w-8 items-center justify-center">
{link.permissionGroupId && (
<ButtonTooltip content="Limited File Access">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-default"
>
<FileSlidersIcon className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</Button>
</ButtonTooltip>
)}
</div>
)}
</div>
</div>
</TableCell>
{hasAnyTags ? (
<TableCell className="w-[250px] 2xl:w-auto">
<TagColumn link={link} />
</TableCell>
) : null}
<TableCell>
<CollapsibleTrigger
disabled={isDataroom || link._count.views === 0}
>
<div className="flex items-center space-x-1 [&[data-state=open]>svg.chevron]:rotate-180">
<BarChart className="h-4 w-4 text-muted-foreground" />
<p className="whitespace-nowrap text-sm text-muted-foreground">
{nFormatter(link._count.views)}
<span className="ml-1 hidden sm:inline-block">
views
</span>
</p>
{!isDataroom && link._count.views > 0 ? (
<ChevronDown className="chevron h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
) : null}
</div>
</CollapsibleTrigger>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{link.views[0] ? (
<TimestampTooltip
timestamp={link.views[0].viewedAt}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none"
dateTime={new Date(
link.views[0].viewedAt,
).toISOString()}
>
{timeAgo(link.views[0].viewedAt)}
</time>
</TimestampTooltip>
) : (
"-"
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-x-1">
<Switch
className="data-[state=checked]:bg-primary/80 data-[state=checked]:hover:bg-primary data-[state=unchecked]:hover:bg-muted-foreground/80"
id={`${link.id}-active-switch`}
checked={!link.isArchived}
onCheckedChange={(checked) =>
handleArchiveLink(
link.id,
link.documentId ?? link.dataroomId ?? "",
checked,
)
}
disabled={loadingLinks.has(link.id)}
/>
<Label
className="font-normal"
htmlFor={`${link.id}-active-switch`}
>
{link.isArchived ? "No" : "Yes"}
</Label>
</div>
</TableCell>
<TableCell className="text-center sm:text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleEditLink(link)}
>
<Settings2Icon className="mr-2 h-4 w-4" />
Edit Link
</DropdownMenuItem>
{/* Dataroom-only: Edit File Permissions */}
{isDataroom &&
link.audienceType !== LinkAudienceType.GROUP && (
<DropdownMenuItem
onClick={() => handleEditPermissions(link)}
disabled={isLoading}
>
<FileSlidersIcon className="mr-2 h-4 w-4" />
Edit File Permissions
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handlePreviewLink(link)}
>
<EyeIcon className="mr-2 h-4 w-4" />
Preview Link
</DropdownMenuItem>
{/* Dataroom-only: Send Invitations */}
{isDataroom &&
isFeatureEnabled("dataroomInvitations") && (
<DropdownMenuItem
onClick={() => handleSendInvitations(link)}
>
<SendIcon className="mr-2 h-4 w-4" />
Send Invitations
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!canAddLinks}
onClick={() => handleDuplicateLink(link)}
>
<CopyPlusIcon className="mr-2 h-4 w-4" />
Duplicate Link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedEmbedLink({
id: link.id,
name: link.name || `Link #${link.id.slice(-5)}`,
});
setEmbedModalOpen(true);
}}
>
<Code2Icon className="mr-2 h-4 w-4" />
Get Embed Code
</DropdownMenuItem>
{!isFree && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setLinkToDelete(link);
setShowDeleteLinkModal(true);
}}
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
>
<Trash2Icon className="mr-2 h-4 w-4" />
Delete Link
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<LinksVisitors
linkName={link.name || "No link name"}
linkId={link.id}
/>
</CollapsibleContent>
</>
</Collapsible>
))
) : (
<TableRow>
<TableCell colSpan={hasAnyTags ? 7 : 6}>
<div className="flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4">
<div className="hidden rounded-full sm:block">
<div
className={cn(
"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3",
)}
>
<LinkIcon className="size-6" />
</div>
</div>
<p>No links found for this {targetType.toLowerCase()}</p>
<AddLinkButton />
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
return (
<>
<div className="w-full">
{/* Collapsible wrapper for DOCUMENT type, plain div for DATAROOM */}
{targetType === "DOCUMENT" ? (
<Collapsible
open={isAllLinksOpen}
onOpenChange={handleAllLinksToggle}
className="w-full"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="mb-2 flex w-full cursor-pointer items-center gap-2 text-left md:mb-4"
>
<ChevronRightIcon
className={cn(
"h-5 w-5 text-muted-foreground transition-transform duration-200",
isAllLinksOpen && "rotate-90",
)}
/>
<h2 className="m-0">All links</h2>
{processedLinks && processedLinks.length > 0 && (
<Badge variant="outline" className="text-muted-foreground">
{processedLinks.length}
</Badge>
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
{linksTableContent}
</CollapsibleContent>
</Collapsible>
) : (
linksTableContent
)}
{targetType === "DATAROOM" ? (
<>
<DataroomLinkSheet
isOpen={isLinkSheetVisible}
setIsOpen={setIsLinkSheetVisible}
linkType={`${targetType}_LINK`}
currentLink={selectedLink.id ? selectedLink : undefined}
existingLinks={links}
/>
<PermissionsSheet
isOpen={showPermissionsSheet}
setIsOpen={(open: boolean) => {
setShowPermissionsSheet(open);
if (!open) {
setEditPermissionLink(null);
}
}}
dataroomId={targetId}
linkId={editPermissionLink?.id}
permissionGroupId={editPermissionLink?.permissionGroupId}
onSave={handlePermissionsSave}
/>
{inviteLink && isFeatureEnabled("dataroomInvitations") ? (
<InviteViewersModal
open={isInviteModalOpen}
setOpen={(open) => {
setIsInviteModalOpen(open);
if (!open) {
setInviteLink(null);
}
}}
dataroomId={targetId}
dataroomName={dataroomDisplayName}
groupId={inviteLink.groupId ?? undefined}
linkId={inviteLink.id}
defaultEmails={inviteDefaultEmails}
onSuccess={() => {
if (linksApiRoute) {
mutate(linksApiRoute);
}
setInviteLink(null);
}}
/>
) : null}
</>
) : (
<LinkSheet
isOpen={isLinkSheetVisible}
setIsOpen={setIsLinkSheetVisible}
linkType={`${targetType}_LINK`}
currentLink={selectedLink.id ? selectedLink : undefined}
existingLinks={links}
/>
)}
{selectedEmbedLink && (
<EmbedCodeModal
isOpen={embedModalOpen}
setIsOpen={setEmbedModalOpen}
linkId={selectedEmbedLink.id}
linkName={selectedEmbedLink.name}
/>
)}
<DeleteLinkModal />
</div>
</>
);
}