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

690 lines
20 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { BookOpenIcon, EyeIcon } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { uploadImage } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface IntroductionSettingsProps {
dataroomId: string;
}
interface FolderItem {
id: string;
name: string;
documents?: { document: { name: string } }[];
}
interface DocumentItem {
document: { name: string };
}
// Generate TipTap JSON content for introduction based on dataroom structure
function generateIntroductionContent(
dataroomName: string,
folders: FolderItem[],
rootDocuments: DocumentItem[],
): any {
const content: any[] = [];
// Overview paragraph (no "Welcome to" repetition)
content.push({
type: "paragraph",
content: [
{
type: "text",
text: `This data room contains confidential documents and materials prepared for your review. Please take a moment to familiarize yourself with the structure below.`,
},
],
});
// What's Inside section
content.push({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "What's Inside" }],
});
// If there are folders, list them
if (folders.length > 0) {
const folderList = {
type: "bulletList",
content: folders.slice(0, 8).map((folder) => ({
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: folder.name,
marks: [{ type: "bold" }],
},
...(folder.documents && folder.documents.length > 0
? [
{
type: "text",
text: ` — ${folder.documents.length} document${folder.documents.length > 1 ? "s" : ""}`,
},
]
: []),
],
},
],
})),
};
content.push(folderList);
if (folders.length > 8) {
content.push({
type: "paragraph",
content: [
{
type: "text",
text: `...and ${folders.length - 8} more sections.`,
},
],
});
}
} else {
// Show placeholder sections if dataroom is empty
const placeholderList = {
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 1", marks: [{ type: "bold" }] },
{ type: "text", text: " — Company Overview & Key Documents" },
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 2", marks: [{ type: "bold" }] },
{ type: "text", text: " — Financial Information" },
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 3", marks: [{ type: "bold" }] },
{ type: "text", text: " — Legal & Compliance" },
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Section 4", marks: [{ type: "bold" }] },
{ type: "text", text: " — Additional Materials" },
],
},
],
},
],
};
content.push(placeholderList);
}
// If there are root documents, mention them
if (rootDocuments.length > 0) {
content.push({
type: "paragraph",
content: [
{
type: "text",
text: `There ${rootDocuments.length === 1 ? "is" : "are"} also ${rootDocuments.length} document${rootDocuments.length > 1 ? "s" : ""} available at the root level for quick access.`,
},
],
});
}
// How to Navigate section
content.push({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "How to Navigate" }],
});
content.push({
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Use the sidebar on the left to browse sections and folders",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Click on any document to open and view it",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Use the search function to find specific documents quickly",
},
],
},
],
},
],
});
// Placeholder for navigation screenshot
// Need Help section
content.push({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Need Help?" }],
});
content.push({
type: "paragraph",
content: [
{
type: "text",
text: "If you have any questions or need assistance, please reach out to your designated contact. We're here to help you navigate this data room effectively.",
},
],
});
return {
type: "doc",
content,
};
}
// Helper to render inline text nodes with marks (bold, italic, etc.)
function renderInlineContent(nodes: any[] | undefined): React.ReactNode {
if (!nodes) return null;
return nodes.map((textNode: any, textIndex: number) => {
if (textNode.type === "text") {
let text: React.ReactNode = textNode.text;
if (textNode.marks) {
textNode.marks.forEach((mark: any) => {
if (mark.type === "bold") {
text = (
<strong key={`bold-${textIndex}`} className="font-semibold">
{text}
</strong>
);
} else if (mark.type === "italic") {
text = (
<em key={`italic-${textIndex}`} className="italic">
{text}
</em>
);
}
});
}
return <React.Fragment key={textIndex}>{text}</React.Fragment>;
} else if (textNode.type === "image") {
return (
<img
key={textIndex}
src={textNode.attrs?.src}
alt={textNode.attrs?.alt || ""}
className="my-2 h-auto max-w-full rounded-md"
/>
);
}
return null;
});
}
// Render TipTap JSON content for preview
function renderContent(content: any): React.ReactNode {
if (!content || !content.content) return null;
return content.content.map((node: any, index: number) => {
if (node.type === "heading") {
const level = node.attrs?.level || 1;
const text = node.content?.[0]?.text || "";
if (level === 1) {
return (
<h1
key={index}
className="mb-3 mt-4 text-xl font-bold text-gray-900 first:mt-0"
>
{text}
</h1>
);
}
return (
<h2
key={index}
className="mb-2 mt-4 text-base font-semibold text-gray-800 first:mt-0"
>
{text}
</h2>
);
} else if (node.type === "paragraph") {
return (
<p key={index} className="mb-3 text-sm leading-relaxed text-gray-700">
{renderInlineContent(node.content)}
</p>
);
} else if (node.type === "bulletList") {
return (
<ul key={index} className="mb-3 list-disc pl-5 text-sm text-gray-700">
{node.content?.map((item: any, itemIndex: number) => (
<li key={itemIndex} className="mb-1">
{renderInlineContent(item.content?.[0]?.content)}
</li>
))}
</ul>
);
} else if (node.type === "orderedList") {
return (
<ol
key={index}
className="mb-3 list-decimal pl-5 text-sm text-gray-700"
>
{node.content?.map((item: any, itemIndex: number) => (
<li key={itemIndex} className="mb-1">
{renderInlineContent(item.content?.[0]?.content)}
</li>
))}
</ol>
);
} else if (node.type === "blockquote") {
return (
<blockquote
key={index}
className="mb-3 border-l-4 border-gray-300 pl-4 italic text-gray-600"
>
{node.content?.map((p: any) =>
p.content?.map((textNode: any) =>
textNode.type === "text" ? textNode.text : null,
),
)}
</blockquote>
);
} else if (node.type === "image") {
return (
<img
key={index}
src={node.attrs?.src}
alt={node.attrs?.alt || ""}
className="my-3 h-auto max-w-full rounded-md"
/>
);
} else if (node.type === "youtube") {
// Extract video ID from the src URL
const src = node.attrs?.src || "";
let videoId = "";
// Handle different YouTube URL formats
const youtubeMatch = src.match(
/(?:youtube(?:-nocookie)?\.com\/(?:embed\/|watch\?v=)|youtu\.be\/)([^?&]+)/,
);
if (youtubeMatch) {
videoId = youtubeMatch[1];
}
if (!videoId) return null;
return (
<div key={index} className="my-4 aspect-video w-full">
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title="YouTube video"
className="h-full w-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
);
}
return null;
});
}
export default function IntroductionSettings({
dataroomId,
}: IntroductionSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const [isFetching, setIsFetching] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [introductionEnabled, setIntroductionEnabled] = useState(false);
const [introductionContent, setIntroductionContent] = useState<any>({
type: "doc",
content: [],
});
const [showPreview, setShowPreview] = useState(false);
const [dataroomName, setDataroomName] = useState<string>("Data Room");
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasInitialLoadRef = useRef(false);
const skipNextAutosaveRef = useRef(false);
// Fetch current introduction settings from dataroom
useEffect(() => {
const fetchSettings = async () => {
if (!teamId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}`,
);
if (response.ok) {
const data = await response.json();
setIntroductionEnabled(data.introductionEnabled || false);
setDataroomName(data.name || "Data Room");
const existingContent = data.introductionContent;
const hasExistingContent =
existingContent?.content && existingContent.content.length > 0;
if (hasExistingContent) {
setIntroductionContent(existingContent);
} else {
// Auto-generate introduction if empty
try {
const foldersResponse = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/folders?include_documents=true`,
);
let folders: FolderItem[] = [];
let rootDocuments: DocumentItem[] = [];
if (foldersResponse.ok) {
const foldersData = await foldersResponse.json();
folders = foldersData.filter(
(item: any) => item.name && !item.document,
);
rootDocuments = foldersData.filter(
(item: any) => item.document,
);
}
const generatedContent = generateIntroductionContent(
data.name || "Data Room",
folders,
rootDocuments,
);
setIntroductionContent(generatedContent);
} catch (genError) {
console.error("Failed to auto-generate introduction:", genError);
setIntroductionContent({ type: "doc", content: [] });
}
}
}
} catch (error) {
console.error("Failed to fetch introduction settings:", error);
} finally {
setIsFetching(false);
hasInitialLoadRef.current = true;
skipNextAutosaveRef.current = true;
}
};
fetchSettings();
}, [teamId, dataroomId]);
// Auto-save function
const saveSettings = useCallback(
async (enabled: boolean, content: any) => {
if (!teamId) return;
setIsSaving(true);
try {
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
introductionEnabled: enabled,
introductionContent: content,
}),
},
);
if (response.ok) {
await mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`);
} else {
toast.error("Failed to save introduction settings");
}
} catch (error) {
console.error("Failed to save introduction settings:", error);
toast.error("Failed to save introduction settings");
} finally {
setIsSaving(false);
}
},
[teamId, dataroomId],
);
// Debounced auto-save on content change
useEffect(() => {
if (!hasInitialLoadRef.current) return;
// Skip the first auto-save pass after initial load
if (skipNextAutosaveRef.current) {
skipNextAutosaveRef.current = false;
return;
}
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveSettings(introductionEnabled, introductionContent);
}, 1000);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [introductionContent, introductionEnabled, saveSettings]);
const handleImageUpload = async (file: File): Promise<string> => {
try {
const imageUrl = await uploadImage(file, "assets");
return imageUrl;
} catch (error) {
console.error("Failed to upload image:", error);
throw new Error("Failed to upload image");
}
};
const handleToggle = (checked: boolean) => {
setIntroductionEnabled(checked);
if (checked) {
toast.success("Introduction page enabled");
}
};
const hasContent =
introductionContent?.content && introductionContent.content.length > 0;
if (isFetching) {
return (
<Card className="bg-transparent">
<CardContent className="flex items-center justify-center py-10">
<LoadingSpinner className="h-6 w-6" />
</CardContent>
</Card>
);
}
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
Introduction Page{" "}
{isSaving && (
<span className="text-xs font-normal text-muted-foreground">
Saving...
</span>
)}
</CardTitle>
<CardDescription>
Create an introduction page that will be shown to viewers when they
first access your data room. Write your message based on the premade
template below. You can edit, add and remove sections as you see fit.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Toggle and Preview */}
<div className="flex items-center justify-between">
<Label
htmlFor="introduction-toggle"
className="flex items-center gap-2"
>
<BookOpenIcon className="h-4 w-4" />
Show introduction on first visit
</Label>
<div className="flex items-center gap-2">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setShowPreview(true)}
disabled={!hasContent}
className="h-8 w-8"
>
<EyeIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Preview introduction</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
id="introduction-toggle"
checked={introductionEnabled}
onCheckedChange={handleToggle}
/>
</div>
</div>
{/* Rich Text Editor */}
<div className="space-y-2">
<RichTextEditor
content={introductionContent}
onChange={setIntroductionContent}
placeholder="Welcome to our data room! Here you'll find..."
onImageUpload={handleImageUpload}
/>
</div>
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden border-0 p-0 shadow-2xl sm:max-w-xl sm:rounded-2xl md:max-w-2xl">
<DialogHeader className="border-b border-gray-100 bg-gray-50 px-4 py-6 dark:border-gray-800 dark:bg-gray-900 sm:px-6">
<p className="mb-1 text-xs text-muted-foreground">
This is a preview
</p>
<DialogTitle className="text-xl font-semibold">
Welcome to {dataroomName}
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[55vh] px-4 py-5 sm:px-6">
<div className="prose prose-sm max-w-none dark:prose-invert">
{renderContent(introductionContent)}
</div>
</ScrollArea>
<div className="flex justify-end border-t border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-900 sm:px-6 sm:py-4">
<Button onClick={() => setShowPreview(false)}>
Continue to Data Room
</Button>
</div>
</DialogContent>
</Dialog>
</CardContent>
<CardFooter className="flex items-center rounded-b-lg border-t bg-muted px-6 py-4">
<p className="text-sm text-muted-foreground">
This page will appear as a welcome popup when visitors first open the
data room. Changes are saved automatically.
</p>
</CardFooter>
</Card>
);
}