From 15740e5cbd735d32f2e1f840be99c07cdc1f72a1 Mon Sep 17 00:00:00 2001 From: samvitchoudhary Date: Mon, 27 Apr 2026 21:15:33 -0400 Subject: [PATCH 1/2] feat: add leave workspace for shared collaborators --- src/app/api/workspaces/[id]/leave/route.ts | 70 +++++++++++ .../workspace/WorkspaceSettingsModal.tsx | 115 ++++++++++++++---- src/contexts/WorkspaceContext.tsx | 52 ++++++++ 3 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 src/app/api/workspaces/[id]/leave/route.ts diff --git a/src/app/api/workspaces/[id]/leave/route.ts b/src/app/api/workspaces/[id]/leave/route.ts new file mode 100644 index 00000000..6c6a56d8 --- /dev/null +++ b/src/app/api/workspaces/[id]/leave/route.ts @@ -0,0 +1,70 @@ +/** + * Leave workspace API - allow a collaborator to remove themselves + * + * POST /api/workspaces/[id]/leave - Remove the current user's + * workspace_collaborators row for this workspace. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db/client"; +import { workspaceCollaborators, workspaces } from "@/lib/db/schema"; +import { eq, and } from "drizzle-orm"; + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: workspaceId } = await params; + const userId = session.user.id; + + const [workspace] = await db + .select({ userId: workspaces.userId }) + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .limit(1); + + if (!workspace) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + + if (workspace.userId === userId) { + return NextResponse.json( + { + error: + "Workspace owners cannot leave their own workspace. Transfer ownership or delete the workspace instead.", + }, + { status: 400 } + ); + } + + const [deleted] = await db + .delete(workspaceCollaborators) + .where( + and( + eq(workspaceCollaborators.workspaceId, workspaceId), + eq(workspaceCollaborators.userId, userId), + ) + ) + .returning(); + + if (!deleted) { + return NextResponse.json( + { error: "You are not a collaborator of this workspace." }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error leaving workspace:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/components/workspace/WorkspaceSettingsModal.tsx b/src/components/workspace/WorkspaceSettingsModal.tsx index 87950307..301f4ec6 100644 --- a/src/components/workspace/WorkspaceSettingsModal.tsx +++ b/src/components/workspace/WorkspaceSettingsModal.tsx @@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; -import { Loader2, Trash2 } from "lucide-react"; +import { Loader2, LogOut, Trash2 } from "lucide-react"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; import { IconPicker } from "@/components/workspace/IconPicker"; import { IconRenderer } from "@/hooks/use-icon-picker"; @@ -46,7 +46,8 @@ export default function WorkspaceSettingsModal({ onOpenChange, onUpdate, }: WorkspaceSettingsModalProps) { - const { deleteWorkspace, updateWorkspaceLocal } = useWorkspaceContext(); + const { deleteWorkspace, leaveWorkspace, updateWorkspaceLocal } = + useWorkspaceContext(); const router = useRouter(); const [name, setName] = useState(""); const [selectedIcon, setSelectedIcon] = useState(null); @@ -56,6 +57,9 @@ export default function WorkspaceSettingsModal({ const [error, setError] = useState(""); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [showLeaveDialog, setShowLeaveDialog] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); + const isShared = workspace?.isShared ?? false; useEffect(() => { if (workspace && open) { @@ -156,6 +160,29 @@ export default function WorkspaceSettingsModal({ } }; + const handleLeaveClick = () => { + setShowLeaveDialog(true); + }; + + const handleLeaveConfirm = async () => { + if (!workspace) return; + + setIsLeaving(true); + try { + await leaveWorkspace(workspace.id); + setShowLeaveDialog(false); + onOpenChange(false); + if (onUpdate) { + onUpdate(); + } + } catch (err) { + console.error("Error leaving workspace:", err); + setError(err instanceof Error ? err.message : "Failed to leave workspace"); + } finally { + setIsLeaving(false); + } + }; + if (!workspace) return null; return ( @@ -259,34 +286,54 @@ export default function WorkspaceSettingsModal({ - {/* Delete Workspace */} + {/* Danger Zone — Delete (owner) or Leave (collaborator) */}
-
-
-

Delete Workspace

-

- Delete this workspace and all its data. This action cannot be undone right now. -

+ {isShared ? ( +
+
+

Leave Workspace

+

+ Remove yourself from this workspace. You'll lose access to all its content. The owner can re-invite you later. +

+
+
- -
+ ) : ( +
+
+

Delete Workspace

+

+ Delete this workspace and all its data. This action cannot be undone right now. +

+
+ +
+ )}
- - @@ -319,6 +366,32 @@ export default function WorkspaceSettingsModal({ + + {/* Leave Confirmation Dialog */} + + + + Leave Workspace + + Are you sure you want to leave "{workspace?.name}"? You'll lose + access to all its content. The owner can re-invite you later. + + + + Cancel + + {isLeaving && } + Leave + + + + ); } diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index e045c1b7..fb2c1a18 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -36,6 +36,7 @@ interface WorkspaceContextType { // Actions switchWorkspace: (slug: string) => void; deleteWorkspace: (workspaceId: string) => Promise; + leaveWorkspace: (workspaceId: string) => Promise; markWorkspaceOpened: (workspaceId: string) => void; } @@ -206,6 +207,56 @@ export function WorkspaceProvider({ [deleteWorkspaceMutation], ); + // Leave workspace mutation (collaborator self-removal). Mirrors the + // delete-workspace cache + redirect handling because both flows remove the + // workspace from the user's list and may need to navigate away. + const leaveWorkspaceMutation = useMutation({ + mutationFn: async (workspaceId: string) => { + const response = await fetch(`/api/workspaces/${workspaceId}/leave`, { + method: "POST", + }); + if (!response.ok) { + throw new Error("Failed to leave workspace"); + } + return workspaceId; + }, + onSuccess: (leftWorkspaceId) => { + queryClient.setQueryData( + ["workspaces"], + (old: WorkspaceWithState[] | undefined) => { + if (!old) return []; + const remainingWorkspaces = old.filter( + (w) => w.id !== leftWorkspaceId, + ); + + if (leftWorkspaceId === currentWorkspaceId && currentSlug) { + if (remainingWorkspaces.length > 0) { + switchWorkspace( + remainingWorkspaces[0].slug || remainingWorkspaces[0].id, + ); + } else { + router.push("/home"); + } + } + + return remainingWorkspaces; + }, + ); + + toast.success("Left workspace successfully"); + }, + onError: () => { + toast.error("Failed to leave workspace"); + }, + }); + + const leaveWorkspace = useCallback( + async (workspaceId: string) => { + await leaveWorkspaceMutation.mutateAsync(workspaceId); + }, + [leaveWorkspaceMutation], + ); + // Optimistically update a single workspace locally without refetching const updateWorkspaceLocal = useCallback( (workspaceId: string, updates: Partial) => { @@ -271,6 +322,7 @@ export function WorkspaceProvider({ currentSlug, switchWorkspace, deleteWorkspace, + leaveWorkspace, markWorkspaceOpened, }; From bb8307d46ecb0e2cfa5d48037bb5463823303562 Mon Sep 17 00:00:00 2001 From: samvitchoudhary Date: Tue, 28 Apr 2026 02:19:14 -0400 Subject: [PATCH 2/2] address PR feedback: ID enumeration, UUID validation, cold-cache redirect --- src/app/api/workspaces/[id]/leave/route.ts | 18 ++++++++++++++++-- src/contexts/WorkspaceContext.tsx | 3 +-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/app/api/workspaces/[id]/leave/route.ts b/src/app/api/workspaces/[id]/leave/route.ts index 6c6a56d8..70065d31 100644 --- a/src/app/api/workspaces/[id]/leave/route.ts +++ b/src/app/api/workspaces/[id]/leave/route.ts @@ -12,6 +12,9 @@ import { db } from "@/lib/db/client"; import { workspaceCollaborators, workspaces } from "@/lib/db/schema"; import { eq, and } from "drizzle-orm"; +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export async function POST( _request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -23,6 +26,14 @@ export async function POST( } const { id: workspaceId } = await params; + + if (!UUID_REGEX.test(workspaceId)) { + return NextResponse.json( + { error: "Invalid workspace id" }, + { status: 400 } + ); + } + const userId = session.user.id; const [workspace] = await db @@ -32,7 +43,10 @@ export async function POST( .limit(1); if (!workspace) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + return NextResponse.json( + { error: "Workspace not found or you are not a collaborator" }, + { status: 404 } + ); } if (workspace.userId === userId) { @@ -57,7 +71,7 @@ export async function POST( if (!deleted) { return NextResponse.json( - { error: "You are not a collaborator of this workspace." }, + { error: "Workspace not found or you are not a collaborator" }, { status: 404 } ); } diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index fb2c1a18..7ede04bd 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -224,8 +224,7 @@ export function WorkspaceProvider({ queryClient.setQueryData( ["workspaces"], (old: WorkspaceWithState[] | undefined) => { - if (!old) return []; - const remainingWorkspaces = old.filter( + const remainingWorkspaces = (old ?? []).filter( (w) => w.id !== leftWorkspaceId, );