diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index c5392f284..e90970b29 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -556,6 +556,54 @@ async def update_member_role( ) from e +# NOTE: /members/me must be defined BEFORE /members/{membership_id} +# because FastAPI matches routes in order, and "me" would otherwise +# be interpreted as a membership_id (causing a 422 validation error) +@router.delete("/searchspaces/{search_space_id}/members/me") +async def leave_search_space( + search_space_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Leave a search space (remove own membership). + Owners cannot leave their search space. + """ + try: + result = await session.execute( + select(SearchSpaceMembership).filter( + SearchSpaceMembership.user_id == user.id, + SearchSpaceMembership.search_space_id == search_space_id, + ) + ) + db_membership = result.scalars().first() + + if not db_membership: + raise HTTPException( + status_code=404, + detail="You are not a member of this search space", + ) + + if db_membership.is_owner: + raise HTTPException( + status_code=400, + detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", + ) + + await session.delete(db_membership) + await session.commit() + return {"message": "Successfully left the search space"} + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Failed to leave search space: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to leave search space: {e!s}" + ) from e + + @router.delete("/searchspaces/{search_space_id}/members/{membership_id}") async def remove_member( search_space_id: int, @@ -608,51 +656,6 @@ async def remove_member( ) from e -@router.delete("/searchspaces/{search_space_id}/members/me") -async def leave_search_space( - search_space_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - """ - Leave a search space (remove own membership). - Owners cannot leave their search space. - """ - try: - result = await session.execute( - select(SearchSpaceMembership).filter( - SearchSpaceMembership.user_id == user.id, - SearchSpaceMembership.search_space_id == search_space_id, - ) - ) - db_membership = result.scalars().first() - - if not db_membership: - raise HTTPException( - status_code=404, - detail="You are not a member of this search space", - ) - - if db_membership.is_owner: - raise HTTPException( - status_code=400, - detail="Owners cannot leave their search space. Transfer ownership first or delete the search space.", - ) - - await session.delete(db_membership) - await session.commit() - return {"message": "Successfully left the search space"} - - except HTTPException: - raise - except Exception as e: - await session.rollback() - logger.error(f"Failed to leave search space: {e!s}", exc_info=True) - raise HTTPException( - status_code=500, detail=f"Failed to leave search space: {e!s}" - ) from e - - # ============ Invite Endpoints ============ diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index dbc9c5f6a..3d4e5630d 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -86,10 +86,13 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); - // Delete search space dialog state + // Delete/Leave search space dialog state const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); + const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); const [searchSpaceToDelete, setSearchSpaceToDelete] = useState(null); + const [searchSpaceToLeave, setSearchSpaceToLeave] = useState(null); const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false); + const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false); const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; @@ -181,8 +184,14 @@ export function LayoutDataProvider({ ); const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => { - setSearchSpaceToDelete(space); - setShowDeleteSearchSpaceDialog(true); + // If user is owner, show delete dialog; otherwise show leave dialog + if (space.isOwner) { + setSearchSpaceToDelete(space); + setShowDeleteSearchSpaceDialog(true); + } else { + setSearchSpaceToLeave(space); + setShowLeaveSearchSpaceDialog(true); + } }, []); const confirmDeleteSearchSpace = useCallback(async () => { @@ -215,6 +224,29 @@ export function LayoutDataProvider({ router, ]); + const confirmLeaveSearchSpace = useCallback(async () => { + if (!searchSpaceToLeave) return; + setIsLeavingSearchSpace(true); + try { + await searchSpacesApiService.leaveSearchSpace(searchSpaceToLeave.id); + refetchSearchSpaces(); + if (Number(searchSpaceId) === searchSpaceToLeave.id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== searchSpaceToLeave.id); + if (remaining.length > 0) { + router.push(`/dashboard/${remaining[0].id}/new-chat`); + } + } else if (searchSpaces.length === 1) { + router.push("/dashboard"); + } + } catch (error) { + console.error("Error leaving search space:", error); + } finally { + setIsLeavingSearchSpace(false); + setShowLeaveSearchSpaceDialog(false); + setSearchSpaceToLeave(null); + } + }, [searchSpaceToLeave, refetchSearchSpaces, searchSpaceId, searchSpaces, router]); + const handleNavItemClick = useCallback( (item: NavItem) => { router.push(item.url); @@ -422,6 +454,48 @@ export function LayoutDataProvider({ + {/* Leave Search Space Dialog */} + + + + + + {t("leave_title")} + + + {t("leave_confirm", { name: searchSpaceToLeave?.name || "" })} + + + + + + + + + {/* All Shared Chats Sidebar */} ; export type GetSearchSpacesRequest = z.infer; diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts index ff60c513b..3e2006e46 100644 --- a/surfsense_web/lib/apis/search-spaces-api.service.ts +++ b/surfsense_web/lib/apis/search-spaces-api.service.ts @@ -11,6 +11,7 @@ import { getSearchSpaceResponse, getSearchSpacesRequest, getSearchSpacesResponse, + leaveSearchSpaceResponse, type UpdateSearchSpaceRequest, updateSearchSpaceRequest, updateSearchSpaceResponse, @@ -115,6 +116,17 @@ class SearchSpacesApiService { return baseApiService.delete(`/api/v1/searchspaces/${request.id}`, deleteSearchSpaceResponse); }; + + /** + * Leave a search space (remove own membership) + * This is used by non-owners to leave a shared search space + */ + leaveSearchSpace = async (searchSpaceId: number) => { + return baseApiService.delete( + `/api/v1/searchspaces/${searchSpaceId}/members/me`, + leaveSearchSpaceResponse + ); + }; } export const searchSpacesApiService = new SearchSpacesApiService(); diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index d55f70bf7..c0c579070 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -100,6 +100,7 @@ "leave": "Leave", "leave_title": "Leave Search Space", "leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.", + "leaving": "Leaving...", "welcome_title": "Welcome to SurfSense", "welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.", "create_first_button": "Create your first search space" @@ -162,6 +163,10 @@ "go_home": "Go Home", "delete_search_space": "Delete Search Space", "delete_space_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone. All documents and chats in this search space will be permanently deleted.", + "leave": "Leave", + "leave_title": "Leave Search Space", + "leave_confirm": "Are you sure you want to leave \"{name}\"? You will lose access to all documents and chats in this search space.", + "leaving": "Leaving...", "no_spaces_found": "No search spaces found", "create_first_space": "Create your first search space to get started", "created": "Created" diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index d0e6e50d7..f01ccda4b 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -159,6 +159,10 @@ "go_home": "返回首页", "delete_search_space": "删除搜索空间", "delete_space_confirm": "您确定要删除\"{name}\"吗?此操作无法撤销。此搜索空间中的所有文档、对话和播客将被永久删除。", + "leave": "退出", + "leave_title": "退出搜索空间", + "leave_confirm": "您确定要退出\"{name}\"吗?您将无法访问此搜索空间中的所有文档和对话。", + "leaving": "退出中...", "no_spaces_found": "未找到搜索空间", "create_first_space": "创建您的第一个搜索空间以开始使用", "created": "创建于"