mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: implement leave search space functionality for non-owners and update related UI components
This commit is contained in:
parent
6622a8c582
commit
d140f6393e
6 changed files with 154 additions and 49 deletions
|
|
@ -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 ============
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SearchSpace | null>(null);
|
||||
const [searchSpaceToLeave, setSearchSpaceToLeave] = useState<SearchSpace | null>(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({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Leave Search Space Dialog */}
|
||||
<Dialog open={showLeaveSearchSpaceDialog} onOpenChange={setShowLeaveSearchSpaceDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LogOut className="h-5 w-5 text-destructive" />
|
||||
<span>{t("leave_title")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("leave_confirm", { name: searchSpaceToLeave?.name || "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowLeaveSearchSpaceDialog(false)}
|
||||
disabled={isLeavingSearchSpace}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmLeaveSearchSpace}
|
||||
disabled={isLeavingSearchSpace}
|
||||
className="gap-2"
|
||||
>
|
||||
{isLeavingSearchSpace ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("leaving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{t("leave")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* All Shared Chats Sidebar */}
|
||||
<AllSharedChatsSidebar
|
||||
open={isAllSharedChatsSidebarOpen}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ export const deleteSearchSpaceResponse = z.object({
|
|||
message: z.literal("Search space deleted successfully"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Leave search space (for non-owners)
|
||||
*/
|
||||
export const leaveSearchSpaceResponse = z.object({
|
||||
message: z.literal("Successfully left the search space"),
|
||||
});
|
||||
|
||||
// Inferred types
|
||||
export type SearchSpace = z.infer<typeof searchSpace>;
|
||||
export type GetSearchSpacesRequest = z.infer<typeof getSearchSpacesRequest>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "创建于"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue