-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add leave workspace for shared collaborators #473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The endpoint returns a 404 with |
||
|
|
||
| return NextResponse.json({ success: true }); | ||
| } catch (error) { | ||
| console.error("Error leaving workspace:", error); | ||
| return NextResponse.json({ error: "Internal server error" }, { status: 500 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string | null>(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({ | |
|
|
||
| <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'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> | ||
|
|
@@ -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 "{workspace?.name}"? You'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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Radix UI's |
||
| </AlertDialogAction> | ||
| </AlertDialogFooter> | ||
| </AlertDialogContent> | ||
| </AlertDialog> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redirect logic is skipped when At Line 227, 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return remainingWorkspaces; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.success("Left workspace successfully"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+223
to
+246
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Evict left-workspace metadata cache after successful leave. Only the 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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>) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -271,6 +321,7 @@ export function WorkspaceProvider({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| currentSlug, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| switchWorkspace, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| deleteWorkspace, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| leaveWorkspace, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| markWorkspaceOpened, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.