From 4fa350cf64496da29927102f009e1df5061d056e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 13:24:45 +0200 Subject: [PATCH 01/25] hide SurfSense docs from documents table --- .../documents/(manage)/page.tsx | 166 +++--------------- 1 file changed, 23 insertions(+), 143 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index 54fd490a1..c2ddf6f71 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -20,7 +20,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { PaginationControls } from "./components/PaginationControls"; import { ProcessingIndicator } from "./components/ProcessingIndicator"; -import type { ColumnVisibility, Document } from "./components/types"; +import type { ColumnVisibility } from "./components/types"; function useDebounced(value: T, delay = 250) { const [debounced, setDebounced] = useState(value); @@ -60,39 +60,30 @@ export default function DocumentsTable() { const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); - // Filter out SURFSENSE_DOCS from active types for regular documents API - const regularDocumentTypes = useMemo( - () => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"), - [activeTypes] - ); - - // Check if only SURFSENSE_DOCS is selected (skip regular docs query) - const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS"; - - // Build query parameters for fetching documents (excluding SURFSENSE_DOCS type) + // Build query parameters for fetching documents const queryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, - ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), + ...(activeTypes.length > 0 && { document_types: activeTypes }), }), - [searchSpaceId, pageIndex, pageSize, regularDocumentTypes] + [searchSpaceId, pageIndex, pageSize, activeTypes] ); - // Build search query parameters (excluding SURFSENSE_DOCS type) + // Build search query parameters const searchQueryParams = useMemo( () => ({ search_space_id: searchSpaceId, page: pageIndex, page_size: pageSize, title: debouncedSearch.trim(), - ...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), + ...(activeTypes.length > 0 && { document_types: activeTypes }), }), - [searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch] + [searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] ); - // Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected) + // Use query for fetching documents const { data: documentsResponse, isLoading: isDocumentsLoading, @@ -102,10 +93,10 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(queryParams), queryFn: () => documentsApiService.getDocuments({ queryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected, + enabled: !!searchSpaceId && !debouncedSearch.trim(), }); - // Use query for searching documents (disabled when only SURFSENSE_DOCS is selected) + // Use query for searching documents const { data: searchResponse, isLoading: isSearchLoading, @@ -115,114 +106,20 @@ export default function DocumentsTable() { queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), staleTime: 3 * 60 * 1000, // 3 minutes - enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected, + enabled: !!searchSpaceId && !!debouncedSearch.trim(), }); - // Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected) - const showSurfsenseDocs = - activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum); - - // Use query for fetching SurfSense docs - const { - data: surfsenseDocsResponse, - isLoading: isSurfsenseDocsLoading, - refetch: refetchSurfsenseDocs, - } = useQuery({ - queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], - queryFn: () => - documentsApiService.getSurfsenseDocs({ - queryParams: { - page: pageIndex, - page_size: pageSize, - title: debouncedSearch.trim() || undefined, - }, - }), - staleTime: 3 * 60 * 1000, // 3 minutes - enabled: showSurfsenseDocs, - }); - - // Transform SurfSense docs to match the Document type - const surfsenseDocsAsDocuments: Document[] = useMemo(() => { - if (!surfsenseDocsResponse?.items) return []; - return surfsenseDocsResponse.items.map((doc) => ({ - id: doc.id, - title: doc.title, - document_type: "SURFSENSE_DOCS", - document_metadata: { source: doc.source }, - content: doc.content, - created_at: doc.created_at || doc.updated_at || new Date().toISOString(), - search_space_id: -1, // Special value for global docs - })); - }, [surfsenseDocsResponse]); - - // Merge type counts with SURFSENSE_DOCS count - const typeCounts = useMemo(() => { - const counts = { ...(rawTypeCounts || {}) }; - if (surfsenseDocsResponse?.total) { - counts.SURFSENSE_DOCS = surfsenseDocsResponse.total; - } - return counts; - }, [rawTypeCounts, surfsenseDocsResponse?.total]); - // Extract documents and total based on search state - const regularDocuments = debouncedSearch.trim() + const documents = debouncedSearch.trim() ? searchResponse?.items || [] : documentsResponse?.items || []; - const regularTotal = debouncedSearch.trim() - ? searchResponse?.total || 0 - : documentsResponse?.total || 0; + const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0; - // Merge regular documents with SurfSense docs - const documents = useMemo(() => { - // If filtering by type and not including SURFSENSE_DOCS, only show regular docs - if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { - return regularDocuments; - } - // If filtering only by SURFSENSE_DOCS, only show surfsense docs - if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { - return surfsenseDocsAsDocuments; - } - // Otherwise, merge both (surfsense docs first) - return [...surfsenseDocsAsDocuments, ...regularDocuments]; - }, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]); + const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; + const error = debouncedSearch.trim() ? searchError : documentsError; - const total = useMemo(() => { - if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { - return regularTotal; - } - if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") { - return surfsenseDocsResponse?.total || 0; - } - return regularTotal + (surfsenseDocsResponse?.total || 0); - }, [regularTotal, surfsenseDocsResponse?.total, activeTypes]); - - const loading = useMemo(() => { - // If only SURFSENSE_DOCS selected, only check surfsense loading - if (onlySurfsenseDocsSelected) { - return isSurfsenseDocsLoading; - } - // Otherwise check both regular docs and surfsense docs loading - const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading; - return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading); - }, [ - onlySurfsenseDocsSelected, - isSurfsenseDocsLoading, - debouncedSearch, - isSearchLoading, - isDocumentsLoading, - showSurfsenseDocs, - ]); - - const error = useMemo(() => { - // If only SURFSENSE_DOCS selected, no regular docs errors - if (onlySurfsenseDocsSelected) { - return null; - } - return debouncedSearch.trim() ? searchError : documentsError; - }, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]); - - // Display server-filtered results directly - const displayDocs = documents || []; + // Display results directly + const displayDocs = documents; const displayTotal = total; const pageStart = pageIndex * pageSize; const pageEnd = Math.min(pageStart + pageSize, displayTotal); @@ -242,33 +139,16 @@ export default function DocumentsTable() { if (isRefreshing) return; setIsRefreshing(true); try { - const refetchPromises: Promise[] = []; - // Only refetch regular documents if not in "only surfsense docs" mode - if (!onlySurfsenseDocsSelected) { - if (debouncedSearch.trim()) { - refetchPromises.push(refetchSearch()); - } else { - refetchPromises.push(refetchDocuments()); - } + if (debouncedSearch.trim()) { + await refetchSearch(); + } else { + await refetchDocuments(); } - if (showSurfsenseDocs) { - refetchPromises.push(refetchSurfsenseDocs()); - } - await Promise.all(refetchPromises); toast.success(t("refresh_success") || "Documents refreshed"); } finally { setIsRefreshing(false); } - }, [ - debouncedSearch, - refetchSearch, - refetchDocuments, - refetchSurfsenseDocs, - showSurfsenseDocs, - onlySurfsenseDocsSelected, - t, - isRefreshing, - ]); + }, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]); // Set up smart polling for active tasks - only polls when tasks are in progress const { summary } = useLogsSummary(searchSpaceId, 24, { @@ -385,7 +265,7 @@ export default function DocumentsTable() { Date: Wed, 14 Jan 2026 13:28:49 +0200 Subject: [PATCH 02/25] clean up SURFSENSE_DOCS handling from DocumentsTableShell --- .../components/DocumentsTableShell.tsx | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 566e103ac..94c0626e6 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -79,25 +79,17 @@ export function DocumentsTableShell({ [documents, sortKey, sortDesc] ); - // Filter out SURFSENSE_DOCS for selection purposes - const selectableDocs = React.useMemo( - () => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"), - [sorted] - ); - - const allSelectedOnPage = - selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id)); - const someSelectedOnPage = - selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; + const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); + const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; const toggleAll = (checked: boolean) => { const next = new Set(selectedIds); if (checked) - selectableDocs.forEach((d) => { + sorted.forEach((d) => { next.add(d.id); }); else - selectableDocs.forEach((d) => { + sorted.forEach((d) => { next.delete(d.id); }); setSelectedIds(next); @@ -238,10 +230,9 @@ export function DocumentsTableShell({ const icon = getDocumentTypeIcon(doc.document_type); const title = doc.title; const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; - const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS"; return ( !isSurfsenseDoc && toggleOne(doc.id, !!v)} - disabled={isSurfsenseDoc} + checked={selectedIds.has(doc.id)} + onCheckedChange={(v) => toggleOne(doc.id, !!v)} aria-label="Select row" /> From af6451847bfaa846ea191bc9d53af05a00d9c5ab Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 13:40:29 +0200 Subject: [PATCH 03/25] add section headers to document mention picker --- .../new-chat/document-mention-picker.tsx | 149 +++++++++++++----- 1 file changed, 108 insertions(+), 41 deletions(-) diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index e89885b1d..8fbef9d15 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { FileText } from "lucide-react"; +import { BookOpen, FileText } from "lucide-react"; import { forwardRef, useCallback, @@ -215,6 +215,16 @@ export const DocumentMentionPicker = forwardRef< isSurfsenseDocsLoading) && currentPage === 0; + // Split documents into SurfSense docs and user docs for grouped rendering + const surfsenseDocsList = useMemo( + () => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"), + [actualDocuments] + ); + const userDocsList = useMemo( + () => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"), + [actualDocuments] + ); + // Track already selected documents using unique key (document_type:id) to avoid ID collisions const selectedKeys = useMemo( () => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)), @@ -324,47 +334,104 @@ export const DocumentMentionPicker = forwardRef< ) : (
- {actualDocuments.map((doc) => { - const docKey = `${doc.document_type}:${doc.id}`; - const isAlreadySelected = selectedKeys.has(docKey); - const selectableIndex = selectableDocuments.findIndex( - (d) => d.document_type === doc.document_type && d.id === doc.id - ); - const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; + {/* SurfSense Documentation Section */} + {surfsenseDocsList.length > 0 && ( + <> +
+ + SurfSense Docs +
+ {surfsenseDocsList.map((doc) => { + const docKey = `${doc.document_type}:${doc.id}`; + const isAlreadySelected = selectedKeys.has(docKey); + const selectableIndex = selectableDocuments.findIndex( + (d) => d.document_type === doc.document_type && d.id === doc.id + ); + const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; + + return ( + + ); + })} + + )} + + {/* User Documents Section */} + {userDocsList.length > 0 && ( + <> +
+ + Your Documents +
+ {userDocsList.map((doc) => { + const docKey = `${doc.document_type}:${doc.id}`; + const isAlreadySelected = selectedKeys.has(docKey); + const selectableIndex = selectableDocuments.findIndex( + (d) => d.document_type === doc.document_type && d.id === doc.id + ); + const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex; + + return ( + + ); + })} + + )} - return ( - - ); - })} {/* Loading indicator for additional pages */} {isLoadingMore && (
From c6aec95797350af894c0b567dbb0fe49e8e590de Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 14:14:01 +0200 Subject: [PATCH 04/25] improve document mention picker section headers styling --- .../components/new-chat/document-mention-picker.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index 8fbef9d15..ba9e4ea95 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { BookOpen, FileText } from "lucide-react"; +import { FileText } from "lucide-react"; import { forwardRef, useCallback, @@ -337,9 +337,8 @@ export const DocumentMentionPicker = forwardRef< {/* SurfSense Documentation Section */} {surfsenseDocsList.length > 0 && ( <> -
- - SurfSense Docs +
+ SurfSense Docs
{surfsenseDocsList.map((doc) => { const docKey = `${doc.document_type}:${doc.id}`; @@ -386,9 +385,8 @@ export const DocumentMentionPicker = forwardRef< {/* User Documents Section */} {userDocsList.length > 0 && ( <> -
- - Your Documents +
+ Your Documents
{userDocsList.map((doc) => { const docKey = `${doc.document_type}:${doc.id}`; From 596cef1952f6f3d0edf32c4a1569b0c270731cd8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 14:37:16 +0200 Subject: [PATCH 05/25] add display_name and avatar_url to User model --- surfsense_backend/app/db.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 73727a9ef..691e19278 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -874,6 +874,10 @@ if config.AUTH_TYPE == "GOOGLE": ) pages_used = Column(Integer, nullable=False, default=0, server_default="0") + # User profile from OAuth + display_name = Column(String, nullable=True) + avatar_url = Column(String, nullable=True) + else: class User(SQLAlchemyBaseUserTableUUID, Base): @@ -907,6 +911,10 @@ else: ) pages_used = Column(Integer, nullable=False, default=0, server_default="0") + # User profile (can be set manually for non-OAuth users) + display_name = Column(String, nullable=True) + avatar_url = Column(String, nullable=True) + engine = create_async_engine(DATABASE_URL) async_session_maker = async_sessionmaker(engine, expire_on_commit=False) From 2e2534ccacf1b1904e6dee905a1313d5b0c5ce8c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 14:37:29 +0200 Subject: [PATCH 06/25] migration: add user profile columns --- .../versions/62_add_user_profile_columns.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 surfsense_backend/alembic/versions/62_add_user_profile_columns.py diff --git a/surfsense_backend/alembic/versions/62_add_user_profile_columns.py b/surfsense_backend/alembic/versions/62_add_user_profile_columns.py new file mode 100644 index 000000000..a6fef0c5b --- /dev/null +++ b/surfsense_backend/alembic/versions/62_add_user_profile_columns.py @@ -0,0 +1,72 @@ +"""Add display_name and avatar_url columns to user table + +This migration adds: +- display_name column for user's full name from OAuth +- avatar_url column for user's profile picture URL from OAuth + +Revision ID: 62 +Revises: 61 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "62" +down_revision: str | None = "61" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add display_name and avatar_url columns to user table.""" + + # Add display_name column (nullable for existing users) + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'user' AND column_name = 'display_name' + ) THEN + ALTER TABLE "user" + ADD COLUMN display_name VARCHAR; + END IF; + END$$; + """ + ) + + # Add avatar_url column (nullable for existing users) + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'user' AND column_name = 'avatar_url' + ) THEN + ALTER TABLE "user" + ADD COLUMN avatar_url VARCHAR; + END IF; + END$$; + """ + ) + + +def downgrade() -> None: + """Remove display_name and avatar_url columns from user table.""" + + op.execute( + """ + ALTER TABLE "user" + DROP COLUMN IF EXISTS avatar_url; + """ + ) + op.execute( + """ + ALTER TABLE "user" + DROP COLUMN IF EXISTS display_name; + """ + ) From acb9ba5a8807c8811764e6911264cefcef2fa561 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 14:37:42 +0200 Subject: [PATCH 07/25] capture Google profile in oauth_callback --- surfsense_backend/app/users.py | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index dd284307f..0669c320c 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -46,6 +46,60 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): reset_password_token_secret = SECRET verification_token_secret = SECRET + async def oauth_callback( + self, + oauth_name: str, + access_token: str, + account_id: str, + account_email: str, + expires_at: int | None = None, + refresh_token: str | None = None, + request: Request | None = None, + *, + associate_by_email: bool = False, + is_verified_by_default: bool = False, + ) -> User: + """ + Override OAuth callback to capture Google profile data (name, avatar). + """ + # Call parent implementation to create/get user + user = await super().oauth_callback( + oauth_name, + access_token, + account_id, + account_email, + expires_at, + refresh_token, + request, + associate_by_email=associate_by_email, + is_verified_by_default=is_verified_by_default, + ) + + # Fetch and store Google profile data if not already set + if oauth_name == "google" and (not user.display_name or not user.avatar_url): + try: + profile = await google_oauth_client.get_profile(access_token) + update_dict = {} + + if not user.display_name and profile.get("name"): + update_dict["display_name"] = profile["name"] + + if not user.avatar_url and profile.get("picture"): + update_dict["avatar_url"] = profile["picture"] + + if update_dict: + user = await self.user_db.update(user, update_dict) + logger.info( + f"Updated profile for user {user.id}: {list(update_dict.keys())}" + ) + + except Exception as e: + logger.warning( + f"Failed to fetch Google profile for user {user.id}: {e}" + ) + + return user + async def on_after_register(self, user: User, request: Request | None = None): """ Called after a user registers. Creates a default search space for the user From 87e1374c728be06a8e51a9ba91d0646370bb3d4b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 14:37:50 +0200 Subject: [PATCH 08/25] add profile fields to UserRead and UserUpdate --- surfsense_backend/app/schemas/users.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/schemas/users.py b/surfsense_backend/app/schemas/users.py index a8e0cfac8..88d0a4f37 100644 --- a/surfsense_backend/app/schemas/users.py +++ b/surfsense_backend/app/schemas/users.py @@ -6,6 +6,8 @@ from fastapi_users import schemas class UserRead(schemas.BaseUser[uuid.UUID]): pages_limit: int pages_used: int + display_name: str | None = None + avatar_url: str | None = None class UserCreate(schemas.BaseUserCreate): @@ -13,4 +15,5 @@ class UserCreate(schemas.BaseUserCreate): class UserUpdate(schemas.BaseUserUpdate): - pass + display_name: str | None = None + avatar_url: str | None = None From 3ad2dbeeaf514ce6d191b47c1a095e1f38a6c081 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 15:05:48 +0200 Subject: [PATCH 09/25] fix: fetch Google profile with names,photos fields --- surfsense_backend/app/users.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 0669c320c..e86eb752b 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,6 +1,7 @@ import logging import uuid +import httpx from fastapi import Depends, Request, Response from fastapi.responses import JSONResponse, RedirectResponse from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models @@ -78,25 +79,36 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): # Fetch and store Google profile data if not already set if oauth_name == "google" and (not user.display_name or not user.avatar_url): try: - profile = await google_oauth_client.get_profile(access_token) + async with httpx.AsyncClient() as client: + response = await client.get( + "https://people.googleapis.com/v1/people/me", + params={"personFields": "names,photos"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + response.raise_for_status() + profile = response.json() + update_dict = {} - if not user.display_name and profile.get("name"): - update_dict["display_name"] = profile["name"] + # Extract name from names array + names = profile.get("names", []) + if not user.display_name and names: + display_name = names[0].get("displayName") + if display_name: + update_dict["display_name"] = display_name - if not user.avatar_url and profile.get("picture"): - update_dict["avatar_url"] = profile["picture"] + # Extract photo URL from photos array + photos = profile.get("photos", []) + if not user.avatar_url and photos: + photo_url = photos[0].get("url") + if photo_url: + update_dict["avatar_url"] = photo_url if update_dict: user = await self.user_db.update(user, update_dict) - logger.info( - f"Updated profile for user {user.id}: {list(update_dict.keys())}" - ) except Exception as e: - logger.warning( - f"Failed to fetch Google profile for user {user.id}: {e}" - ) + logger.warning(f"Failed to fetch Google profile: {e}") return user From 9d3c6f0ee177656821b2c94db582965307bcf4b2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 15:22:24 +0200 Subject: [PATCH 10/25] show user avatar in sidebar when available --- .../layout/providers/LayoutDataProvider.tsx | 6 +- .../components/layout/types/layout.types.ts | 1 + .../layout/ui/sidebar/SidebarUserProfile.tsx | 57 +++++++++++-------- surfsense_web/contracts/types/user.types.ts | 2 + 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 3d4e5630d..95ff5d782 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -354,7 +354,11 @@ export function LayoutDataProvider({ onChatDelete={handleChatDelete} onViewAllSharedChats={handleViewAllSharedChats} onViewAllPrivateChats={handleViewAllPrivateChats} - user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} + user={{ + email: user?.email || "", + name: user?.display_name || user?.email?.split("@")[0], + avatarUrl: user?.avatar_url || undefined, + }} onSettings={handleSettings} onManageMembers={handleManageMembers} onUserSettings={handleUserSettings} diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 73ac98fa5..3eac64e60 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -12,6 +12,7 @@ export interface SearchSpace { export interface User { email: string; name?: string; + avatarUrl?: string; } export interface NavItem { diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index d3e97c8eb..30a4d14dc 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -61,6 +61,34 @@ function getInitials(email: string): string { return name.slice(0, 2).toUpperCase(); } +/** + * User avatar component - shows image if available, otherwise falls back to initials + */ +function UserAvatar({ + avatarUrl, + initials, + bgColor, +}: { + avatarUrl?: string; + initials: string; + bgColor: string; +}) { + if (avatarUrl) { + return ( + User avatar + ); + } + + return ( +
+ {initials} +
+ ); +} + export function SidebarUserProfile({ user, onUserSettings, @@ -88,12 +116,7 @@ export function SidebarUserProfile({ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" )} > -
- {initials} -
+ {displayName} @@ -104,12 +127,7 @@ export function SidebarUserProfile({
-
- {initials} -
+

{displayName}

{user.email}

@@ -149,13 +167,7 @@ export function SidebarUserProfile({ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" )} > - {/* Avatar */} -
- {initials} -
+ {/* Name and email */}
@@ -171,12 +183,7 @@ export function SidebarUserProfile({
-
- {initials} -
+

{displayName}

{user.email}

diff --git a/surfsense_web/contracts/types/user.types.ts b/surfsense_web/contracts/types/user.types.ts index f2d1f0ffc..0cd8314da 100644 --- a/surfsense_web/contracts/types/user.types.ts +++ b/surfsense_web/contracts/types/user.types.ts @@ -8,6 +8,8 @@ export const user = z.object({ is_verified: z.boolean(), pages_limit: z.number(), pages_used: z.number(), + display_name: z.string().nullable().optional(), + avatar_url: z.string().nullable().optional(), }); /** From dc9a797fca6b545cedecc3d4bd47f91594be964e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 15:27:42 +0200 Subject: [PATCH 11/25] format: auto-fix whitespace --- .../components/assistant-ui/document-upload-popup.tsx | 5 +---- surfsense_web/lib/env-config.ts | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index 453c6abde..29f633ebf 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -119,10 +119,7 @@ const DocumentUploadPopupContent: FC<{
- +
{/* Bottom fade shadow */} diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 5e35b160c..6201a0425 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -1,10 +1,10 @@ /** * Environment configuration for the frontend. - * + * * This file centralizes access to NEXT_PUBLIC_* environment variables. * For Docker deployments, these placeholders are replaced at container startup * via sed in the entrypoint script. - * + * * IMPORTANT: Do not use template literals or complex expressions with these values * as it may prevent the sed replacement from working correctly. */ @@ -24,5 +24,5 @@ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"; // Helper to check if local auth is enabled export const isLocalAuth = () => AUTH_TYPE === "LOCAL"; -// Helper to check if Google auth is enabled +// Helper to check if Google auth is enabled export const isGoogleAuth = () => AUTH_TYPE === "GOOGLE"; From f9a7b3875a5054947f972886af429317926a70c4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 15:38:58 +0200 Subject: [PATCH 12/25] add UpdateUserRequest and UpdateUserResponse schemas --- surfsense_web/contracts/types/user.types.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/surfsense_web/contracts/types/user.types.ts b/surfsense_web/contracts/types/user.types.ts index 0cd8314da..85fee49a8 100644 --- a/surfsense_web/contracts/types/user.types.ts +++ b/surfsense_web/contracts/types/user.types.ts @@ -8,8 +8,8 @@ export const user = z.object({ is_verified: z.boolean(), pages_limit: z.number(), pages_used: z.number(), - display_name: z.string().nullable().optional(), - avatar_url: z.string().nullable().optional(), + display_name: z.string().nullish(), + avatar_url: z.string().nullish(), }); /** @@ -17,5 +17,20 @@ export const user = z.object({ */ export const getMeResponse = user; +/** + * Update current user request + */ +export const updateUserRequest = z.object({ + display_name: z.string().nullish(), + avatar_url: z.string().nullish(), +}); + +/** + * Update current user response + */ +export const updateUserResponse = user; + export type User = z.infer; export type GetMeResponse = z.infer; +export type UpdateUserRequest = z.infer; +export type UpdateUserResponse = z.infer; From 0ba5de511ebc24278f75f03f6f04c480bbc79c15 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 15:39:50 +0200 Subject: [PATCH 13/25] add updateMe method to user API service --- surfsense_web/lib/apis/user-api.service.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/surfsense_web/lib/apis/user-api.service.ts b/surfsense_web/lib/apis/user-api.service.ts index ea46ac116..94914ebaa 100644 --- a/surfsense_web/lib/apis/user-api.service.ts +++ b/surfsense_web/lib/apis/user-api.service.ts @@ -1,4 +1,8 @@ -import { getMeResponse } from "@/contracts/types/user.types"; +import { + getMeResponse, + updateUserResponse, + type UpdateUserRequest, +} from "@/contracts/types/user.types"; import { baseApiService } from "./base-api.service"; class UserApiService { @@ -8,6 +12,15 @@ class UserApiService { getMe = async () => { return baseApiService.get(`/users/me`, getMeResponse); }; + + /** + * Update current authenticated user + */ + updateMe = async (request: UpdateUserRequest) => { + return baseApiService.patch(`/users/me`, updateUserResponse, { + body: request, + }); + }; } export const userApiService = new UserApiService(); From 1dbc27b0a309bfacff80347ff0fdb6406d24a4fe Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 15:42:29 +0200 Subject: [PATCH 14/25] add updateUserMutationAtom with cache invalidation --- .../atoms/user/user-mutation.atoms.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 surfsense_web/atoms/user/user-mutation.atoms.ts diff --git a/surfsense_web/atoms/user/user-mutation.atoms.ts b/surfsense_web/atoms/user/user-mutation.atoms.ts new file mode 100644 index 000000000..02a9f2146 --- /dev/null +++ b/surfsense_web/atoms/user/user-mutation.atoms.ts @@ -0,0 +1,19 @@ +import { atomWithMutation, queryClientAtom } from "jotai-tanstack-query"; +import type { UpdateUserRequest } from "@/contracts/types/user.types"; +import { userApiService } from "@/lib/apis/user-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const updateUserMutationAtom = atomWithMutation((get) => { + const queryClient = get(queryClientAtom); + + return { + mutationKey: cacheKeys.user.current(), + mutationFn: async (request: UpdateUserRequest) => { + return userApiService.updateMe(request); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cacheKeys.user.current() }); + }, + }; +}); + From 4e096427199dbbf8d527444d522598918cde37e9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 15:57:02 +0200 Subject: [PATCH 15/25] modularize user settings and add profile section --- .../settings/components/ApiKeyContent.tsx | 123 ++++++++ .../settings/components/ProfileContent.tsx | 80 +++++ .../components/UserSettingsSidebar.tsx | 155 ++++++++++ .../app/dashboard/user/settings/page.tsx | 286 +----------------- surfsense_web/messages/en.json | 4 + 5 files changed, 377 insertions(+), 271 deletions(-) create mode 100644 surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx create mode 100644 surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx create mode 100644 surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx diff --git a/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx new file mode 100644 index 000000000..40e7b1d34 --- /dev/null +++ b/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Check, Copy, Key, Menu, Shield } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useApiKey } from "@/hooks/use-api-key"; + +interface ApiKeyContentProps { + onMenuClick: () => void; +} + +export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) { + const t = useTranslations("userSettings"); + const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); + + return ( + +
+
+ + +
+ + + + +
+

+ {t("api_key_title")} +

+

{t("api_key_description")}

+
+
+
+
+ + + + + + {t("api_key_warning_title")} + {t("api_key_warning_description")} + + +
+

{t("your_api_key")}

+ {isLoading ? ( +
+ ) : apiKey ? ( +
+
+ {apiKey} +
+ + + + + + {copied ? t("copied") : t("copy")} + + +
+ ) : ( +

{t("no_api_key")}

+ )} +
+ +
+

{t("usage_title")}

+

{t("usage_description")}

+
+									Authorization: Bearer {apiKey || "YOUR_API_KEY"}
+								
+
+ + +
+
+ + ); +} + diff --git a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx new file mode 100644 index 000000000..b0c5869f7 --- /dev/null +++ b/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Menu, User } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; + +interface ProfileContentProps { + onMenuClick: () => void; +} + +export function ProfileContent({ onMenuClick }: ProfileContentProps) { + const t = useTranslations("userSettings"); + + return ( + +
+
+ + +
+ + + + +
+

+ {t("profile_title")} +

+

{t("profile_description")}

+
+
+
+
+ + + + {/* Profile form will be added in Task 5 */} +
+

Profile settings coming soon...

+
+
+
+
+
+
+ ); +} + diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx new file mode 100644 index 000000000..e25d318f3 --- /dev/null +++ b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { ArrowLeft, ChevronRight, X } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export interface SettingsNavItem { + id: string; + label: string; + description: string; + icon: LucideIcon; +} + +interface UserSettingsSidebarProps { + activeSection: string; + onSectionChange: (section: string) => void; + onBackToApp: () => void; + isOpen: boolean; + onClose: () => void; + navItems: SettingsNavItem[]; +} + +export function UserSettingsSidebar({ + activeSection, + onSectionChange, + onBackToApp, + isOpen, + onClose, + navItems, +}: UserSettingsSidebarProps) { + const t = useTranslations("userSettings"); + + const handleNavClick = (sectionId: string) => { + onSectionChange(sectionId); + onClose(); + }; + + return ( + <> + + {isOpen && ( + + )} + + + + + ); +} + diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx index bf88e65e5..973b39076 100644 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ b/surfsense_web/app/dashboard/user/settings/page.tsx @@ -1,286 +1,27 @@ "use client"; -import { - ArrowLeft, - Check, - ChevronRight, - Copy, - Key, - type LucideIcon, - Menu, - Shield, - X, -} from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; +import { Key, User } from "lucide-react"; +import { motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useState } from "react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { useApiKey } from "@/hooks/use-api-key"; -import { cn } from "@/lib/utils"; - -interface SettingsNavItem { - id: string; - label: string; - description: string; - icon: LucideIcon; -} - -function UserSettingsSidebar({ - activeSection, - onSectionChange, - onBackToApp, - isOpen, - onClose, - navItems, -}: { - activeSection: string; - onSectionChange: (section: string) => void; - onBackToApp: () => void; - isOpen: boolean; - onClose: () => void; - navItems: SettingsNavItem[]; -}) { - const t = useTranslations("userSettings"); - - const handleNavClick = (sectionId: string) => { - onSectionChange(sectionId); - onClose(); - }; - - return ( - <> - - {isOpen && ( - - )} - - - - - ); -} - -function ApiKeyContent({ onMenuClick }: { onMenuClick: () => void }) { - const t = useTranslations("userSettings"); - const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); - - return ( - -
-
- - -
- - - - -
-

- {t("api_key_title")} -

-

{t("api_key_description")}

-
-
-
-
- - - - - - {t("api_key_warning_title")} - {t("api_key_warning_description")} - - -
-

{t("your_api_key")}

- {isLoading ? ( -
- ) : apiKey ? ( -
-
- {apiKey} -
- - - - - - {copied ? t("copied") : t("copy")} - - -
- ) : ( -

{t("no_api_key")}

- )} -
- -
-

{t("usage_title")}

-

{t("usage_description")}

-
-									Authorization: Bearer {apiKey || "YOUR_API_KEY"}
-								
-
- - -
-
- - ); -} +import { ApiKeyContent } from "./components/ApiKeyContent"; +import { ProfileContent } from "./components/ProfileContent"; +import { UserSettingsSidebar, type SettingsNavItem } from "./components/UserSettingsSidebar"; export default function UserSettingsPage() { const t = useTranslations("userSettings"); const router = useRouter(); - const [activeSection, setActiveSection] = useState("api-key"); + const [activeSection, setActiveSection] = useState("profile"); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const navItems: SettingsNavItem[] = [ + { + id: "profile", + label: t("profile_nav_label"), + description: t("profile_nav_description"), + icon: User, + }, { id: "api-key", label: t("api_key_nav_label"), @@ -310,6 +51,9 @@ export default function UserSettingsPage() { onClose={() => setIsSidebarOpen(false)} navItems={navItems} /> + {activeSection === "profile" && ( + setIsSidebarOpen(true)} /> + )} {activeSection === "api-key" && ( setIsSidebarOpen(true)} /> )} diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index ae7f98843..131cbb360 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -109,6 +109,10 @@ "title": "User Settings", "description": "Manage your account settings and API access", "back_to_app": "Back to app", + "profile_nav_label": "Profile", + "profile_nav_description": "Manage your display name and avatar", + "profile_title": "Profile", + "profile_description": "Update your personal information", "api_key_nav_label": "API Key", "api_key_nav_description": "Manage your API access token", "api_key_title": "API Key", From 4d024219cb27d20fc237b23a6432721316cac622 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 16:11:13 +0200 Subject: [PATCH 16/25] add profile settings form with display name editing --- .../settings/components/ProfileContent.tsx | 115 ++++++++++++++++-- surfsense_web/messages/en.json | 7 ++ 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx index b0c5869f7..fab978b49 100644 --- a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx +++ b/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx @@ -1,16 +1,78 @@ "use client"; -import { Menu, User } from "lucide-react"; +import { useAtomValue } from "jotai"; +import { Loader2, Menu, User } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; interface ProfileContentProps { onMenuClick: () => void; } +function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { + const [hasError, setHasError] = useState(false); + + useEffect(() => { + setHasError(false); + }, [url]); + + if (url && !hasError) { + return ( + Avatar setHasError(true)} + /> + ); + } + + return ( +
+ {fallback} +
+ ); +} + export function ProfileContent({ onMenuClick }: ProfileContentProps) { const t = useTranslations("userSettings"); + const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom); + const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom); + + const [displayName, setDisplayName] = useState(""); + + useEffect(() => { + if (user) { + setDisplayName(user.display_name || ""); + } + }, [user]); + + const getInitials = (email: string) => { + const name = email.split("@")[0]; + return name.slice(0, 2).toUpperCase(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await updateUser({ + display_name: displayName || null, + }); + toast.success(t("profile_saved")); + } catch { + toast.error(t("profile_save_error")); + } + }; + + const hasChanges = displayName !== (user?.display_name || ""); return ( - {/* Profile form will be added in Task 5 */} -
-

Profile settings coming soon...

-
+ {isUserLoading ? ( +
+ +
+ ) : ( +
+
+
+
+ + +
+ +
+ + setDisplayName(e.target.value)} + /> +

+ {t("profile_display_name_hint")} +

+
+ +
+ + +
+
+
+ +
+ +
+
+ )}
@@ -77,4 +179,3 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
); } - diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 131cbb360..5159d4df0 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -113,6 +113,13 @@ "profile_nav_description": "Manage your display name and avatar", "profile_title": "Profile", "profile_description": "Update your personal information", + "profile_avatar": "Profile Picture", + "profile_display_name": "Display Name", + "profile_display_name_hint": "This is how your name appears across the app", + "profile_email": "Email", + "profile_save": "Save Changes", + "profile_saved": "Profile updated successfully", + "profile_save_error": "Failed to update profile", "api_key_nav_label": "API Key", "api_key_nav_description": "Manage your API access token", "api_key_title": "API Key", From d89bcf13c3ac209eb356ed842dda07c4666ba1c7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 17:56:45 +0200 Subject: [PATCH 17/25] add author_id to NewChatMessage model --- surfsense_backend/app/db.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 691e19278..0fa877fc8 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -412,8 +412,17 @@ class NewChatMessage(BaseModel, TimestampMixin): index=True, ) - # Relationship + # Track who sent this message (for shared chats) + author_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + # Relationships thread = relationship("NewChatThread", back_populates="messages") + author = relationship("User") class Document(BaseModel, TimestampMixin): From efcc394b0cd69be8a8a13ef75c57bcfded0a05c7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 18:24:28 +0200 Subject: [PATCH 18/25] migration: add author_id to messages --- .../versions/63_add_message_author_id.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 surfsense_backend/alembic/versions/63_add_message_author_id.py diff --git a/surfsense_backend/alembic/versions/63_add_message_author_id.py b/surfsense_backend/alembic/versions/63_add_message_author_id.py new file mode 100644 index 000000000..2fc3f0b4c --- /dev/null +++ b/surfsense_backend/alembic/versions/63_add_message_author_id.py @@ -0,0 +1,47 @@ +"""Add author_id column to new_chat_messages table + +Revision ID: 63 +Revises: 62 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "63" +down_revision: str | None = "62" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add author_id column to new_chat_messages table.""" + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'new_chat_messages' AND column_name = 'author_id' + ) THEN + ALTER TABLE new_chat_messages + ADD COLUMN author_id UUID REFERENCES "user"(id) ON DELETE SET NULL; + + CREATE INDEX IF NOT EXISTS ix_new_chat_messages_author_id + ON new_chat_messages(author_id); + END IF; + END$$; + """ + ) + + +def downgrade() -> None: + """Remove author_id column from new_chat_messages table.""" + op.execute( + """ + DROP INDEX IF EXISTS ix_new_chat_messages_author_id; + ALTER TABLE new_chat_messages + DROP COLUMN IF EXISTS author_id; + """ + ) + From 468feae592761c4557a03bd7cdd46b617559858b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 18:27:21 +0200 Subject: [PATCH 19/25] add author fields to NewChatMessageRead schema --- surfsense_backend/app/schemas/new_chat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_backend/app/schemas/new_chat.py b/surfsense_backend/app/schemas/new_chat.py index e6dbcd920..3734b0470 100644 --- a/surfsense_backend/app/schemas/new_chat.py +++ b/surfsense_backend/app/schemas/new_chat.py @@ -38,6 +38,9 @@ class NewChatMessageRead(NewChatMessageBase, IDModel, TimestampModel): """Schema for reading a message.""" thread_id: int + author_id: UUID | None = None + author_display_name: str | None = None + author_avatar_url: str | None = None model_config = ConfigDict(from_attributes=True) From e9d164007de5dad85274379de815ce7ed7328fd5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 18:29:19 +0200 Subject: [PATCH 20/25] save author_id when appending message --- surfsense_backend/app/routes/new_chat_routes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index fb5808307..b2c2bfe26 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -782,6 +782,7 @@ async def append_message( thread_id=thread_id, role=message_role, content=message.content, + author_id=user.id, ) session.add(db_message) From a8918b9cca9c00d8fc0c9fa23035edf8e3d1ddff Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 18:36:48 +0200 Subject: [PATCH 21/25] include author info in message response --- surfsense_backend/app/routes/new_chat_routes.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index b2c2bfe26..4ee515770 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -411,10 +411,12 @@ async def get_thread_messages( Requires CHATS_READ permission. """ try: - # Get thread with messages + # Get thread with messages and their authors result = await session.execute( select(NewChatThread) - .options(selectinload(NewChatThread.messages)) + .options( + selectinload(NewChatThread.messages).selectinload(NewChatMessage.author) + ) .filter(NewChatThread.id == thread_id) ) thread = result.scalars().first() @@ -442,6 +444,9 @@ async def get_thread_messages( role=msg.role, content=msg.content, created_at=msg.created_at, + author_id=msg.author_id, + author_display_name=msg.author.display_name if msg.author else None, + author_avatar_url=msg.author.avatar_url if msg.author else None, ) for msg in thread.messages ] From dcd36166fcb7b1e98bf21aa5809e619002405502 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 18:38:47 +0200 Subject: [PATCH 22/25] add author fields to MessageRecord type --- surfsense_web/lib/chat/thread-persistence.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index 5c65ad47e..23dd35800 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -31,6 +31,9 @@ export interface MessageRecord { role: "user" | "assistant" | "system"; content: unknown; created_at: string; + author_id?: string | null; + author_display_name?: string | null; + author_avatar_url?: string | null; } export interface ThreadListResponse { From 83d6b735fabb9e9dde40a4a4ed8b8cc08490a427 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 19:46:37 +0200 Subject: [PATCH 23/25] display user avatar in shared chat messages --- .../app/routes/new_chat_routes.py | 19 ++-- .../new-chat/[[...chat_id]]/page.tsx | 19 ++-- .../components/assistant-ui/thread.tsx | 68 +------------ .../components/assistant-ui/user-message.tsx | 99 ++++++++++++++----- 4 files changed, 98 insertions(+), 107 deletions(-) diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 4ee515770..e4dc5714a 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -411,13 +411,9 @@ async def get_thread_messages( Requires CHATS_READ permission. """ try: - # Get thread with messages and their authors + # Get thread first result = await session.execute( - select(NewChatThread) - .options( - selectinload(NewChatThread.messages).selectinload(NewChatMessage.author) - ) - .filter(NewChatThread.id == thread_id) + select(NewChatThread).filter(NewChatThread.id == thread_id) ) thread = result.scalars().first() @@ -436,6 +432,15 @@ async def get_thread_messages( # Check thread-level access based on visibility await check_thread_access(session, thread, user) + # Get messages with their authors loaded + messages_result = await session.execute( + select(NewChatMessage) + .options(selectinload(NewChatMessage.author)) + .filter(NewChatMessage.thread_id == thread_id) + .order_by(NewChatMessage.created_at) + ) + db_messages = messages_result.scalars().all() + # Return messages in the format expected by assistant-ui messages = [ NewChatMessageRead( @@ -448,7 +453,7 @@ async def get_thread_messages( author_display_name=msg.author.display_name if msg.author else None, author_avatar_url=msg.author.avatar_url if msg.author else None, ) - for msg in thread.messages + for msg in db_messages ] return ThreadHistoryLoadResponse(messages=messages) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index fbe4a9d95..6e4ab3039 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -185,12 +185,25 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { } } + // Build metadata.custom for author display in shared chats + const metadata = msg.author_id + ? { + custom: { + author: { + displayName: msg.author_display_name ?? null, + avatarUrl: msg.author_avatar_url ?? null, + }, + }, + } + : undefined; + return { id: `msg-${msg.id}`, role: msg.role, content, createdAt: new Date(msg.created_at), attachments, + metadata, }; } @@ -306,12 +319,6 @@ export default function NewChatPage() { if (steps.length > 0) { restoredThinkingSteps.set(`msg-${msg.id}`, steps); } - // Hydrate write_todos plan state from persisted tool calls - // Disabled for now - // const writeTodosCalls = extractWriteTodosFromContent(msg.content); - // for (const todoData of writeTodosCalls) { - // hydratePlanState(todoData); - // } } if (msg.role === "user") { const docs = extractMentionedDocuments(msg.content); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index bf46e3d97..2507fb8a9 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -19,9 +19,7 @@ import { ChevronRightIcon, CopyIcon, DownloadIcon, - FileText, Loader2, - PencilIcon, RefreshCwIcon, SquareIcon, } from "lucide-react"; @@ -31,7 +29,6 @@ import { createPortal } from "react-dom"; import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, - messageDocumentsMapAtom, } from "@/atoms/chat/mentioned-documents.atom"; import { globalNewLLMConfigsAtom, @@ -42,8 +39,8 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ComposerAddAttachment, ComposerAttachments, - UserMessageAttachments, } from "@/components/assistant-ui/attachment"; +import { UserMessage } from "@/components/assistant-ui/user-message"; import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { InlineMentionEditor, @@ -639,69 +636,6 @@ const AssistantActionBar: FC = () => { ); }; -const UserMessage: FC = () => { - const messageId = useAssistantState(({ message }) => message?.id); - const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom); - const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; - const hasAttachments = useAssistantState( - ({ message }) => message?.attachments && message.attachments.length > 0 - ); - - return ( - -
- {/* Display attachments and mentioned documents */} - {(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && ( -
- {/* Attachments (images show as thumbnails, documents as chips) */} - - {/* Mentioned documents as chips */} - {mentionedDocs?.map((doc) => ( - - - {doc.title} - - ))} -
- )} - {/* Message bubble with action bar positioned relative to it */} -
-
- -
-
- -
-
-
- - -
- ); -}; - -const UserActionBar: FC = () => { - return ( - - - - - - - - ); -}; const EditComposer: FC = () => { return ( diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 745542304..ee23f6aab 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,16 +1,53 @@ import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { FileText, PencilIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { UserMessageAttachments } from "@/components/assistant-ui/attachment"; import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +interface AuthorMetadata { + displayName: string | null; + avatarUrl: string | null; +} + +const UserAvatar: FC = ({ displayName, avatarUrl }) => { + const [hasError, setHasError] = useState(false); + + const initials = displayName + ? displayName + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + : "U"; + + if (avatarUrl && !hasError) { + return ( + {displayName setHasError(true)} + /> + ); + } + + return ( +
+ {initials} +
+ ); +}; + export const UserMessage: FC = () => { const messageId = useAssistantState(({ message }) => message?.id); const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom); const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; + const metadata = useAssistantState(({ message }) => message?.metadata); + const author = metadata?.custom?.author as AuthorMetadata | undefined; const hasAttachments = useAssistantState( ({ message }) => message?.attachments && message.attachments.length > 0 ); @@ -20,34 +57,42 @@ export const UserMessage: FC = () => { className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2" data-role="user" > -
- {/* Display attachments and mentioned documents */} - {(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && ( -
- {/* Attachments (images show as thumbnails, documents as chips) */} - - {/* Mentioned documents as chips */} - {mentionedDocs?.map((doc) => ( - - - {doc.title} - - ))} -
- )} - {/* Message bubble with action bar positioned relative to it */} -
-
- -
-
- +
+
+ {/* Display attachments and mentioned documents */} + {(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && ( +
+ {/* Attachments (images show as thumbnails, documents as chips) */} + + {/* Mentioned documents as chips */} + {mentionedDocs?.map((doc) => ( + + + {doc.title} + + ))} +
+ )} + {/* Message bubble with action bar positioned relative to it */} +
+
+ +
+
+ +
+ {/* User avatar - only shown in shared chats */} + {author && ( +
+ +
+ )}
From 54568a62e07eaf342f59696a53dccd736bc782b8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 20:30:02 +0200 Subject: [PATCH 24/25] fix: add referrerPolicy for Google profile images --- surfsense_web/components/assistant-ui/user-message.tsx | 1 + .../components/layout/ui/sidebar/SidebarUserProfile.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index ee23f6aab..15b5461b6 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -30,6 +30,7 @@ const UserAvatar: FC = ({ displayName, avatarUrl }) => { src={avatarUrl} alt={displayName || "User"} className="size-8 rounded-full object-cover" + referrerPolicy="no-referrer" onError={() => setHasError(true)} /> ); diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 30a4d14dc..f67dbf7c6 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -75,7 +75,12 @@ function UserAvatar({ }) { if (avatarUrl) { return ( - User avatar + User avatar ); } From 142e72c0302c7b1d9b619638771d760d24e2445a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 14 Jan 2026 21:44:13 +0200 Subject: [PATCH 25/25] show avatar for newly sent messages in shared chats --- .../new-chat/[[...chat_id]]/page.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 6e4ab3039..827646dd2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -23,6 +23,7 @@ import { // extractWriteTodosFromContent, hydratePlanStateAtom, } from "@/atoms/chat/plan-state.atom"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Thread } from "@/components/assistant-ui/thread"; import { ChatHeader } from "@/components/new-chat/chat-header"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; @@ -251,6 +252,9 @@ export default function NewChatPage() { const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const hydratePlanState = useSetAtom(hydratePlanStateAtom); + // Get current user for author info in shared chats + const { data: currentUser } = useAtomValue(currentUserAtom); + // Create the attachment adapter for file processing const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); @@ -455,13 +459,27 @@ export default function NewChatPage() { // Add user message to state const userMsgId = `msg-user-${Date.now()}`; + + // Include author metadata for shared chats + const authorMetadata = + currentThread?.visibility === "SEARCH_SPACE" && currentUser + ? { + custom: { + author: { + displayName: currentUser.display_name ?? null, + avatarUrl: currentUser.avatar_url ?? null, + }, + }, + } + : undefined; + const userMessage: ThreadMessageLike = { id: userMsgId, role: "user", content: message.content, createdAt: new Date(), - // Include attachments so they can be displayed attachments: message.attachments || [], + metadata: authorMetadata, }; setMessages((prev) => [...prev, userMessage]); @@ -891,6 +909,8 @@ export default function NewChatPage() { setMentionedDocuments, setMessageDocumentsMap, queryClient, + currentThread, + currentUser, ] );