From 17c7b34e44436ee5493593511a5c2aa9fca67bce Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 14:04:08 +0200 Subject: [PATCH 01/16] feat: add public_sharing permissions --- surfsense_backend/app/db.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 56e39c2e7..07927986e 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -257,6 +257,11 @@ class Permission(str, Enum): SETTINGS_UPDATE = "settings:update" SETTINGS_DELETE = "settings:delete" # Delete the entire search space + # Public Sharing + PUBLIC_SHARING_VIEW = "public_sharing:view" + PUBLIC_SHARING_CREATE = "public_sharing:create" + PUBLIC_SHARING_DELETE = "public_sharing:delete" + # Full access wildcard FULL_ACCESS = "*" @@ -299,6 +304,9 @@ DEFAULT_ROLE_PERMISSIONS = { Permission.ROLES_READ.value, # Settings (view only, no update or delete) Permission.SETTINGS_VIEW.value, + # Public Sharing (can create and view, no delete) + Permission.PUBLIC_SHARING_VIEW.value, + Permission.PUBLIC_SHARING_CREATE.value, ], "Viewer": [ # Documents (read only) @@ -322,6 +330,8 @@ DEFAULT_ROLE_PERMISSIONS = { Permission.ROLES_READ.value, # Settings (view only) Permission.SETTINGS_VIEW.value, + # Public Sharing (view only) + Permission.PUBLIC_SHARING_VIEW.value, ], } From f18ba8e045a6974a95601bf5249eeb4470b6dcd4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 14:18:17 +0200 Subject: [PATCH 02/16] feat: add permission checks for public sharing --- .../app/services/public_chat_service.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 21c87ad29..ba50c28ba 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -25,12 +25,14 @@ from app.db import ( ChatVisibility, NewChatMessage, NewChatThread, + Permission, Podcast, PodcastStatus, PublicChatSnapshot, SearchSpaceMembership, User, ) +from app.utils.rbac import check_permission UI_TOOLS = { "display_image", @@ -177,11 +179,13 @@ async def create_snapshot( if not thread: raise HTTPException(status_code=404, detail="Thread not found") - if thread.created_by_id != user.id: - raise HTTPException( - status_code=403, - detail="Only the creator of this chat can create public snapshots", - ) + await check_permission( + session, + user, + thread.search_space_id, + Permission.PUBLIC_SHARING_CREATE.value, + "You don't have permission to create public share links", + ) # Build snapshot data user_cache: dict[UUID, dict] = {} @@ -412,11 +416,13 @@ async def delete_snapshot( if not snapshot: raise HTTPException(status_code=404, detail="Snapshot not found") - if snapshot.thread.created_by_id != user.id: - raise HTTPException( - status_code=403, - detail="Only the creator can delete snapshots", - ) + await check_permission( + session, + user, + snapshot.thread.search_space_id, + Permission.PUBLIC_SHARING_DELETE.value, + "You don't have permission to delete public share links", + ) await session.delete(snapshot) await session.commit() From 148daa23e155508f3e97f9880caafc6c1dd02579 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 14:22:18 +0200 Subject: [PATCH 03/16] feat: add migration for public_sharing permissions --- ...add_public_sharing_permissions_to_roles.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 surfsense_backend/alembic/versions/86_add_public_sharing_permissions_to_roles.py diff --git a/surfsense_backend/alembic/versions/86_add_public_sharing_permissions_to_roles.py b/surfsense_backend/alembic/versions/86_add_public_sharing_permissions_to_roles.py new file mode 100644 index 000000000..ea18b6fd3 --- /dev/null +++ b/surfsense_backend/alembic/versions/86_add_public_sharing_permissions_to_roles.py @@ -0,0 +1,66 @@ +"""Add public_sharing permissions to existing roles + +Revision ID: 86 +Revises: 85 +Create Date: 2026-02-02 + +""" + +from sqlalchemy import text + +from alembic import op + +revision = "86" +down_revision = "85" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + + connection.execute( + text( + """ + UPDATE search_space_roles + SET permissions = array_append(permissions, 'public_sharing:view') + WHERE name IN ('Editor', 'Viewer') + AND NOT ('public_sharing:view' = ANY(permissions)) + """ + ) + ) + + connection.execute( + text( + """ + UPDATE search_space_roles + SET permissions = array_append(permissions, 'public_sharing:create') + WHERE name = 'Editor' + AND NOT ('public_sharing:create' = ANY(permissions)) + """ + ) + ) + + +def downgrade(): + connection = op.get_bind() + + connection.execute( + text( + """ + UPDATE search_space_roles + SET permissions = array_remove(permissions, 'public_sharing:view') + WHERE name IN ('Editor', 'Viewer') + """ + ) + ) + + connection.execute( + text( + """ + UPDATE search_space_roles + SET permissions = array_remove(permissions, 'public_sharing:create') + WHERE name = 'Editor' + """ + ) + ) From a80dd25ee44b891a5bbed034e9f296ef4565f771 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 14:24:59 +0200 Subject: [PATCH 04/16] feat: hide public link option based on permission --- .../components/new-chat/chat-share-button.tsx | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index f523752f1..85e76966e 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -3,10 +3,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { Globe, User, Users } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; +import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -56,6 +57,14 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS createSnapshotMutationAtom ); + // Permission check for public sharing + const { data: access } = useAtomValue(myAccessAtom); + const canCreatePublicLink = useMemo(() => { + if (!access) return false; + if (access.is_owner) return true; + return access.permissions?.includes("public_sharing:create") ?? false; + }, [access]); + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; @@ -183,35 +192,39 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS ); })} - {/* Divider */} -
+ {canCreatePublicLink && ( + <> + {/* Divider */} +
- {/* Public Link Option */} - + {/* Public Link Option */} + + + )}
From 0bcd7505fb3d54995ba692730c1ea5fdf9e90728 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 14:40:44 +0200 Subject: [PATCH 05/16] feat: add Public Chat Sharing permission category --- .../app/dashboard/[search_space_id]/team/page.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 87e4281ae..2eff4ade4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -11,6 +11,7 @@ import { Crown, Edit2, FileText, + Globe, Hash, Link2, LinkIcon, @@ -827,6 +828,12 @@ const CATEGORY_CONFIG: Record< description: "Manage search space settings", order: 10, }, + public_sharing: { + label: "Public Chat Sharing", + icon: Globe, + description: "Share chats publicly via links", + order: 11, + }, }; const ACTION_LABELS: Record = { From 38216304045c6b1d2d7c9a6d1fcbc29241514435 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 15:36:59 +0200 Subject: [PATCH 06/16] feat: use frontend URL for public share links --- .../app/routes/new_chat_routes.py | 7 -- surfsense_backend/app/schemas/new_chat.py | 18 +++++ .../app/services/public_chat_service.py | 65 +++++++++++++++++-- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index e419be7f7..14a649923 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -739,7 +739,6 @@ async def update_thread_visibility( @router.post("/threads/{thread_id}/snapshots", response_model=SnapshotCreateResponse) async def create_thread_snapshot( thread_id: int, - request: Request, session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): @@ -747,23 +746,19 @@ async def create_thread_snapshot( Create a public snapshot of the thread. Returns existing snapshot URL if content unchanged (deduplication). - Only the thread owner can create snapshots. """ from app.services.public_chat_service import create_snapshot - base_url = str(request.base_url).rstrip("/") return await create_snapshot( session=session, thread_id=thread_id, user=user, - base_url=base_url, ) @router.get("/threads/{thread_id}/snapshots", response_model=SnapshotListResponse) async def list_thread_snapshots( thread_id: int, - request: Request, session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): @@ -774,13 +769,11 @@ async def list_thread_snapshots( """ from app.services.public_chat_service import list_snapshots_for_thread - base_url = str(request.base_url).rstrip("/") return SnapshotListResponse( snapshots=await list_snapshots_for_thread( session=session, thread_id=thread_id, user=user, - base_url=base_url, ) ) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 1c15c5f4d..51bb584f6 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -236,6 +236,24 @@ class SnapshotListResponse(BaseModel): snapshots: list[SnapshotInfo] +class SearchSpaceSnapshotInfo(BaseModel): + """Snapshot info with thread context for search space listing.""" + + id: int + share_token: str + public_url: str + created_at: datetime + message_count: int + thread_id: int + thread_title: str + + +class SearchSpaceSnapshotListResponse(BaseModel): + """List of all snapshots in a search space.""" + + snapshots: list[SearchSpaceSnapshotInfo] + + # ============================================================================= # Public Chat View Schemas (for unauthenticated access) # ============================================================================= diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index ba50c28ba..2125dd8ce 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -161,7 +161,6 @@ async def create_snapshot( session: AsyncSession, thread_id: int, user: User, - base_url: str, ) -> dict: """ Create a public snapshot of a chat thread. @@ -169,6 +168,9 @@ async def create_snapshot( Returns existing snapshot if content unchanged (same hash). Returns new snapshot with unique URL if content changed. """ + from app.config import config + + frontend_url = (config.NEXT_FRONTEND_URL or "").rstrip("/") result = await session.execute( select(NewChatThread) .options(selectinload(NewChatThread.messages)) @@ -250,7 +252,7 @@ async def create_snapshot( return { "snapshot_id": existing.id, "share_token": existing.share_token, - "public_url": f"{base_url}/public/{existing.share_token}", + "public_url": f"{frontend_url}/public/{existing.share_token}", "is_new": False, } @@ -283,7 +285,7 @@ async def create_snapshot( return { "snapshot_id": snapshot.id, "share_token": snapshot.share_token, - "public_url": f"{base_url}/public/{snapshot.share_token}", + "public_url": f"{frontend_url}/public/{snapshot.share_token}", "is_new": True, } @@ -352,10 +354,10 @@ async def list_snapshots_for_thread( session: AsyncSession, thread_id: int, user: User, - base_url: str, ) -> list[dict]: """List all public snapshots for a thread.""" - # Verify ownership + from app.config import config + result = await session.execute( select(NewChatThread).filter(NewChatThread.id == thread_id) ) @@ -370,7 +372,6 @@ async def list_snapshots_for_thread( detail="Only the creator can view snapshots", ) - # Get snapshots result = await session.execute( select(PublicChatSnapshot) .filter(PublicChatSnapshot.thread_id == thread_id) @@ -378,11 +379,13 @@ async def list_snapshots_for_thread( ) snapshots = result.scalars().all() + frontend_url = (config.NEXT_FRONTEND_URL or "").rstrip("/") + return [ { "id": s.id, "share_token": s.share_token, - "public_url": f"{base_url}/public/{s.share_token}", + "public_url": f"{frontend_url}/public/{s.share_token}", "created_at": s.created_at.isoformat() if s.created_at else None, "message_count": len(s.message_ids) if s.message_ids else 0, } @@ -390,6 +393,54 @@ async def list_snapshots_for_thread( ] +async def list_snapshots_for_search_space( + session: AsyncSession, + search_space_id: int, + user: User, +) -> list[dict]: + """List all public snapshots for a search space.""" + from app.config import config + + await check_permission( + session, + user, + search_space_id, + Permission.PUBLIC_SHARING_VIEW.value, + "You don't have permission to view public share links", + ) + + result = await session.execute( + select(PublicChatSnapshot) + .join(NewChatThread, PublicChatSnapshot.thread_id == NewChatThread.id) + .filter(NewChatThread.search_space_id == search_space_id) + .order_by(PublicChatSnapshot.created_at.desc()) + ) + snapshots = result.scalars().all() + + snapshot_thread_ids = [s.thread_id for s in snapshots] + thread_result = await session.execute( + select(NewChatThread.id, NewChatThread.title).filter( + NewChatThread.id.in_(snapshot_thread_ids) + ) + ) + thread_titles = {row[0]: row[1] for row in thread_result.fetchall()} + + frontend_url = (config.NEXT_FRONTEND_URL or "").rstrip("/") + + return [ + { + "id": s.id, + "share_token": s.share_token, + "public_url": f"{frontend_url}/public/{s.share_token}", + "created_at": s.created_at.isoformat() if s.created_at else None, + "message_count": len(s.message_ids) if s.message_ids else 0, + "thread_id": s.thread_id, + "thread_title": thread_titles.get(s.thread_id, "Untitled"), + } + for s in snapshots + ] + + # ============================================================================= # Snapshot Deletion # ============================================================================= From ab343b544ae357c24d8e1f41f9bd8283aae05598 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 15:38:35 +0200 Subject: [PATCH 07/16] feat: add search space snapshots list endpoint --- .../app/routes/search_spaces_routes.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index 297f9be5f..2730250ab 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -501,3 +501,25 @@ async def update_llm_preferences( raise HTTPException( status_code=500, detail=f"Failed to update LLM preferences: {e!s}" ) from e + + +@router.get("/searchspaces/{search_space_id}/snapshots") +async def list_search_space_snapshots( + search_space_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + List all public chat snapshots for a search space. + + Requires PUBLIC_SHARING_VIEW permission. + """ + from app.schemas.new_chat import SearchSpaceSnapshotListResponse + from app.services.public_chat_service import list_snapshots_for_search_space + + snapshots = await list_snapshots_for_search_space( + session=session, + search_space_id=search_space_id, + user=user, + ) + return SearchSpaceSnapshotListResponse(snapshots=snapshots) From 47b7befc55b4c46e9d7af8ed5633659b6496995d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 15:43:07 +0200 Subject: [PATCH 08/16] feat: add search space snapshots frontend API --- .../contracts/types/chat-threads.types.ts | 27 +++++++++++++++++++ .../lib/apis/chat-threads-api.service.ts | 23 ++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/surfsense_web/contracts/types/chat-threads.types.ts b/surfsense_web/contracts/types/chat-threads.types.ts index d360c3732..683a72e21 100644 --- a/surfsense_web/contracts/types/chat-threads.types.ts +++ b/surfsense_web/contracts/types/chat-threads.types.ts @@ -44,6 +44,30 @@ export const deleteSnapshotRequest = z.object({ snapshot_id: z.number(), }); +/** + * Search space snapshot info (includes thread context) + */ +export const searchSpaceSnapshotInfo = z.object({ + id: z.number(), + share_token: z.string(), + public_url: z.string(), + created_at: z.string(), + message_count: z.number(), + thread_id: z.number(), + thread_title: z.string(), +}); + +/** + * List snapshots for search space + */ +export const listSearchSpaceSnapshotsRequest = z.object({ + search_space_id: z.number(), +}); + +export const listSearchSpaceSnapshotsResponse = z.object({ + snapshots: z.array(searchSpaceSnapshotInfo), +}); + // Type exports export type SnapshotInfo = z.infer; export type CreateSnapshotRequest = z.infer; @@ -51,3 +75,6 @@ export type CreateSnapshotResponse = z.infer; export type ListSnapshotsRequest = z.infer; export type ListSnapshotsResponse = z.infer; export type DeleteSnapshotRequest = z.infer; +export type SearchSpaceSnapshotInfo = z.infer; +export type ListSearchSpaceSnapshotsRequest = z.infer; +export type ListSearchSpaceSnapshotsResponse = z.infer; diff --git a/surfsense_web/lib/apis/chat-threads-api.service.ts b/surfsense_web/lib/apis/chat-threads-api.service.ts index 144defcb2..985eebc76 100644 --- a/surfsense_web/lib/apis/chat-threads-api.service.ts +++ b/surfsense_web/lib/apis/chat-threads-api.service.ts @@ -5,8 +5,12 @@ import { createSnapshotResponse, type DeleteSnapshotRequest, deleteSnapshotRequest, + type ListSearchSpaceSnapshotsRequest, + type ListSearchSpaceSnapshotsResponse, type ListSnapshotsRequest, type ListSnapshotsResponse, + listSearchSpaceSnapshotsRequest, + listSearchSpaceSnapshotsResponse, listSnapshotsRequest, listSnapshotsResponse, } from "@/contracts/types/chat-threads.types"; @@ -63,6 +67,25 @@ class ChatThreadsApiService { `/api/v1/threads/${parsed.data.thread_id}/snapshots/${parsed.data.snapshot_id}` ); }; + + /** + * List all snapshots for a search space. + */ + listSearchSpaceSnapshots = async ( + request: ListSearchSpaceSnapshotsRequest + ): Promise => { + const parsed = listSearchSpaceSnapshotsRequest.safeParse(request); + + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/searchspaces/${parsed.data.search_space_id}/snapshots`, + listSearchSpaceSnapshotsResponse + ); + }; } export const chatThreadsApiService = new ChatThreadsApiService(); From e62e4faaa598fdf0dd657a05ddf3c15e20bc37d5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 15:47:24 +0200 Subject: [PATCH 09/16] feat: add snapshots cache key and query atom --- .../atoms/snapshots/snapshots-query.atoms.ts | 22 +++++++++++++++++++ surfsense_web/lib/query-client/cache-keys.ts | 3 +++ 2 files changed, 25 insertions(+) create mode 100644 surfsense_web/atoms/snapshots/snapshots-query.atoms.ts diff --git a/surfsense_web/atoms/snapshots/snapshots-query.atoms.ts b/surfsense_web/atoms/snapshots/snapshots-query.atoms.ts new file mode 100644 index 000000000..6c29dccc0 --- /dev/null +++ b/surfsense_web/atoms/snapshots/snapshots-query.atoms.ts @@ -0,0 +1,22 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const searchSpaceSnapshotsAtom = atomWithQuery((get) => { + const searchSpaceId = get(activeSearchSpaceIdAtom); + + return { + queryKey: cacheKeys.snapshots.bySearchSpace(Number(searchSpaceId) || 0), + enabled: !!searchSpaceId, + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!searchSpaceId) { + return { snapshots: [] }; + } + return chatThreadsApiService.listSearchSpaceSnapshots({ + search_space_id: Number(searchSpaceId), + }); + }, + }; +}); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index e6cf5610b..dcb5288f5 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -82,4 +82,7 @@ export const cacheKeys = { publicChat: { byToken: (shareToken: string) => ["public-chat", shareToken] as const, }, + snapshots: { + bySearchSpace: (searchSpaceId: number) => ["snapshots", "search-space", searchSpaceId] as const, + }, }; From d890c562d4db83775ab77411a1b84acecccd87ab Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 16:05:23 +0200 Subject: [PATCH 10/16] refactor: rename snapshot types to PublicChatSnapshot prefix --- .../app/routes/new_chat_routes.py | 10 +-- .../app/routes/search_spaces_routes.py | 4 +- surfsense_backend/app/schemas/new_chat.py | 26 ++++---- .../atoms/chat/chat-thread-mutation.atoms.ts | 13 ++-- .../public-chat-snapshots-query.atoms.ts} | 6 +- .../components/new-chat/chat-share-button.tsx | 4 +- .../contracts/types/chat-threads.types.ts | 52 +++++++-------- .../lib/apis/chat-threads-api.service.ts | 66 ++++++++++--------- surfsense_web/lib/query-client/cache-keys.ts | 5 +- 9 files changed, 95 insertions(+), 91 deletions(-) rename surfsense_web/atoms/{snapshots/snapshots-query.atoms.ts => public-chat-snapshots/public-chat-snapshots-query.atoms.ts} (71%) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 14a649923..e6a8db689 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -45,9 +45,9 @@ from app.schemas.new_chat import ( NewChatThreadUpdate, NewChatThreadVisibilityUpdate, NewChatThreadWithMessages, + PublicChatSnapshotCreateResponse, + PublicChatSnapshotListResponse, RegenerateRequest, - SnapshotCreateResponse, - SnapshotListResponse, ThreadHistoryLoadResponse, ThreadListItem, ThreadListResponse, @@ -736,7 +736,7 @@ async def update_thread_visibility( # ============================================================================= -@router.post("/threads/{thread_id}/snapshots", response_model=SnapshotCreateResponse) +@router.post("/threads/{thread_id}/snapshots", response_model=PublicChatSnapshotCreateResponse) async def create_thread_snapshot( thread_id: int, session: AsyncSession = Depends(get_async_session), @@ -756,7 +756,7 @@ async def create_thread_snapshot( ) -@router.get("/threads/{thread_id}/snapshots", response_model=SnapshotListResponse) +@router.get("/threads/{thread_id}/snapshots", response_model=PublicChatSnapshotListResponse) async def list_thread_snapshots( thread_id: int, session: AsyncSession = Depends(get_async_session), @@ -769,7 +769,7 @@ async def list_thread_snapshots( """ from app.services.public_chat_service import list_snapshots_for_thread - return SnapshotListResponse( + return PublicChatSnapshotListResponse( snapshots=await list_snapshots_for_thread( session=session, thread_id=thread_id, diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index 2730250ab..a8916f2ea 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -514,7 +514,7 @@ async def list_search_space_snapshots( Requires PUBLIC_SHARING_VIEW permission. """ - from app.schemas.new_chat import SearchSpaceSnapshotListResponse + from app.schemas.new_chat import PublicChatSnapshotsBySpaceResponse from app.services.public_chat_service import list_snapshots_for_search_space snapshots = await list_snapshots_for_search_space( @@ -522,4 +522,4 @@ async def list_search_space_snapshots( search_space_id=search_space_id, user=user, ) - return SearchSpaceSnapshotListResponse(snapshots=snapshots) + return PublicChatSnapshotsBySpaceResponse(snapshots=snapshots) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index 51bb584f6..61af0d92c 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -211,17 +211,17 @@ class RegenerateRequest(BaseModel): # ============================================================================= -class SnapshotCreateResponse(BaseModel): - """Response after creating a public snapshot.""" +class PublicChatSnapshotCreateResponse(BaseModel): + """Response after creating a public chat snapshot.""" snapshot_id: int share_token: str public_url: str - is_new: bool # False if existing snapshot returned (same content) + is_new: bool -class SnapshotInfo(BaseModel): - """Info about a single snapshot.""" +class PublicChatSnapshotInfo(BaseModel): + """Info about a single public chat snapshot.""" id: int share_token: str @@ -230,14 +230,14 @@ class SnapshotInfo(BaseModel): message_count: int -class SnapshotListResponse(BaseModel): - """List of snapshots for a thread.""" +class PublicChatSnapshotListResponse(BaseModel): + """List of public chat snapshots for a thread.""" - snapshots: list[SnapshotInfo] + snapshots: list[PublicChatSnapshotInfo] -class SearchSpaceSnapshotInfo(BaseModel): - """Snapshot info with thread context for search space listing.""" +class PublicChatSnapshotDetail(BaseModel): + """Public chat snapshot with thread context.""" id: int share_token: str @@ -248,10 +248,10 @@ class SearchSpaceSnapshotInfo(BaseModel): thread_title: str -class SearchSpaceSnapshotListResponse(BaseModel): - """List of all snapshots in a search space.""" +class PublicChatSnapshotsBySpaceResponse(BaseModel): + """List of public chat snapshots for a search space.""" - snapshots: list[SearchSpaceSnapshotInfo] + snapshots: list[PublicChatSnapshotDetail] # ============================================================================= diff --git a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts index d8c158fd1..35858171e 100644 --- a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts +++ b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts @@ -1,17 +1,16 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; import type { - CreateSnapshotRequest, - CreateSnapshotResponse, + PublicChatSnapshotCreateRequest, + PublicChatSnapshotCreateResponse, } from "@/contracts/types/chat-threads.types"; import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; -export const createSnapshotMutationAtom = atomWithMutation(() => ({ - mutationFn: async (request: CreateSnapshotRequest) => { - return chatThreadsApiService.createSnapshot(request); +export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: PublicChatSnapshotCreateRequest) => { + return chatThreadsApiService.createPublicChatSnapshot(request); }, - onSuccess: (response: CreateSnapshotResponse) => { - // Construct URL using frontend origin (backend returns its own URL which differs) + onSuccess: (response: PublicChatSnapshotCreateResponse) => { const publicUrl = `${window.location.origin}/public/${response.share_token}`; navigator.clipboard.writeText(publicUrl); if (response.is_new) { diff --git a/surfsense_web/atoms/snapshots/snapshots-query.atoms.ts b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms.ts similarity index 71% rename from surfsense_web/atoms/snapshots/snapshots-query.atoms.ts rename to surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms.ts index 6c29dccc0..9c9eafab4 100644 --- a/surfsense_web/atoms/snapshots/snapshots-query.atoms.ts +++ b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms.ts @@ -3,18 +3,18 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; -export const searchSpaceSnapshotsAtom = atomWithQuery((get) => { +export const publicChatSnapshotsAtom = atomWithQuery((get) => { const searchSpaceId = get(activeSearchSpaceIdAtom); return { - queryKey: cacheKeys.snapshots.bySearchSpace(Number(searchSpaceId) || 0), + queryKey: cacheKeys.publicChatSnapshots.bySearchSpace(Number(searchSpaceId) || 0), enabled: !!searchSpaceId, staleTime: 5 * 60 * 1000, queryFn: async () => { if (!searchSpaceId) { return { snapshots: [] }; } - return chatThreadsApiService.listSearchSpaceSnapshots({ + return chatThreadsApiService.listPublicChatSnapshotsForSearchSpace({ search_space_id: Number(searchSpaceId), }); }, diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 85e76966e..23af0995e 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -5,7 +5,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { Globe, User, Users } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; +import { createPublicChatSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { Button } from "@/components/ui/button"; @@ -54,7 +54,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS // Snapshot creation mutation const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue( - createSnapshotMutationAtom + createPublicChatSnapshotMutationAtom ); // Permission check for public sharing diff --git a/surfsense_web/contracts/types/chat-threads.types.ts b/surfsense_web/contracts/types/chat-threads.types.ts index 683a72e21..df561092e 100644 --- a/surfsense_web/contracts/types/chat-threads.types.ts +++ b/surfsense_web/contracts/types/chat-threads.types.ts @@ -1,9 +1,9 @@ import { z } from "zod"; /** - * Snapshot info + * Public chat snapshot info */ -export const snapshotInfo = z.object({ +export const publicChatSnapshotInfo = z.object({ id: z.number(), share_token: z.string(), public_url: z.string(), @@ -12,13 +12,13 @@ export const snapshotInfo = z.object({ }); /** - * Create snapshot + * Create public chat snapshot */ -export const createSnapshotRequest = z.object({ +export const publicChatSnapshotCreateRequest = z.object({ thread_id: z.number(), }); -export const createSnapshotResponse = z.object({ +export const publicChatSnapshotCreateResponse = z.object({ snapshot_id: z.number(), share_token: z.string(), public_url: z.string(), @@ -26,28 +26,28 @@ export const createSnapshotResponse = z.object({ }); /** - * List snapshots + * List public chat snapshots for thread */ -export const listSnapshotsRequest = z.object({ +export const publicChatSnapshotListRequest = z.object({ thread_id: z.number(), }); -export const listSnapshotsResponse = z.object({ - snapshots: z.array(snapshotInfo), +export const publicChatSnapshotListResponse = z.object({ + snapshots: z.array(publicChatSnapshotInfo), }); /** - * Delete snapshot + * Delete public chat snapshot */ -export const deleteSnapshotRequest = z.object({ +export const publicChatSnapshotDeleteRequest = z.object({ thread_id: z.number(), snapshot_id: z.number(), }); /** - * Search space snapshot info (includes thread context) + * Public chat snapshot with thread context */ -export const searchSpaceSnapshotInfo = z.object({ +export const publicChatSnapshotDetail = z.object({ id: z.number(), share_token: z.string(), public_url: z.string(), @@ -58,23 +58,23 @@ export const searchSpaceSnapshotInfo = z.object({ }); /** - * List snapshots for search space + * List public chat snapshots for search space */ -export const listSearchSpaceSnapshotsRequest = z.object({ +export const publicChatSnapshotsBySpaceRequest = z.object({ search_space_id: z.number(), }); -export const listSearchSpaceSnapshotsResponse = z.object({ - snapshots: z.array(searchSpaceSnapshotInfo), +export const publicChatSnapshotsBySpaceResponse = z.object({ + snapshots: z.array(publicChatSnapshotDetail), }); // Type exports -export type SnapshotInfo = z.infer; -export type CreateSnapshotRequest = z.infer; -export type CreateSnapshotResponse = z.infer; -export type ListSnapshotsRequest = z.infer; -export type ListSnapshotsResponse = z.infer; -export type DeleteSnapshotRequest = z.infer; -export type SearchSpaceSnapshotInfo = z.infer; -export type ListSearchSpaceSnapshotsRequest = z.infer; -export type ListSearchSpaceSnapshotsResponse = z.infer; +export type PublicChatSnapshotInfo = z.infer; +export type PublicChatSnapshotCreateRequest = z.infer; +export type PublicChatSnapshotCreateResponse = z.infer; +export type PublicChatSnapshotListRequest = z.infer; +export type PublicChatSnapshotListResponse = z.infer; +export type PublicChatSnapshotDeleteRequest = z.infer; +export type PublicChatSnapshotDetail = z.infer; +export type PublicChatSnapshotsBySpaceRequest = z.infer; +export type PublicChatSnapshotsBySpaceResponse = z.infer; diff --git a/surfsense_web/lib/apis/chat-threads-api.service.ts b/surfsense_web/lib/apis/chat-threads-api.service.ts index 985eebc76..9dcd85761 100644 --- a/surfsense_web/lib/apis/chat-threads-api.service.ts +++ b/surfsense_web/lib/apis/chat-threads-api.service.ts @@ -1,28 +1,30 @@ import { - type CreateSnapshotRequest, - type CreateSnapshotResponse, - createSnapshotRequest, - createSnapshotResponse, - type DeleteSnapshotRequest, - deleteSnapshotRequest, - type ListSearchSpaceSnapshotsRequest, - type ListSearchSpaceSnapshotsResponse, - type ListSnapshotsRequest, - type ListSnapshotsResponse, - listSearchSpaceSnapshotsRequest, - listSearchSpaceSnapshotsResponse, - listSnapshotsRequest, - listSnapshotsResponse, + type PublicChatSnapshotCreateRequest, + type PublicChatSnapshotCreateResponse, + type PublicChatSnapshotDeleteRequest, + type PublicChatSnapshotListRequest, + type PublicChatSnapshotListResponse, + type PublicChatSnapshotsBySpaceRequest, + type PublicChatSnapshotsBySpaceResponse, + publicChatSnapshotCreateRequest, + publicChatSnapshotCreateResponse, + publicChatSnapshotDeleteRequest, + publicChatSnapshotListRequest, + publicChatSnapshotListResponse, + publicChatSnapshotsBySpaceRequest, + publicChatSnapshotsBySpaceResponse, } from "@/contracts/types/chat-threads.types"; import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; class ChatThreadsApiService { /** - * Create a public snapshot for a thread. + * Create a public chat snapshot for a thread. */ - createSnapshot = async (request: CreateSnapshotRequest): Promise => { - const parsed = createSnapshotRequest.safeParse(request); + createPublicChatSnapshot = async ( + request: PublicChatSnapshotCreateRequest + ): Promise => { + const parsed = publicChatSnapshotCreateRequest.safeParse(request); if (!parsed.success) { const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); @@ -31,15 +33,17 @@ class ChatThreadsApiService { return baseApiService.post( `/api/v1/threads/${parsed.data.thread_id}/snapshots`, - createSnapshotResponse + publicChatSnapshotCreateResponse ); }; /** - * List all snapshots for a thread. + * List all public chat snapshots for a thread. */ - listSnapshots = async (request: ListSnapshotsRequest): Promise => { - const parsed = listSnapshotsRequest.safeParse(request); + listPublicChatSnapshots = async ( + request: PublicChatSnapshotListRequest + ): Promise => { + const parsed = publicChatSnapshotListRequest.safeParse(request); if (!parsed.success) { const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); @@ -48,15 +52,15 @@ class ChatThreadsApiService { return baseApiService.get( `/api/v1/threads/${parsed.data.thread_id}/snapshots`, - listSnapshotsResponse + publicChatSnapshotListResponse ); }; /** - * Delete a specific snapshot. + * Delete a public chat snapshot. */ - deleteSnapshot = async (request: DeleteSnapshotRequest): Promise => { - const parsed = deleteSnapshotRequest.safeParse(request); + deletePublicChatSnapshot = async (request: PublicChatSnapshotDeleteRequest): Promise => { + const parsed = publicChatSnapshotDeleteRequest.safeParse(request); if (!parsed.success) { const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); @@ -69,12 +73,12 @@ class ChatThreadsApiService { }; /** - * List all snapshots for a search space. + * List all public chat snapshots for a search space. */ - listSearchSpaceSnapshots = async ( - request: ListSearchSpaceSnapshotsRequest - ): Promise => { - const parsed = listSearchSpaceSnapshotsRequest.safeParse(request); + listPublicChatSnapshotsForSearchSpace = async ( + request: PublicChatSnapshotsBySpaceRequest + ): Promise => { + const parsed = publicChatSnapshotsBySpaceRequest.safeParse(request); if (!parsed.success) { const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); @@ -83,7 +87,7 @@ class ChatThreadsApiService { return baseApiService.get( `/api/v1/searchspaces/${parsed.data.search_space_id}/snapshots`, - listSearchSpaceSnapshotsResponse + publicChatSnapshotsBySpaceResponse ); }; } diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index dcb5288f5..df456eba3 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -82,7 +82,8 @@ export const cacheKeys = { publicChat: { byToken: (shareToken: string) => ["public-chat", shareToken] as const, }, - snapshots: { - bySearchSpace: (searchSpaceId: number) => ["snapshots", "search-space", searchSpaceId] as const, + publicChatSnapshots: { + bySearchSpace: (searchSpaceId: number) => + ["public-chat-snapshots", "search-space", searchSpaceId] as const, }, }; From ea2dd20a780e5aa4454aa39e5263e0999c03bb58 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 16:24:13 +0200 Subject: [PATCH 11/16] feat: add public chat snapshots components --- .../public-chat-snapshots-mutation.atoms.ts | 17 +++ .../public-chat-snapshot-row.tsx | 67 +++++++++ .../public-chat-snapshots-empty-state.tsx | 23 +++ .../public-chat-snapshots-list.tsx | 40 ++++++ .../public-chat-snapshots-manager.tsx | 133 ++++++++++++++++++ 5 files changed, 280 insertions(+) create mode 100644 surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts create mode 100644 surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx create mode 100644 surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx create mode 100644 surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx create mode 100644 surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx diff --git a/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts new file mode 100644 index 000000000..a0343fb9d --- /dev/null +++ b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts @@ -0,0 +1,17 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { PublicChatSnapshotDeleteRequest } from "@/contracts/types/chat-threads.types"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; + +export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: PublicChatSnapshotDeleteRequest) => { + return chatThreadsApiService.deletePublicChatSnapshot(request); + }, + onSuccess: () => { + toast.success("Public link deleted"); + }, + onError: (error: Error) => { + console.error("Failed to delete public chat link:", error); + toast.error("Failed to delete public link"); + }, +})); diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx new file mode 100644 index 000000000..696d32466 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Copy, MessageSquare, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types"; + +interface PublicChatSnapshotRowProps { + snapshot: PublicChatSnapshotDetail; + canDelete: boolean; + onCopy: (snapshot: PublicChatSnapshotDetail) => void; + onDelete: (snapshot: PublicChatSnapshotDetail) => void; + isDeleting?: boolean; +} + +export function PublicChatSnapshotRow({ + snapshot, + canDelete, + onCopy, + onDelete, + isDeleting = false, +}: PublicChatSnapshotRowProps) { + const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + + return ( +
+
+

+ {snapshot.thread_title} +

+
+ {formattedDate} + + + {snapshot.message_count} + +
+
+
+ + {canDelete && ( + + )} +
+
+ ); +} diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx new file mode 100644 index 000000000..4bb295217 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Link2Off } from "lucide-react"; + +interface PublicChatSnapshotsEmptyStateProps { + title?: string; + description?: string; +} + +export function PublicChatSnapshotsEmptyState({ + title = "No public chat links", + description = "When you create public links to share chats, they will appear here.", +}: PublicChatSnapshotsEmptyStateProps) { + return ( +
+
+ +
+

{title}

+

{description}

+
+ ); +} diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx new file mode 100644 index 000000000..38c435059 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types"; +import { PublicChatSnapshotRow } from "./public-chat-snapshot-row"; +import { PublicChatSnapshotsEmptyState } from "./public-chat-snapshots-empty-state"; + +interface PublicChatSnapshotsListProps { + snapshots: PublicChatSnapshotDetail[]; + canDelete: boolean; + onCopy: (snapshot: PublicChatSnapshotDetail) => void; + onDelete: (snapshot: PublicChatSnapshotDetail) => void; + deletingId?: number; +} + +export function PublicChatSnapshotsList({ + snapshots, + canDelete, + onCopy, + onDelete, + deletingId, +}: PublicChatSnapshotsListProps) { + if (snapshots.length === 0) { + return ; + } + + return ( +
+ {snapshots.map((snapshot) => ( + + ))} +
+ ); +} diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx new file mode 100644 index 000000000..a24efcd77 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { Globe, Info } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms"; +import { publicChatSnapshotsAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms"; +import { myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types"; +import { PublicChatSnapshotsList } from "./public-chat-snapshots-list"; + +interface PublicChatSnapshotsManagerProps { + searchSpaceId: number; +} + +export function PublicChatSnapshotsManager({ + searchSpaceId: _searchSpaceId, +}: PublicChatSnapshotsManagerProps) { + const [deletingId, setDeletingId] = useState(); + + // Data fetching + const { data: snapshotsData, isLoading, refetch } = useAtomValue(publicChatSnapshotsAtom); + + // Permissions + const { data: access } = useAtomValue(myAccessAtom); + const canView = useMemo(() => { + if (!access) return false; + if (access.is_owner) return true; + return access.permissions?.includes("public_sharing:view") ?? false; + }, [access]); + + const canDelete = useMemo(() => { + if (!access) return false; + if (access.is_owner) return true; + return access.permissions?.includes("public_sharing:delete") ?? false; + }, [access]); + + // Mutations + const { mutateAsync: deleteSnapshot } = useAtomValue(deletePublicChatSnapshotMutationAtom); + + // Handlers + const handleCopy = useCallback((snapshot: PublicChatSnapshotDetail) => { + const publicUrl = `${window.location.origin}/public/${snapshot.share_token}`; + navigator.clipboard.writeText(publicUrl); + toast.success("Link copied to clipboard"); + }, []); + + const handleDelete = useCallback( + async (snapshot: PublicChatSnapshotDetail) => { + try { + setDeletingId(snapshot.id); + await deleteSnapshot({ + thread_id: snapshot.thread_id, + snapshot_id: snapshot.id, + }); + await refetch(); + } catch (error) { + console.error("Failed to delete snapshot:", error); + } finally { + setDeletingId(undefined); + } + }, + [deleteSnapshot, refetch] + ); + + // Loading state + if (isLoading) { + return ( +
+ + + + + + + + + +
+ ); + } + + // Permission denied + if (!canView) { + return ( + + + + You don't have permission to view public chat links in this search space. + + + ); + } + + const snapshots = snapshotsData?.snapshots ?? []; + + return ( +
+ + + + Public chat links allow anyone with the URL to view a snapshot of a chat. These links do + not update when the original chat changes. + + + + + + + + Public Chat Links + + + Manage public links to chats in this search space. + + + + + + +
+ ); +} From 67f797232e2590d0444ec0df67f9db090aecf1a1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 16:40:11 +0200 Subject: [PATCH 12/16] feat: add public chat links settings page --- .../[search_space_id]/settings/page.tsx | 11 +++++++++++ .../public-chat-snapshots-manager.tsx | 16 ++++++++++++++-- surfsense_web/messages/en.json | 2 ++ surfsense_web/messages/zh.json | 2 ++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx index 8c8bdb2e9..1a727f1b6 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/settings/page.tsx @@ -6,6 +6,7 @@ import { Brain, ChevronRight, FileText, + Globe, type LucideIcon, Menu, MessageSquare, @@ -16,6 +17,7 @@ import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; +import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager"; import { GeneralSettingsManager } from "@/components/settings/general-settings-manager"; import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager"; @@ -56,6 +58,12 @@ const settingsNavItems: SettingsNavItem[] = [ descriptionKey: "nav_system_instructions_desc", icon: MessageSquare, }, + { + id: "public-links", + labelKey: "nav_public_links", + descriptionKey: "nav_public_links_desc", + icon: Globe, + }, ]; function SettingsSidebar({ @@ -276,6 +284,9 @@ function SettingsContent({ {activeSection === "models" && } {activeSection === "roles" && } {activeSection === "prompts" && } + {activeSection === "public-links" && ( + + )}
diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx index a24efcd77..1c5c1fe42 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue } from "jotai"; -import { Globe, Info } from "lucide-react"; +import { AlertCircle, Globe, Info } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms"; @@ -23,7 +23,7 @@ export function PublicChatSnapshotsManager({ const [deletingId, setDeletingId] = useState(); // Data fetching - const { data: snapshotsData, isLoading, refetch } = useAtomValue(publicChatSnapshotsAtom); + const { data: snapshotsData, isLoading, isError, refetch } = useAtomValue(publicChatSnapshotsAtom); // Permissions const { data: access } = useAtomValue(myAccessAtom); @@ -84,6 +84,18 @@ export function PublicChatSnapshotsManager({ ); } + // Error state + if (isError) { + return ( + + + + Failed to load public chat links. Please try again later. + + + ); + } + // Permission denied if (!canView) { return ( diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index a9a75d8dc..409a6fb6f 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -731,6 +731,8 @@ "nav_role_assignments_desc": "Assign configs to agent roles", "nav_system_instructions": "System Instructions", "nav_system_instructions_desc": "SearchSpace-wide AI instructions", + "nav_public_links": "Public Chat Links", + "nav_public_links_desc": "Manage publicly shared chat links", "general_name_label": "Name", "general_name_placeholder": "Enter search space name", "general_name_description": "A unique name for your search space.", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 7c0fd8400..750902915 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -716,6 +716,8 @@ "nav_role_assignments_desc": "为代理角色分配配置", "nav_system_instructions": "系统指令", "nav_system_instructions_desc": "搜索空间级别的 AI 指令", + "nav_public_links": "公开聊天链接", + "nav_public_links_desc": "管理公开分享的聊天链接", "general_name_label": "名称", "general_name_placeholder": "输入搜索空间名称", "general_name_description": "您的搜索空间的唯一名称。", From ee56334abef175af72105d20687ea6d340aad2b6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 16:47:23 +0200 Subject: [PATCH 13/16] refactor: consolidate public chat snapshot mutations with cache invalidation --- .../atoms/chat/chat-thread-mutation.atoms.ts | 30 --------------- .../public-chat-snapshots-mutation.atoms.ts | 38 ++++++++++++++++++- .../components/new-chat/chat-share-button.tsx | 2 +- .../public-chat-snapshots-manager.tsx | 5 +-- surfsense_web/lib/query-client/cache-keys.ts | 1 + 5 files changed, 41 insertions(+), 35 deletions(-) delete mode 100644 surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts diff --git a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts deleted file mode 100644 index 35858171e..000000000 --- a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { atomWithMutation } from "jotai-tanstack-query"; -import { toast } from "sonner"; -import type { - PublicChatSnapshotCreateRequest, - PublicChatSnapshotCreateResponse, -} from "@/contracts/types/chat-threads.types"; -import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; - -export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({ - mutationFn: async (request: PublicChatSnapshotCreateRequest) => { - return chatThreadsApiService.createPublicChatSnapshot(request); - }, - onSuccess: (response: PublicChatSnapshotCreateResponse) => { - const publicUrl = `${window.location.origin}/public/${response.share_token}`; - navigator.clipboard.writeText(publicUrl); - if (response.is_new) { - toast.success("Public link created and copied to clipboard", { - description: "Anyone with this link can view a snapshot of this chat", - }); - } else { - toast.success("Public link copied to clipboard", { - description: "This snapshot already exists", - }); - } - }, - onError: (error: Error) => { - console.error("Failed to create snapshot:", error); - toast.error("Failed to create public link"); - }, -})); diff --git a/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts index a0343fb9d..bfdfccb16 100644 --- a/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts +++ b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts @@ -1,13 +1,49 @@ import { atomWithMutation } from "jotai-tanstack-query"; import { toast } from "sonner"; -import type { PublicChatSnapshotDeleteRequest } from "@/contracts/types/chat-threads.types"; +import type { + PublicChatSnapshotCreateRequest, + PublicChatSnapshotCreateResponse, + PublicChatSnapshotDeleteRequest, +} from "@/contracts/types/chat-threads.types"; import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; + +export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: PublicChatSnapshotCreateRequest) => { + return chatThreadsApiService.createPublicChatSnapshot(request); + }, + onSuccess: (response: PublicChatSnapshotCreateResponse) => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.publicChatSnapshots.all, + }); + + const publicUrl = `${window.location.origin}/public/${response.share_token}`; + navigator.clipboard.writeText(publicUrl); + if (response.is_new) { + toast.success("Public link created and copied to clipboard", { + description: "Anyone with this link can view a snapshot of this chat", + }); + } else { + toast.success("Public link copied to clipboard", { + description: "This snapshot already exists", + }); + } + }, + onError: (error: Error) => { + console.error("Failed to create snapshot:", error); + toast.error("Failed to create public link"); + }, +})); export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({ mutationFn: async (request: PublicChatSnapshotDeleteRequest) => { return chatThreadsApiService.deletePublicChatSnapshot(request); }, onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.publicChatSnapshots.all, + }); toast.success("Public link deleted"); }, onError: (error: Error) => { diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 23af0995e..336a3d4e8 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -5,7 +5,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { Globe, User, Users } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { createPublicChatSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; +import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { Button } from "@/components/ui/button"; diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx index 1c5c1fe42..f5fd281be 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx @@ -23,7 +23,7 @@ export function PublicChatSnapshotsManager({ const [deletingId, setDeletingId] = useState(); // Data fetching - const { data: snapshotsData, isLoading, isError, refetch } = useAtomValue(publicChatSnapshotsAtom); + const { data: snapshotsData, isLoading, isError } = useAtomValue(publicChatSnapshotsAtom); // Permissions const { data: access } = useAtomValue(myAccessAtom); @@ -57,14 +57,13 @@ export function PublicChatSnapshotsManager({ thread_id: snapshot.thread_id, snapshot_id: snapshot.id, }); - await refetch(); } catch (error) { console.error("Failed to delete snapshot:", error); } finally { setDeletingId(undefined); } }, - [deleteSnapshot, refetch] + [deleteSnapshot] ); // Loading state diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index df456eba3..4d220a62a 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -83,6 +83,7 @@ export const cacheKeys = { byToken: (shareToken: string) => ["public-chat", shareToken] as const, }, publicChatSnapshots: { + all: ["public-chat-snapshots"] as const, bySearchSpace: (searchSpaceId: number) => ["public-chat-snapshots", "search-space", searchSpaceId] as const, }, From 3cf8647d5026adb5f47c9de4f9a6a55791e500dd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 19:31:46 +0200 Subject: [PATCH 14/16] fix: organize imports --- .../public-chat-snapshots/public-chat-snapshots-manager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx index f5fd281be..167443f66 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx @@ -4,9 +4,9 @@ import { useAtomValue } from "jotai"; import { AlertCircle, Globe, Info } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms"; import { publicChatSnapshotsAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms"; -import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; From 8d9dfc7aa4d8832e8b495b2f44d2e88eb5020cee Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 2 Feb 2026 20:08:38 +0200 Subject: [PATCH 15/16] feat: add graceful public chat not found page --- .../public-chat/public-chat-not-found.tsx | 30 +++++++++++++++++++ .../public-chat/public-chat-view.tsx | 13 ++------ surfsense_web/messages/en.json | 5 ++++ surfsense_web/messages/zh.json | 5 ++++ 4 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 surfsense_web/components/public-chat/public-chat-not-found.tsx diff --git a/surfsense_web/components/public-chat/public-chat-not-found.tsx b/surfsense_web/components/public-chat/public-chat-not-found.tsx new file mode 100644 index 000000000..8ecff6931 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-not-found.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Link2Off } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Navbar } from "@/components/homepage/navbar"; + +export function PublicChatNotFound() { + const t = useTranslations("public_chat"); + + return ( +
+ +
+
+ +
+
+

{t("not_found_title")}

+

+ + {t("click_here")} + {" "} + {t("sign_in_prompt")} +

+
+
+
+ ); +} diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx index 8b21fede1..08a450d06 100644 --- a/surfsense_web/components/public-chat/public-chat-view.tsx +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -10,6 +10,7 @@ import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { usePublicChat } from "@/hooks/use-public-chat"; import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime"; import { PublicChatFooter } from "./public-chat-footer"; +import { PublicChatNotFound } from "./public-chat-not-found"; import { PublicThread } from "./public-thread"; interface PublicChatViewProps { @@ -32,17 +33,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) { } if (error || !data) { - return ( -
- -
-

Chat not found

-

- This chat may have been removed or is no longer public. -

-
-
- ); + return ; } return ( diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 409a6fb6f..75b186420 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -796,5 +796,10 @@ "comments": "comments", "example_comment": "Let's discuss this tomorrow!" } + }, + "public_chat": { + "not_found_title": "This chat has been deleted.", + "click_here": "Click here", + "sign_in_prompt": "to log in to SurfSense and start your own." } } diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 750902915..81121ef3e 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -781,5 +781,10 @@ "comments": "评论", "example_comment": "我们明天讨论这个!" } + }, + "public_chat": { + "not_found_title": "此对话已被删除。", + "click_here": "点击这里", + "sign_in_prompt": "登录 SurfSense 开始您自己的对话。" } } From b221e8cd114a44726255f0f27467b5790ad01815 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 3 Feb 2026 14:46:33 +0200 Subject: [PATCH 16/16] fix: renumber migration to avoid version conflict with upstream --- ...s.py => 90_add_public_sharing_permissions_to_roles.py} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename surfsense_backend/alembic/versions/{86_add_public_sharing_permissions_to_roles.py => 90_add_public_sharing_permissions_to_roles.py} (95%) diff --git a/surfsense_backend/alembic/versions/86_add_public_sharing_permissions_to_roles.py b/surfsense_backend/alembic/versions/90_add_public_sharing_permissions_to_roles.py similarity index 95% rename from surfsense_backend/alembic/versions/86_add_public_sharing_permissions_to_roles.py rename to surfsense_backend/alembic/versions/90_add_public_sharing_permissions_to_roles.py index ea18b6fd3..b18d465c0 100644 --- a/surfsense_backend/alembic/versions/86_add_public_sharing_permissions_to_roles.py +++ b/surfsense_backend/alembic/versions/90_add_public_sharing_permissions_to_roles.py @@ -1,7 +1,7 @@ """Add public_sharing permissions to existing roles -Revision ID: 86 -Revises: 85 +Revision ID: 90 +Revises: 89 Create Date: 2026-02-02 """ @@ -10,8 +10,8 @@ from sqlalchemy import text from alembic import op -revision = "86" -down_revision = "85" +revision = "90" +down_revision = "89" branch_labels = None depends_on = None