From 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
Date: Mon, 17 Nov 2025 01:12:41 +0000
Subject: feat: Add collaborative lists (#2146)

* feat: Add collaborative lists backend implementation

This commit implements the core backend functionality for collaborative
lists, allowing multiple users to share and interact with bookmark lists.

Database changes:
- Add listCollaborators table to track users with access to lists and
  their roles (viewer/editor)
- Add addedBy field to bookmarksInLists to track who added bookmarks
- Add relations for collaborative list functionality

Access control updates:
- Update List model to support role-based access (owner/editor/viewer)
- Add methods to check and enforce permissions for list operations
- Update Bookmark model to allow access through collaborative lists
- Modify bookmark queries to include bookmarks from collaborative lists

List collaboration features:
- Add/remove/update collaborators
- Get list of collaborators
- Get lists shared with current user
- Only manual lists can have collaborators

tRPC procedures:
- addCollaborator: Add a user as a collaborator to a list
- removeCollaborator: Remove a collaborator from a list
- updateCollaboratorRole: Change a collaborator's role
- getCollaborators: Get all collaborators for a list
- getSharedWithMe: Get all lists shared with the current user
- cloneBookmark: Clone a bookmark to the current user's collection

Implementation notes:
- Editors can add/remove bookmarks from the list (must own the bookmark)
- Viewers can only view bookmarks in the list
- Only the list owner can manage collaborators and list metadata
- Smart lists cannot have collaborators (only manual lists)
- Users cannot edit bookmarks they don't own, even in shared lists

* feat: Add collaborative lists frontend UI

This commit implements the frontend user interface for collaborative lists,
allowing users to view shared bookmarks and manage list collaborators.

New pages:
- /dashboard/shared: Shows bookmarks from lists shared with the user
  - Displays bookmarks from all collaborative lists
  - Uses SharedBookmarks component
  - Shows empty state when no lists are shared

Navigation:
- Added "Shared with you" link to sidebar with Users icon
- Positioned after "Home" in main navigation
- Available in both desktop and mobile sidebar

Collaborator management:
- ManageCollaboratorsModal component for managing list collaborators
  - Add collaborators by user ID with viewer/editor role
  - View current collaborators with their roles
  - Update collaborator roles inline
  - Remove collaborators
  - Shows empty state when no collaborators
- Integrated into ListOptions dropdown menu
- Accessible via "Manage Collaborators" menu item

Components created:
- SharedBookmarks.tsx: Server component fetching shared lists/bookmarks
- ManageCollaboratorsModal.tsx: Client component with tRPC mutations
- /dashboard/shared/page.tsx: Route for shared bookmarks page

UI features:
- Role selector for viewer/editor permissions
- Real-time collaborator list updates
- Toast notifications for success/error states
- Loading states for async operations
- Responsive design matching existing UI patterns

Implementation notes:
- Uses existing tRPC endpoints (getSharedWithMe, getCollaborators, etc.)
- Follows established modal patterns from ShareListModal
- Integrates seamlessly with existing list UI
- Currently uses user ID for adding collaborators (email lookup TBD)

* fix typecheck

* add collaborator by email

* add shared list in the sidebar

* fix perm issue

* hide UI components from non list owners

* list leaving

* fix shared bookmarks showing up in homepage

* fix getBookmark access check

* e2e tests

* hide user specific fields from shared lists

* simplify bookmark perm checks

* disable editable fields in bookmark preview

* hide lists if they don't have options

* fix list ownership

* fix highlights

* move tests to trpc

* fix alignment of leave list

* make tag lists unclickable

* allow editors to remove from list

* add a badge for shared lists

* remove bookmarks of user when they're removed from a list

* fix tests

* show owner in the manage collab modal

* fix hasCollab

* drop shared with you

* i18n

* beta badge

* correctly invalidate caches on collab change

* reduce unnecessary changes

* Add ratelimits

* stop manually removing bookmarks on remove

* some fixes

* fixes

* remove unused function

* improve tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
---
 .../web/components/dashboard/lists/ListOptions.tsx | 161 +++++++++++++++------
 1 file changed, 118 insertions(+), 43 deletions(-)

(limited to 'apps/web/components/dashboard/lists/ListOptions.tsx')

diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index 7e020374..b80ac680 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -8,6 +8,7 @@ import {
 import { useShowArchived } from "@/components/utils/useShowArchived";
 import { useTranslation } from "@/lib/i18n/client";
 import {
+  DoorOpen,
   FolderInput,
   Pencil,
   Plus,
@@ -15,12 +16,15 @@ import {
   Square,
   SquareCheck,
   Trash2,
+  Users,
 } from "lucide-react";
 
 import { ZBookmarkList } from "@karakeep/shared/types/lists";
 
 import { EditListModal } from "../lists/EditListModal";
 import DeleteListConfirmationDialog from "./DeleteListConfirmationDialog";
+import LeaveListConfirmationDialog from "./LeaveListConfirmationDialog";
+import { ManageCollaboratorsModal } from "./ManageCollaboratorsModal";
 import { MergeListModal } from "./MergeListModal";
 import { ShareListModal } from "./ShareListModal";
 
@@ -39,10 +43,102 @@ export function ListOptions({
   const { showArchived, onClickShowArchived } = useShowArchived();
 
   const [deleteListDialogOpen, setDeleteListDialogOpen] = useState(false);
+  const [leaveListDialogOpen, setLeaveListDialogOpen] = useState(false);
   const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
   const [mergeListModalOpen, setMergeListModalOpen] = useState(false);
   const [editModalOpen, setEditModalOpen] = useState(false);
   const [shareModalOpen, setShareModalOpen] = useState(false);
+  const [collaboratorsModalOpen, setCollaboratorsModalOpen] = useState(false);
+
+  // Only owners can manage the list (edit, delete, manage collaborators, etc.)
+  const isOwner = list.userRole === "owner";
+  // Collaborators (non-owners) can leave the list
+  const isCollaborator =
+    list.userRole === "editor" || list.userRole === "viewer";
+
+  // Define action items array
+  const actionItems = [
+    {
+      id: "edit",
+      title: t("actions.edit"),
+      icon: <Pencil className="size-4" />,
+      visible: isOwner,
+      disabled: false,
+      onClick: () => setEditModalOpen(true),
+    },
+    {
+      id: "share",
+      title: t("lists.share_list"),
+      icon: <Share className="size-4" />,
+      visible: isOwner,
+      disabled: false,
+      onClick: () => setShareModalOpen(true),
+    },
+    {
+      id: "manage-collaborators",
+      title: isOwner
+        ? t("lists.collaborators.manage")
+        : t("lists.collaborators.view"),
+      icon: <Users className="size-4" />,
+      visible: true, // Always visible for all roles
+      disabled: false,
+      onClick: () => setCollaboratorsModalOpen(true),
+    },
+    {
+      id: "new-nested-list",
+      title: t("lists.new_nested_list"),
+      icon: <Plus className="size-4" />,
+      visible: isOwner,
+      disabled: false,
+      onClick: () => setNewNestedListModalOpen(true),
+    },
+    {
+      id: "merge-list",
+      title: t("lists.merge_list"),
+      icon: <FolderInput className="size-4" />,
+      visible: isOwner,
+      disabled: false,
+      onClick: () => setMergeListModalOpen(true),
+    },
+    {
+      id: "toggle-archived",
+      title: t("actions.toggle_show_archived"),
+      icon: showArchived ? (
+        <SquareCheck className="size-4" />
+      ) : (
+        <Square className="size-4" />
+      ),
+      visible: true,
+      disabled: false,
+      onClick: onClickShowArchived,
+    },
+    {
+      id: "leave-list",
+      title: t("lists.leave_list.action"),
+      icon: <DoorOpen className="size-4" />,
+      visible: isCollaborator,
+      disabled: false,
+      className: "flex gap-2 text-destructive",
+      onClick: () => setLeaveListDialogOpen(true),
+    },
+    {
+      id: "delete",
+      title: t("actions.delete"),
+      icon: <Trash2 className="size-4" />,
+      visible: isOwner,
+      disabled: false,
+      className: "flex gap-2 text-destructive",
+      onClick: () => setDeleteListDialogOpen(true),
+    },
+  ];
+
+  // Filter visible items
+  const visibleItems = actionItems.filter((item) => item.visible);
+
+  // If no items are visible, don't render the dropdown
+  if (visibleItems.length === 0) {
+    return null;
+  }
 
   return (
     <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
@@ -51,6 +147,12 @@ export function ListOptions({
         setOpen={setShareModalOpen}
         list={list}
       />
+      <ManageCollaboratorsModal
+        open={collaboratorsModalOpen}
+        setOpen={setCollaboratorsModalOpen}
+        list={list}
+        readOnly={!isOwner}
+      />
       <EditListModal
         open={newNestedListModalOpen}
         setOpen={setNewNestedListModalOpen}
@@ -73,51 +175,24 @@ export function ListOptions({
         open={deleteListDialogOpen}
         setOpen={setDeleteListDialogOpen}
       />
+      <LeaveListConfirmationDialog
+        list={list}
+        open={leaveListDialogOpen}
+        setOpen={setLeaveListDialogOpen}
+      />
       <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
       <DropdownMenuContent>
-        <DropdownMenuItem
-          className="flex gap-2"
-          onClick={() => setEditModalOpen(true)}
-        >
-          <Pencil className="size-4" />
-          <span>{t("actions.edit")}</span>
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          className="flex gap-2"
-          onClick={() => setShareModalOpen(true)}
-        >
-          <Share className="size-4" />
-          <span>{t("lists.share_list")}</span>
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          className="flex gap-2"
-          onClick={() => setNewNestedListModalOpen(true)}
-        >
-          <Plus className="size-4" />
-          <span>{t("lists.new_nested_list")}</span>
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          className="flex gap-2"
-          onClick={() => setMergeListModalOpen(true)}
-        >
-          <FolderInput className="size-4" />
-          <span>{t("lists.merge_list")}</span>
-        </DropdownMenuItem>
-        <DropdownMenuItem className="flex gap-2" onClick={onClickShowArchived}>
-          {showArchived ? (
-            <SquareCheck className="size-4" />
-          ) : (
-            <Square className="size-4" />
-          )}
-          <span>{t("actions.toggle_show_archived")}</span>
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          className="flex gap-2"
-          onClick={() => setDeleteListDialogOpen(true)}
-        >
-          <Trash2 className="size-4" />
-          <span>{t("actions.delete")}</span>
-        </DropdownMenuItem>
+        {visibleItems.map((item) => (
+          <DropdownMenuItem
+            key={item.id}
+            className={item.className ?? "flex gap-2"}
+            disabled={item.disabled}
+            onClick={item.onClick}
+          >
+            {item.icon}
+            <span>{item.title}</span>
+          </DropdownMenuItem>
+        ))}
       </DropdownMenuContent>
     </DropdownMenu>
   );
-- 
cgit v1.2.3-70-g09d2

