Files
zeekayandHanzo Dev f77d5010d8 ee: delete the relocated upstream commercial code and its paywall
Papermark dual-licenses: AGPL-3.0 for the tree, a separate commercial
licence for ee/. We hold the first and not the second, so none of the
second may live here.

ee/ was already absent from main, but it was never deleted. 98f0da7fa
("ci: amd64-only build") renamed 150 files out of ee/ byte-identical
into features/, lib/billing/legacy/ (ee/stripe), lib/billing/limits/
(ee/limits), and flattened app/(ee)/ into app/ — putting commercially
licensed code under our AGPL root LICENSE. A rename is not a deletion.
Every path that commit moved is now gone, edited-since or not.

Deleted, with their routes and UI entry points: features/ai and
app/api/ai, features/workflows and app/api/{workflows,workflow-entry},
features/conversations and the Q&A/FAQ surface, features/security/sso
with app/api/auth/saml + app/api/scim + lib/jackson.ts (we use Hanzo
IAM OIDC natively), features/dataroom-invitations, features/templates,
features/access-notifications, features/conversions, features/
permissions. Five of these were gated by lib/featureFlags, which reads
@vercel/edge-config and returns false for every flag when EDGE_CONFIG
is unset — every non-Vercel deployment we run. Those flags are gone too.

Deleted the commercial gating outright rather than reimplementing it:
lib/billing/legacy (ee/stripe) and lib/billing/limits (ee/limits), the
upgrade modals and banners, UpgradeButton, FeaturePreview, PlanBadge,
the trial and blocking modals, /settings/upgrade, /settings/billing and
the billing checkout/seat/pause/cancel API. usePlan() no longer returns
capability booleans — the plan tag describes provisioning, it is not a
permission — and useLimits() is gone with every quota check it fed.
Subscription pause went the same way. Also dropped the npm dependencies
that existed only for the removed features (@boxyhq/saml-jackson, the
@ai-sdk packages, openai, streamdown, use-stick-to-bottom,
@trigger.dev/python) and the Jackson-specific next.config webpack shims.

Rewritten as original Hanzo code where a capability was load-bearing:
- lib/utils/get-file-size-limits.ts — one UPLOAD_LIMITS set plus
  getFileSizeLimit(contentType), env-overridable. Operational limits,
  not commercial ones, so the tus and browser-upload paths still refuse
  oversized files without consulting a plan matrix.
- components/links/link-sheet/permissions-sheet.tsx — a flat, depth
  annotated item list with view/download switches over the same
  /permission-groups API. Two states, not three. ItemPermission now has
  a single definition in lib/types.ts.
- app/api/links/[id]/upload/route.ts — visitor uploads, built around one
  authorize() helper shared by GET and POST, and a notification debounce
  keyed per (link, viewer) instead of listing and cancelling runs.

There is now exactly one link sheet: the ee DataroomLinkSheet duplicated
the AGPL LinkSheet, which already handled DATAROOM_LINK. Replaced
upstream's Omit<LinkType, "WORKFLOW_LINK"> — Omit on a string union
yields an object type and never narrowed anything — with a real union.

Verified: the S3/tus upload+download harness passes 13/13 against MinIO
after the deletions, and tsc goes from 257 pre-existing errors on
origin/main to 192 with zero files worse than baseline.

Reasoning recorded in LLM.md. LICENSE untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 20:19:21 -07:00

769 lines
26 KiB
TypeScript

import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { Document, DocumentVersion } from "@prisma/client";
import {
ArrowRightIcon,
BetweenHorizontalStartIcon,
ChevronRight,
CloudDownloadIcon,
DownloadIcon,
FileDownIcon,
FolderIcon,
MoonIcon,
ServerIcon,
SheetIcon,
SunIcon,
TrashIcon,
ViewIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { mutate } from "swr";
import { getFile } from "@/lib/files/get-file";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
import {
DocumentWithLinksAndLinkCountAndViewCount,
DocumentWithVersion,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { supportsAdvancedExcelMode } from "@/lib/utils/get-content-type";
import { fileIcon } from "@/lib/utils/get-file-icon";
import FileUp from "@/components/shared/icons/file-up";
import MoreVertical from "@/components/shared/icons/more-vertical";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import AdvancedSheet from "../shared/icons/advanced-sheet";
import PortraitLandscape from "../shared/icons/portrait-landscape";
import LoadingSpinner from "../ui/loading-spinner";
import { ButtonTooltip } from "../ui/tooltip";
import { AddDocumentModal } from "./add-document-modal";
import { AddToDataroomModal } from "./add-document-to-dataroom-modal";
import AlertBanner from "./alert";
import { ExportVisitsModal } from "./export-visits-modal";
export default function DocumentHeader({
prismaDocument,
primaryVersion,
teamId,
actions,
}: {
prismaDocument: DocumentWithVersion;
primaryVersion: DocumentVersion;
teamId: string;
actions?: React.ReactNode[];
}) {
const router = useRouter();
const teamInfo = useTeam();
const { datarooms } = useDataroomsSimple();
const { theme, systemTheme } = useTheme();
const isLight =
theme === "light" || (theme === "system" && systemTheme === "light");
const [isEditingName, setIsEditingName] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
const [orientationLoading, setOrientationLoading] = useState<boolean>(false);
const [addDataRoomOpen, setAddDataRoomOpen] = useState<boolean>(false);
const [addDocumentVersion, setAddDocumentVersion] = useState<boolean>(false);
const [openAddDocModal, setOpenAddDocModal] = useState<boolean>(false);
const [exportModalOpen, setExportModalOpen] = useState<boolean>(false);
const nameRef = useRef<HTMLHeadingElement>(null);
const enterPressedRef = useRef<boolean>(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const actionRows: React.ReactNode[][] = [];
if (actions) {
for (let i = 0; i < actions.length; i += 3) {
actionRows.push(actions.slice(i, i + 3));
}
}
// Check if document is in any datarooms
const dataroomCount = prismaDocument.datarooms?.length || 0;
const handleCloseAlert = (id: string) => {
const alert = document.getElementById(id);
if (alert) {
alert.style.display = "none";
}
};
const currentTime = new Date();
const formattedTime =
currentTime.getFullYear() +
"-" +
String(currentTime.getMonth() + 1).padStart(2, "0") +
"-" +
String(currentTime.getDate()).padStart(2, "0") +
"_" +
String(currentTime.getHours()).padStart(2, "0") +
"-" +
String(currentTime.getMinutes()).padStart(2, "0");
"-" + String(currentTime.getSeconds()).padStart(2, "0");
// https://github.com/radix-ui/primitives/issues/1241#issuecomment-1888232392
useEffect(() => {
if (!addDataRoomOpen || !addDocumentVersion) {
setTimeout(() => {
document.body.style.pointerEvents = "";
});
}
}, [addDataRoomOpen, addDocumentVersion]);
const handleNameSubmit = async () => {
if (enterPressedRef.current) {
enterPressedRef.current = false;
return;
}
if (nameRef.current && isEditingName) {
const newName = nameRef.current.innerText;
if (newName !== prismaDocument!.name) {
const response = await fetch(
`/api/teams/${teamId}/documents/${prismaDocument!.id}/update-name`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: newName,
}),
},
);
if (response.ok) {
const { message } = await response.json();
toast.success(message);
} else {
const { message } = await response.json();
toast.error(message);
}
}
setIsEditingName(false);
}
};
const preventEnterAndSubmit = (
event: React.KeyboardEvent<HTMLHeadingElement>,
) => {
if (event.key === "Enter") {
event.preventDefault(); // Prevent the default line break
setIsEditingName(true);
enterPressedRef.current = true;
handleNameSubmit(); // Handle the submit
if (nameRef.current) {
nameRef.current.blur(); // Remove focus from the h2 element
}
}
};
const changeDocumentOrientation = async () => {
setOrientationLoading(true);
try {
const response = await fetch(
"/api/teams/" +
teamId +
"/documents/" +
prismaDocument.id +
"/change-orientation",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
versionId: primaryVersion.id,
isVertical: primaryVersion.isVertical ? false : true,
}),
},
);
if (response.ok) {
const { message } = await response.json();
toast.success(message);
mutate(`/api/teams/${teamId}/documents/${prismaDocument.id}`);
} else {
const { message } = await response.json();
toast.error(message);
}
} catch (error) {
console.error("Error:", error);
toast.error("An error occurred. Please try again.");
} finally {
setOrientationLoading(false);
}
};
const toggleAdvancedExcel = async (document: Document, enabled: boolean) => {
toast.promise(
fetch(`/api/teams/${teamId}/documents/${document.id}/advanced-mode`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
}).then(async (response) => {
if (!response.ok) {
const { message } = await response.json();
throw new Error(message);
}
const { message } = await response.json();
mutate(`/api/teams/${teamId}/documents/${document.id}`);
if (enabled) {
handleCloseAlert("enable-advanced-excel-alert");
}
return message;
}),
{
loading: enabled
? "Enabling advanced Excel mode..."
: "Disabling advanced Excel mode...",
success: (message) => message,
error: (error) =>
error.message ||
(enabled
? "Failed to enable advanced Excel mode"
: "Failed to disable advanced Excel mode"),
},
);
};
// Make a document download only or viewable
const toggleDownloadOnly = async () => {
toast.promise(
fetch(
`/api/teams/${teamId}/documents/${prismaDocument.id}/toggle-download-only`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
downloadOnly: !prismaDocument.downloadOnly,
}),
},
).then(() => {
mutate(`/api/teams/${teamId}/documents/${prismaDocument.id}`);
}),
{
loading: "Updating document...",
success: `Document is now ${
!prismaDocument.downloadOnly ? "download only" : "viewable"
}`,
error: "Failed to update document",
},
);
};
// Toggle dark mode for Notion documents
const toggleNotionDarkMode = async (darkMode: boolean) => {
if (prismaDocument.type !== "notion") {
toast.error("This feature is not available for your document type");
return;
}
toast.promise(
fetch(
`/api/teams/${teamId}/documents/${prismaDocument.id}/toggle-dark-mode`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
darkMode: darkMode,
}),
},
).then(() => {
mutate(`/api/teams/${teamId}/documents/${prismaDocument.id}`);
}),
{
loading: "Updating Notion theme...",
success: `Notion theme changed to ${darkMode ? "dark" : "light"} mode`,
error: "Failed to update Notion theme",
},
);
};
useEffect(() => {
function handleClickOutside(event: { target: any }) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setMenuOpen(false);
setIsFirstClick(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleDeleteDocument = async (documentId: string) => {
// Prevent the first click from deleting the document
if (!isFirstClick) {
setIsFirstClick(true);
return;
}
toast.promise(
fetch(`/api/teams/${teamId}/documents/${documentId}`, {
method: "DELETE",
}).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to delete document");
}
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`, null, {
populateCache: (_, docs) => {
return docs.filter(
(doc: DocumentWithLinksAndLinkCountAndViewCount) =>
doc.id !== documentId,
);
},
revalidate: false,
});
setIsFirstClick(false);
setMenuOpen(false);
router.push("/documents");
}),
{
loading: "Deleting document...",
success: "Document deleted successfully.",
error: (err) => err.message || "Failed to delete document. Try again.",
},
);
};
const handleMenuStateChange = (open: boolean) => {
if (isFirstClick) {
setMenuOpen(true); // Keep the dropdown open on the first click
return;
}
// If the menu is closed, reset the isFirstClick state
if (!open) {
setIsFirstClick(false);
setMenuOpen(false); // Ensure the dropdown is closed
} else {
setMenuOpen(true); // Open the dropdown
}
};
const handleButtonClick = (event: any, documentId: string) => {
event.stopPropagation();
event.preventDefault();
if (isFirstClick) {
handleDeleteDocument(documentId);
setIsFirstClick(false);
setMenuOpen(false); // Close the dropdown after deleting
} else {
setIsFirstClick(true);
}
};
const downloadDocument = async (documentVersion: DocumentVersion) => {
if (documentVersion.type === "notion") {
toast.error("Notion documents cannot be downloaded.");
return;
}
toast.promise(
(async () => {
const downloadUrl = await getFile({
type: documentVersion.storageType,
data: documentVersion.originalFile ?? documentVersion.file,
isDownload: true,
});
// Fetch the file from the S3 URL and create blob
const response = await fetch(downloadUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = prismaDocument.name;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
})(),
{
loading: "Downloading document...",
success: "Document downloaded successfully",
error: "Failed to download document",
},
);
};
return (
<header className="flex flex-col gap-y-4">
<div className="flex items-center justify-between gap-x-8">
<div className="flex items-center space-x-2">
{fileIcon({
fileType: prismaDocument.type ?? "",
className: "size-7 sm:size-8",
isLight,
})}
<div className="mt-1 flex flex-col lg:mt-0">
<h2
className="rounded-md border border-transparent px-1 py-0.5 text-lg font-semibold tracking-tight text-foreground duration-200 hover:cursor-text hover:border hover:border-border focus-visible:text-lg lg:px-3 lg:py-1 lg:text-xl lg:focus-visible:text-xl xl:text-2xl"
ref={nameRef}
contentEditable={true}
onFocus={() => setIsEditingName(true)}
onBlur={handleNameSubmit}
onKeyDown={preventEnterAndSubmit}
title="Click to edit"
dangerouslySetInnerHTML={{ __html: prismaDocument.name }}
/>
{isEditingName && (
<span className="mt-1 text-xs text-muted-foreground">
{`Press <Enter> to save the name.`}
</span>
)}
</div>
{prismaDocument.type === "sheet" &&
prismaDocument.advancedExcelEnabled && (
<ButtonTooltip content="Advanced Excel mode">
<span className="mt-1 text-xs">
<AdvancedSheet className="h-6 w-6" />
</span>
</ButtonTooltip>
)}
{prismaDocument.downloadOnly && (
<ButtonTooltip content="Download only">
<span className="text-xs">
<CloudDownloadIcon className="h-6 w-6" />
<span className="sr-only">This document is download only</span>
</span>
</ButtonTooltip>
)}
</div>
<div className="flex items-center gap-x-4 md:gap-x-2">
{primaryVersion.type !== "notion" &&
primaryVersion.type !== "link" &&
primaryVersion.type !== "sheet" &&
primaryVersion.type !== "zip" &&
primaryVersion.type !== "video" &&
(!orientationLoading ? (
<ButtonTooltip content="Change orientation">
<Button
variant="outline"
size="icon"
className="hidden size-8 sm:flex lg:size-9"
onClick={changeDocumentOrientation}
title={`Change document orientation to ${primaryVersion.isVertical ? "landscape" : "portrait"}`}
>
<PortraitLandscape
className={cn(
"h-6 w-6",
!primaryVersion.isVertical && "-rotate-90 transform",
)}
/>
</Button>
</ButtonTooltip>
) : (
<div className="hidden md:flex">
<LoadingSpinner className="h-6 w-6" />
</div>
))}
{primaryVersion.type !== "notion" &&
primaryVersion.type !== "link" && (
<AddDocumentModal
newVersion
openModal={openAddDocModal}
setAddDocumentModalOpen={setOpenAddDocModal}
>
<ButtonTooltip content="Upload new version">
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
setOpenAddDocModal(true);
}}
className="hidden size-8 md:flex lg:size-9"
>
<FileUp className="h-6 w-6" />
</Button>
</ButtonTooltip>
</AddDocumentModal>
)}
<div className="flex items-center gap-x-1">
{actionRows.map((row, i) => (
<ul
key={i.toString()}
className="flex flex-wrap items-center justify-end gap-x-2 md:flex-nowrap md:gap-x-1"
>
{row.map((action, i) => (
<li key={i}>{action}</li>
))}
</ul>
))}
</div>
<DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-8 bg-transparent p-0 lg:h-9 lg:w-9"
>
<span className="sr-only">Open menu</span>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[240px]"
ref={dropdownRef}
>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuGroup className="block md:hidden">
{prismaDocument.type !== "notion" &&
primaryVersion.type !== "video" && (
<DropdownMenuItem>
<AddDocumentModal
newVersion
setAddDocumentModalOpen={setAddDocumentVersion}
>
<button
title="Add a new version"
className="flex items-center"
onClick={(e) => {
e.stopPropagation();
setAddDocumentVersion(true);
}}
>
<FileUp className="mr-2 h-4 w-4" /> Add new version
</button>
</AddDocumentModal>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</DropdownMenuGroup>
{prismaDocument.type === "sheet" &&
supportsAdvancedExcelMode(primaryVersion.contentType) && (
<DropdownMenuItem
onClick={() =>
toggleAdvancedExcel(
prismaDocument,
!prismaDocument.advancedExcelEnabled,
)
}
>
<SheetIcon className="mr-2 h-4 w-4" />
{prismaDocument.advancedExcelEnabled
? "Disable Advanced Mode"
: "Enable Advanced Mode"}
</DropdownMenuItem>
)}
{datarooms && datarooms.length !== 0 && (
<DropdownMenuItem onClick={() => setAddDataRoomOpen(true)}>
<BetweenHorizontalStartIcon className="mr-2 h-4 w-4" />
Add to dataroom
</DropdownMenuItem>
)}
{primaryVersion.type !== "notion" &&
primaryVersion.type !== "link" &&
primaryVersion.type !== "zip" &&
primaryVersion.type !== "map" &&
primaryVersion.type !== "email" && (
<DropdownMenuItem onClick={toggleDownloadOnly}>
{prismaDocument.downloadOnly ? (
<>
<ViewIcon className="mr-2 h-4 w-4" />
Set viewable
</>
) : (
<>
<CloudDownloadIcon className="mr-2 h-4 w-4" />
Set download only
</>
)}
</DropdownMenuItem>
)}
{prismaDocument.type === "notion" && (
<>
{primaryVersion.file.includes("mode=dark") ? (
<DropdownMenuItem
onClick={() => toggleNotionDarkMode(false)}
>
<MoonIcon className="mr-2 h-4 w-4" />
Disable dark mode
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => toggleNotionDarkMode(true)}
>
<SunIcon className="mr-2 h-4 w-4" />
Enable dark mode
</DropdownMenuItem>
)}
</>
)}
<DropdownMenuSeparator />
{/* Export views in CSV */}
<DropdownMenuItem onClick={() => setExportModalOpen(true)}>
<FileDownIcon className="mr-2 h-4 w-4" />
Export views
</DropdownMenuItem>
{/* Download latest version */}
{primaryVersion.type !== "notion" &&
primaryVersion.type !== "link" &&
primaryVersion.type !== "video" && (
<DropdownMenuItem
onClick={() => downloadDocument(primaryVersion)}
>
<DownloadIcon className="mr-2 h-4 w-4" />
Download latest version
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={(event) => handleButtonClick(event, prismaDocument.id)}
>
<TrashIcon className="mr-2 h-4 w-4" />
{isFirstClick ? "Really delete?" : "Delete document"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Datarooms collapsible section */}
{dataroomCount > 0 && (
<div className="mb-2">
<Collapsible className="w-full">
<CollapsibleTrigger className="flex w-full items-center text-sm font-medium">
<div className="flex items-center space-x-2 [&[data-state=open]>svg.chevron]:rotate-180">
<ChevronRight className="h-4 w-4 transition-transform duration-200" />
<ServerIcon className="h-4 w-4 text-[#fb7a00]" />
<span>
In {dataroomCount} dataroom{dataroomCount > 1 ? "s" : ""}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="pl-6 pt-2">
<ul className="space-y-1">
{prismaDocument.datarooms?.map((item) => (
<li
key={item.dataroom.id}
className="flex items-center space-x-2 text-sm"
>
<ArrowRightIcon className="h-3.5 w-3.5" />
<Link
href={`/datarooms/${item.dataroom.id}/documents`}
className="hover:underline"
>
{item.dataroom.name}
</Link>
{item.folder ? (
<Link
href={`/datarooms/${item.dataroom.id}/documents/${item.folder.path}`}
className="flex flex-row items-center space-x-2 hover:underline"
title={`Folder: ${item.folder.name}`}
>
<ArrowRightIcon className="h-3.5 w-3.5" />
<FolderIcon className="mr-1 h-4 w-4" />
<span className="ml-1 truncate">
{item.folder.name}
</span>
</Link>
) : (
<Link
href={`/datarooms/${item.dataroom.id}/documents`}
className="flex flex-row items-center space-x-2 hover:underline"
title="Home"
>
<ArrowRightIcon className="h-3.5 w-3.5" />
<FolderIcon className="mr-1 h-4 w-4" />
<span className="ml-1 truncate">Home</span>
</Link>
)}
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</div>
)}
{prismaDocument.type === "sheet" &&
!prismaDocument.advancedExcelEnabled &&
supportsAdvancedExcelMode(primaryVersion.contentType) && (
<AlertBanner
id="enable-advanced-excel-alert"
variant="default"
title="Advanced Excel mode"
description={
<>
You can{" "}
<span
className="cursor-pointer underline underline-offset-4 hover:text-primary/80"
onClick={() => setMenuOpen(true)}
>
turn on
</span>{" "}
advanced excel mode to improve the file formatting.
<br /> The advanced mode uses Microsoft viewer.
</>
}
onClose={() => handleCloseAlert("enable-advanced-excel-alert")}
/>
)}
{addDataRoomOpen ? (
<AddToDataroomModal
open={addDataRoomOpen}
setOpen={setAddDataRoomOpen}
documentId={prismaDocument.id}
documentName={prismaDocument.name}
/>
) : null}
{exportModalOpen && (
<ExportVisitsModal
document={prismaDocument}
teamId={teamId}
onClose={() => setExportModalOpen(false)}
/>
)}
</header>
);
}