Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/app/api/workspaces/[id]/leave/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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";

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 }> }
) {
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;

if (!UUID_REGEX.test(workspaceId)) {
return NextResponse.json(
{ error: "Invalid workspace id" },
{ status: 400 }
);
}

const userId = session.user.id;

const [workspace] = await db
.select({ userId: workspaces.userId })
.from(workspaces)
.where(eq(workspaces.id, workspaceId))
.limit(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!workspace) {
return NextResponse.json(
{ error: "Workspace not found or you are not a collaborator" },
{ 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: "Workspace not found or you are not a collaborator" },
{ status: 404 }
);
}
Comment on lines +72 to +77
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Workspace existence leaks information to authenticated users

The endpoint returns a 404 with "Workspace not found" when the workspace doesn't exist, and a 404 with "You are not a collaborator of this workspace." when the user is just not a member. Any authenticated user can distinguish these two cases and enumerate valid workspace IDs by probing this endpoint — they never need to be a collaborator. Consider returning the same 404 body in both cases ("Workspace not found or you are not a collaborator") to avoid the distinction.


return NextResponse.json({ success: true });
} catch (error) {
console.error("Error leaving workspace:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
115 changes: 94 additions & 21 deletions src/components/workspace/WorkspaceSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
Expand All @@ -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) {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -259,34 +286,54 @@ export default function WorkspaceSettingsModal({

<Separator className="my-4" />

{/* Delete Workspace */}
{/* Danger Zone — Delete (owner) or Leave (collaborator) */}
<div className="space-y-2">
<Label className="text-destructive mb-3 block">Danger Zone</Label>
<div className="flex items-center justify-between p-3 rounded-md border border-destructive/20 bg-destructive/5">
<div>
<p className="text-sm font-medium">Delete Workspace</p>
<p className="text-xs text-muted-foreground">
Delete this workspace and all its data. This action cannot be undone right now.
</p>
{isShared ? (
<div className="flex items-center justify-between p-3 rounded-md border border-destructive/20 bg-destructive/5">
<div>
<p className="text-sm font-medium">Leave Workspace</p>
<p className="text-xs text-muted-foreground">
Remove yourself from this workspace. You&apos;ll lose access to all its content. The owner can re-invite you later.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleLeaveClick}
disabled={isSaving || isLeaving}
>
<LogOut className="h-4 w-4 mr-2" />
Leave
</Button>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteClick}
disabled={isSaving || isDeleting}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
) : (
<div className="flex items-center justify-between p-3 rounded-md border border-destructive/20 bg-destructive/5">
<div>
<p className="text-sm font-medium">Delete Workspace</p>
<p className="text-xs text-muted-foreground">
Delete this workspace and all its data. This action cannot be undone right now.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteClick}
disabled={isSaving || isDeleting}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
)}
</div>
</div>

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving || isDeleting}>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving || isDeleting || isLeaving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isDeleting || !name.trim()}>
<Button onClick={handleSave} disabled={isSaving || isDeleting || isLeaving || !name.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
Expand Down Expand Up @@ -319,6 +366,32 @@ export default function WorkspaceSettingsModal({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Leave Confirmation Dialog */}
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>
<AlertDialogContent
className=""
>
<AlertDialogHeader>
<AlertDialogTitle>Leave Workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to leave &quot;{workspace?.name}&quot;? You&apos;ll lose
access to all its content. The owner can re-invite you later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLeaving}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleLeaveConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isLeaving}
>
{isLeaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Leave
Comment on lines +383 to +390
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 AlertDialogAction closes the dialog before the async handler resolves

Radix UI's AlertDialogAction calls onOpenChange(false) synchronously on click (before onClick resolves), so the confirmation dialog closes immediately. The loading spinner (isLeaving) and the disabled={isLeaving} guard on the Cancel button inside the dialog are both rendered invisible before the fetch completes. This mirrors the existing delete-dialog pattern, but it means any error from handleLeaveConfirm silently surfaces in the outer settings modal's error state rather than inside the confirmation dialog where the user expects it. Consider using a plain Button instead of AlertDialogAction, or call event.preventDefault() and gate dialog close on completion.

</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
Expand Down
51 changes: 51 additions & 0 deletions src/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface WorkspaceContextType {
// Actions
switchWorkspace: (slug: string) => void;
deleteWorkspace: (workspaceId: string) => Promise<void>;
leaveWorkspace: (workspaceId: string) => Promise<void>;
markWorkspaceOpened: (workspaceId: string) => void;
}

Expand Down Expand Up @@ -206,6 +207,55 @@ 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");
}
Comment on lines +218 to +220
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Server error body is not surfaced to the caller

When response.ok is false, the mutation throws a generic "Failed to leave workspace" string and ignores the server's JSON body. The API returns useful messages (e.g. "Workspace owners cannot leave their own workspace..." for a 400), but those are silently swallowed. The onError toast also shows the same generic message. Consider reading response.json() and including the server's error field in the thrown Error, so the handleLeaveConfirm catch block (and the toast) can surface the real reason to the user.

return workspaceId;
},
onSuccess: (leftWorkspaceId) => {
queryClient.setQueryData(
["workspaces"],
(old: WorkspaceWithState[] | undefined) => {
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");
}
}
Comment on lines +226 to +239
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redirect logic is skipped when ["workspaces"] cache is empty.

At Line 227, if (!old) return [] exits before the current-workspace redirect check. If the list cache isn’t ready, leaving the active workspace can succeed without navigation fallback.

Proposed fix
       queryClient.setQueryData(
         ["workspaces"],
         (old: WorkspaceWithState[] | undefined) => {
-          if (!old) return [];
-          const remainingWorkspaces = old.filter(
+          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;
         },
       );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(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");
}
}
(old: WorkspaceWithState[] | undefined) => {
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;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/contexts/WorkspaceContext.tsx` around lines 226 - 240, The early return
on null cache prevents the redirect when the workspaces list isn't loaded;
update the updater to compute remainingWorkspaces using const existing = old ||
[]; (i.e. don't return early), then perform the redirect logic that checks
leftWorkspaceId === currentWorkspaceId and currentSlug and calls
switchWorkspace(remainingWorkspaces[0].slug || remainingWorkspaces[0].id) or
router.push("/home") using remainingWorkspaces, and finally return
remainingWorkspaces; reference the updater closure variables leftWorkspaceId,
currentWorkspaceId, currentSlug, switchWorkspace, router.push and ensure the
function returns the updated array instead of returning early when old is
undefined.


return remainingWorkspaces;
},
);

toast.success("Left workspace successfully");
},
Comment on lines +223 to +246
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Evict left-workspace metadata cache after successful leave.

Only the ["workspaces"] list is updated. The per-workspace query (["workspace-by-slug", ...]) can remain fresh and serve stale data after leave (especially on back-nav).

Proposed fix
     onSuccess: (leftWorkspaceId) => {
+      const previous = queryClient.getQueryData<WorkspaceWithState[]>(["workspaces"]) ?? [];
+      const leftWorkspace = previous.find((w) => w.id === leftWorkspaceId);
+
       queryClient.setQueryData(
         ["workspaces"],
         (old: WorkspaceWithState[] | undefined) => {
           if (!old) return [];
           const remainingWorkspaces = old.filter(
             (w) => w.id !== leftWorkspaceId,
           );
@@
           return remainingWorkspaces;
         },
       );
+
+      if (leftWorkspace?.slug) {
+        queryClient.removeQueries({
+          queryKey: ["workspace-by-slug", leftWorkspace.slug],
+          exact: true,
+        });
+      }
 
       toast.success("Left workspace successfully");
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/contexts/WorkspaceContext.tsx` around lines 223 - 247, The onSuccess
handler updates the ["workspaces"] list but doesn't evict per-workspace queries
so stale ["workspace-by-slug", ...] data can persist; inside the same onSuccess
(where queryClient.setQueryData is used) find the removed workspace from the old
array by matching leftWorkspaceId (e.g., const removed = old.find(w => w.id ===
leftWorkspaceId)), then call queryClient.removeQueries(["workspace-by-slug",
removed.slug]) and also remove any id-based key you use (e.g.,
queryClient.removeQueries(["workspace-by-id", leftWorkspaceId])) before calling
switchWorkspace/router.push and toast.success to ensure per-workspace cache is
cleared.

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<WorkspaceWithState>) => {
Expand Down Expand Up @@ -271,6 +321,7 @@ export function WorkspaceProvider({
currentSlug,
switchWorkspace,
deleteWorkspace,
leaveWorkspace,
markWorkspaceOpened,
};

Expand Down