diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 2e10f4e36..628329917 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -32,6 +32,11 @@ ELECTRIC_DB_PASSWORD=electric_password SCHEDULE_CHECKER_INTERVAL=5m SECRET_KEY=SECRET + +# JWT Token Lifetimes (optional, defaults shown) +# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day +# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks + NEXT_FRONTEND_URL=http://localhost:3000 # Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS) diff --git a/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py b/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py new file mode 100644 index 000000000..c7e133ae9 --- /dev/null +++ b/surfsense_backend/alembic/versions/92_add_refresh_tokens_table.py @@ -0,0 +1,92 @@ +"""Add refresh_tokens table for user session management + +Revision ID: 92 +Revises: 91 + +Changes: +1. Create refresh_tokens table with columns: + - id (primary key) + - user_id (foreign key to user) + - token_hash (unique, indexed) + - expires_at (indexed) + - is_revoked + - family_id (indexed, for token rotation tracking) + - created_at, updated_at (timestamps) +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "92" +down_revision: str | None = "91" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create refresh_tokens table (idempotent).""" + # Check if table already exists + connection = op.get_bind() + result = connection.execute( + sa.text( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'refresh_tokens')" + ) + ) + table_exists = result.scalar() + + if not table_exists: + op.create_table( + "refresh_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", UUID(as_uuid=True), nullable=False), + sa.Column("token_hash", sa.String(256), nullable=False), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("is_revoked", sa.Boolean(), nullable=False, default=False), + sa.Column("family_id", UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ondelete="CASCADE", + ), + ) + + # Create indexes if they don't exist + op.execute( + "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_user_id ON refresh_tokens (user_id)" + ) + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_refresh_tokens_token_hash ON refresh_tokens (token_hash)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_expires_at ON refresh_tokens (expires_at)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_refresh_tokens_family_id ON refresh_tokens (family_id)" + ) + + +def downgrade() -> None: + """Drop refresh_tokens table (idempotent).""" + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_family_id") + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_expires_at") + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_token_hash") + op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_user_id") + op.execute("DROP TABLE IF EXISTS refresh_tokens") diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 01dd0da3d..63da4e8ad 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -12,6 +12,7 @@ from app.agents.new_chat.checkpointer import ( from app.config import config, initialize_llm_router from app.db import User, create_db_and_tables, get_async_session from app.routes import router as crud_router +from app.routes.auth_routes import router as auth_router from app.schemas import UserCreate, UserRead, UserUpdate from app.tasks.surfsense_docs_indexer import seed_surfsense_docs from app.users import SECRET, auth_backend, current_active_user, fastapi_users @@ -111,6 +112,9 @@ app.include_router( tags=["users"], ) +# Include custom auth routes (refresh token, logout) +app.include_router(auth_router) + if config.AUTH_TYPE == "GOOGLE": from fastapi.responses import RedirectResponse diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 149fedd39..121e5d3b2 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -255,6 +255,14 @@ class Config: # OAuth JWT SECRET_KEY = os.getenv("SECRET_KEY") + # JWT Token Lifetimes + ACCESS_TOKEN_LIFETIME_SECONDS = int( + os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(24 * 60 * 60)) # 1 day + ) + REFRESH_TOKEN_LIFETIME_SECONDS = int( + os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) # 2 weeks + ) + # ETL Service ETL_SERVICE = os.getenv("ETL_SERVICE") diff --git a/surfsense_backend/app/connectors/airtable_history.py b/surfsense_backend/app/connectors/airtable_history.py index 64f6465fe..092485f77 100644 --- a/surfsense_backend/app/connectors/airtable_history.py +++ b/surfsense_backend/app/connectors/airtable_history.py @@ -71,6 +71,14 @@ class AirtableHistoryConnector: config_data = connector.config.copy() + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Airtable access token not found. " + "Please reconnect your Airtable account." + ) + # Decrypt credentials if they are encrypted token_encrypted = config_data.get("_token_encrypted", False) if token_encrypted and config.SECRET_KEY: @@ -98,6 +106,14 @@ class AirtableHistoryConnector: f"Failed to decrypt Airtable credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Airtable access token is invalid or empty. " + "Please reconnect your Airtable account." + ) + try: self._credentials = AirtableAuthCredentialsBase.from_dict(config_data) except Exception as e: diff --git a/surfsense_backend/app/connectors/confluence_history.py b/surfsense_backend/app/connectors/confluence_history.py index 9e10ffcf1..908f532db 100644 --- a/surfsense_backend/app/connectors/confluence_history.py +++ b/surfsense_backend/app/connectors/confluence_history.py @@ -87,6 +87,14 @@ class ConfluenceHistoryConnector: if is_oauth: # OAuth 2.0 authentication + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Confluence access token not found. " + "Please reconnect your Confluence account." + ) + # Decrypt credentials if they are encrypted token_encrypted = config_data.get("_token_encrypted", False) if token_encrypted and config.SECRET_KEY: @@ -118,6 +126,14 @@ class ConfluenceHistoryConnector: f"Failed to decrypt Confluence credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Confluence access token is invalid or empty. " + "Please reconnect your Confluence account." + ) + try: self._credentials = AtlassianAuthCredentialsBase.from_dict( config_data diff --git a/surfsense_backend/app/connectors/jira_history.py b/surfsense_backend/app/connectors/jira_history.py index 6e04ec2a4..46a28324d 100644 --- a/surfsense_backend/app/connectors/jira_history.py +++ b/surfsense_backend/app/connectors/jira_history.py @@ -86,6 +86,14 @@ class JiraHistoryConnector: if is_oauth: # OAuth 2.0 authentication + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Jira access token not found. " + "Please reconnect your Jira account." + ) + if not config.SECRET_KEY: raise ValueError( "SECRET_KEY not configured but tokens are marked as encrypted" @@ -119,6 +127,14 @@ class JiraHistoryConnector: f"Failed to decrypt Jira credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Jira access token is invalid or empty. " + "Please reconnect your Jira account." + ) + try: self._credentials = AtlassianAuthCredentialsBase.from_dict( config_data diff --git a/surfsense_backend/app/connectors/linear_connector.py b/surfsense_backend/app/connectors/linear_connector.py index b8206a40d..6500b9027 100644 --- a/surfsense_backend/app/connectors/linear_connector.py +++ b/surfsense_backend/app/connectors/linear_connector.py @@ -116,6 +116,14 @@ class LinearConnector: config_data = connector.config.copy() + # Check if access_token exists before processing + raw_access_token = config_data.get("access_token") + if not raw_access_token: + raise ValueError( + "Linear access token not found. " + "Please reconnect your Linear account." + ) + # Decrypt credentials if they are encrypted token_encrypted = config_data.get("_token_encrypted", False) if token_encrypted and config.SECRET_KEY: @@ -143,6 +151,14 @@ class LinearConnector: f"Failed to decrypt Linear credentials: {e!s}" ) from e + # Final validation after decryption + final_token = config_data.get("access_token") + if not final_token or (isinstance(final_token, str) and not final_token.strip()): + raise ValueError( + "Linear access token is invalid or empty. " + "Please reconnect your Linear account." + ) + try: self._credentials = LinearAuthCredentialsBase.from_dict(config_data) except Exception as e: diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 344d83f13..55b86ad7c 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1449,6 +1449,13 @@ if config.AUTH_TYPE == "GOOGLE": display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + # Refresh tokens for this user + refresh_tokens = relationship( + "RefreshToken", + back_populates="user", + cascade="all, delete-orphan", + ) + else: class User(SQLAlchemyBaseUserTableUUID, Base): @@ -1514,6 +1521,43 @@ else: display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + # Refresh tokens for this user + refresh_tokens = relationship( + "RefreshToken", + back_populates="user", + cascade="all, delete-orphan", + ) + + +class RefreshToken(Base, TimestampMixin): + """ + Stores refresh tokens for user session management. + Each row represents one device/session. + """ + + __tablename__ = "refresh_tokens" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user = relationship("User", back_populates="refresh_tokens") + token_hash = Column(String(256), unique=True, nullable=False, index=True) + expires_at = Column(TIMESTAMP(timezone=True), nullable=False, index=True) + is_revoked = Column(Boolean, default=False, nullable=False) + family_id = Column(UUID(as_uuid=True), nullable=False, index=True) + + @property + def is_expired(self) -> bool: + return datetime.now(UTC) >= self.expires_at + + @property + def is_valid(self) -> bool: + return not self.is_expired and not self.is_revoked + engine = create_async_engine(DATABASE_URL) async_session_maker = async_sessionmaker(engine, expire_on_commit=False) diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index 3b21cb9e1..efa31d612 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -104,3 +104,33 @@ SUMMARY_PROMPT = ( SUMMARY_PROMPT_TEMPLATE = PromptTemplate( input_variables=["document"], template=SUMMARY_PROMPT ) + +# ============================================================================= +# Chat Title Generation Prompt +# ============================================================================= + +TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation. + + +- The title MUST be between 1 and 6 words +- The title MUST be on a single line +- Capture the main topic or intent of the conversation +- Do NOT use quotes, punctuation, or formatting +- Do NOT include words like "Chat about" or "Discussion of" +- Return ONLY the title, nothing else + + + +{user_query} + + + +{assistant_response} + + +Title:""" + +TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( + input_variables=["user_query", "assistant_response"], + template=TITLE_GENERATION_PROMPT, +) diff --git a/surfsense_backend/app/routes/auth_routes.py b/surfsense_backend/app/routes/auth_routes.py new file mode 100644 index 000000000..b1cbaf2a5 --- /dev/null +++ b/surfsense_backend/app/routes/auth_routes.py @@ -0,0 +1,93 @@ +"""Authentication routes for refresh token management.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select + +from app.db import User, async_session_maker +from app.schemas.auth import ( + LogoutAllResponse, + LogoutRequest, + LogoutResponse, + RefreshTokenRequest, + RefreshTokenResponse, +) +from app.users import current_active_user, get_jwt_strategy +from app.utils.refresh_tokens import ( + revoke_all_user_tokens, + revoke_refresh_token, + rotate_refresh_token, + validate_refresh_token, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth/jwt", tags=["auth"]) + + +@router.post("/refresh", response_model=RefreshTokenResponse) +async def refresh_access_token(request: RefreshTokenRequest): + """ + Exchange a valid refresh token for a new access token and refresh token. + Implements token rotation for security. + """ + token_record = await validate_refresh_token(request.refresh_token) + + if not token_record: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + # Get user from token record + async with async_session_maker() as session: + result = await session.execute( + select(User).where(User.id == token_record.user_id) + ) + user = result.scalars().first() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + # Generate new access token + strategy = get_jwt_strategy() + access_token = await strategy.write_token(user) + + # Rotate refresh token + new_refresh_token = await rotate_refresh_token(token_record) + + logger.info(f"Refreshed token for user {user.id}") + + return RefreshTokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + ) + + +@router.post("/revoke", response_model=LogoutResponse) +async def revoke_token(request: LogoutRequest): + """ + Logout current device by revoking the provided refresh token. + Does not require authentication - just the refresh token. + """ + revoked = await revoke_refresh_token(request.refresh_token) + if revoked: + logger.info("User logged out from current device - token revoked") + else: + logger.warning("Logout called but no matching token found to revoke") + return LogoutResponse() + + +@router.post("/logout-all", response_model=LogoutAllResponse) +async def logout_all_devices(user: User = Depends(current_active_user)): + """ + Logout from all devices by revoking all refresh tokens for the user. + Requires valid access token. + """ + await revoke_all_user_tokens(user.id) + logger.info(f"User {user.id} logged out from all devices") + return LogoutAllResponse() diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 42b8a821b..06e929997 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -886,30 +886,8 @@ async def append_message( # Update thread's updated_at timestamp thread.updated_at = datetime.now(UTC) - # Auto-generate title from first user message if title is still default - if thread.title == "New Chat" and role_str == "user": - # Extract text content for title - content = message.content - if isinstance(content, str): - title_text = content - elif isinstance(content, list): - # Find first text content - title_text = "" - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - title_text = part.get("text", "") - break - elif isinstance(part, str): - title_text = part - break - else: - title_text = str(content) - - # Truncate title - if title_text: - thread.title = title_text[:100] + ( - "..." if len(title_text) > 100 else "" - ) + # Note: Title generation now happens in stream_new_chat.py after the first response + # using LLM to generate a descriptive title (with truncation as fallback) await session.commit() await session.refresh(db_message) diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index e4296d501..e12ffdf38 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -1,3 +1,10 @@ +from .auth import ( + LogoutAllResponse, + LogoutRequest, + LogoutResponse, + RefreshTokenRequest, + RefreshTokenResponse, +) from .base import IDModel, TimestampModel from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate from .documents import ( @@ -119,6 +126,10 @@ __all__ = [ "LogFilter", "LogRead", "LogUpdate", + # Auth schemas + "LogoutAllResponse", + "LogoutRequest", + "LogoutResponse", # Search source connector schemas "MCPConnectorCreate", "MCPConnectorRead", @@ -148,6 +159,8 @@ __all__ = [ "PodcastCreate", "PodcastRead", "PodcastUpdate", + "RefreshTokenRequest", + "RefreshTokenResponse", "RoleCreate", "RoleRead", "RoleUpdate", diff --git a/surfsense_backend/app/schemas/auth.py b/surfsense_backend/app/schemas/auth.py new file mode 100644 index 000000000..0d958a6d2 --- /dev/null +++ b/surfsense_backend/app/schemas/auth.py @@ -0,0 +1,35 @@ +"""Authentication schemas for refresh token endpoints.""" + +from pydantic import BaseModel + + +class RefreshTokenRequest(BaseModel): + """Request body for token refresh endpoint.""" + + refresh_token: str + + +class RefreshTokenResponse(BaseModel): + """Response from token refresh endpoint.""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class LogoutRequest(BaseModel): + """Request body for logout endpoint (current device).""" + + refresh_token: str + + +class LogoutResponse(BaseModel): + """Response from logout endpoint (current device).""" + + detail: str = "Successfully logged out" + + +class LogoutAllResponse(BaseModel): + """Response from logout all devices endpoint.""" + + detail: str = "Successfully logged out from all devices" diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index dc3b51238..c9ca920f6 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -5,7 +5,7 @@ Service layer for chat comments and mentions. from uuid import UUID from fastapi import HTTPException -from sqlalchemy import delete, select +from sqlalchemy import delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -103,6 +103,37 @@ async def process_mentions( return mentions_map +async def get_comment_thread_participants( + session: AsyncSession, + parent_comment_id: int, + exclude_user_ids: set[UUID], +) -> list[UUID]: + """ + Get all unique authors in a comment thread (parent + replies), excluding specified users. + + Args: + session: Database session + parent_comment_id: ID of the parent comment + exclude_user_ids: Set of user IDs to exclude (e.g., replier, mentioned users) + + Returns: + List of user UUIDs who have participated in the thread + """ + query = select(ChatComment.author_id).where( + or_( + ChatComment.id == parent_comment_id, + ChatComment.parent_id == parent_comment_id, + ), + ChatComment.author_id.isnot(None), + ) + + if exclude_user_ids: + query = query.where(ChatComment.author_id.notin_(list(exclude_user_ids))) + + result = await session.execute(query.distinct()) + return [row[0] for row in result.fetchall()] + + async def get_comments_for_message( session: AsyncSession, message_id: int, @@ -436,6 +467,31 @@ async def create_reply( search_space_id=search_space_id, ) + # Notify thread participants (excluding replier and mentioned users) + mentioned_user_ids = set(mentions_map.keys()) + exclude_ids = {user.id} | mentioned_user_ids + participants = await get_comment_thread_participants( + session, comment_id, exclude_ids + ) + for participant_id in participants: + if participant_id in mentioned_user_ids: + continue + await NotificationService.comment_reply.notify_comment_reply( + session=session, + user_id=participant_id, + reply_id=reply.id, + parent_comment_id=comment_id, + message_id=parent_comment.message_id, + thread_id=thread.id, + thread_title=thread.title or "Untitled thread", + author_id=str(user.id), + author_name=author_name, + author_avatar_url=user.avatar_url, + author_email=user.email, + content_preview=content_preview[:200], + search_space_id=search_space_id, + ) + author = AuthorResponse( id=user.id, display_name=user.display_name, diff --git a/surfsense_backend/app/services/new_streaming_service.py b/surfsense_backend/app/services/new_streaming_service.py index 05dd2d4dd..57fbc9663 100644 --- a/surfsense_backend/app/services/new_streaming_service.py +++ b/surfsense_backend/app/services/new_streaming_service.py @@ -479,6 +479,31 @@ class VercelStreamingService: }, ) + def format_thread_title_update(self, thread_id: int, title: str) -> str: + """ + Format a thread title update notification (SurfSense specific). + + This is sent after the first response in a thread to update the + auto-generated title based on the conversation content. + + Args: + thread_id: The ID of the thread being updated + title: The new title for the thread + + Returns: + str: SSE formatted thread title update data part + + Example output: + data: {"type":"data-thread-title-update","data":{"threadId":123,"title":"New Title"}} + """ + return self.format_data( + "thread-title-update", + { + "threadId": thread_id, + "title": title, + }, + ) + # ========================================================================= # Error Part # ========================================================================= diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py index 1788d05e1..a759f3536 100644 --- a/surfsense_backend/app/services/notification_service.py +++ b/surfsense_backend/app/services/notification_service.py @@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler): raise +class CommentReplyNotificationHandler(BaseNotificationHandler): + """Handler for comment reply notifications.""" + + def __init__(self): + super().__init__("comment_reply") + + async def find_notification_by_reply( + self, + session: AsyncSession, + reply_id: int, + user_id: UUID, + ) -> Notification | None: + query = select(Notification).where( + Notification.type == self.notification_type, + Notification.user_id == user_id, + Notification.notification_metadata["reply_id"].astext == str(reply_id), + ) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def notify_comment_reply( + self, + session: AsyncSession, + user_id: UUID, + reply_id: int, + parent_comment_id: int, + message_id: int, + thread_id: int, + thread_title: str, + author_id: str, + author_name: str, + author_avatar_url: str | None, + author_email: str, + content_preview: str, + search_space_id: int, + ) -> Notification: + existing = await self.find_notification_by_reply(session, reply_id, user_id) + if existing: + logger.info( + f"Notification already exists for reply {reply_id} to user {user_id}" + ) + return existing + + title = f"{author_name} replied in a thread" + message = content_preview[:100] + ("..." if len(content_preview) > 100 else "") + + metadata = { + "reply_id": reply_id, + "parent_comment_id": parent_comment_id, + "message_id": message_id, + "thread_id": thread_id, + "thread_title": thread_title, + "author_id": author_id, + "author_name": author_name, + "author_avatar_url": author_avatar_url, + "author_email": author_email, + "content_preview": content_preview[:200], + } + + try: + notification = Notification( + user_id=user_id, + search_space_id=search_space_id, + type=self.notification_type, + title=title, + message=message, + notification_metadata=metadata, + ) + session.add(notification) + await session.commit() + await session.refresh(notification) + logger.info( + f"Created comment_reply notification {notification.id} for user {user_id}" + ) + return notification + except Exception as e: + await session.rollback() + if ( + "duplicate key" in str(e).lower() + or "unique constraint" in str(e).lower() + ): + logger.warning( + f"Duplicate notification for reply {reply_id} to user {user_id}" + ) + existing = await self.find_notification_by_reply( + session, reply_id, user_id + ) + if existing: + return existing + raise + + class PageLimitNotificationHandler(BaseNotificationHandler): """Handler for page limit exceeded notifications.""" @@ -959,6 +1051,7 @@ class NotificationService: connector_indexing = ConnectorIndexingNotificationHandler() document_processing = DocumentProcessingNotificationHandler() mention = MentionNotificationHandler() + comment_reply = CommentReplyNotificationHandler() page_limit = PageLimitNotificationHandler() @staticmethod diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 2125dd8ce..4da316240 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -366,11 +366,14 @@ async def list_snapshots_for_thread( 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 can view snapshots", - ) + # Check permission to view public share links + await check_permission( + session, + user, + thread.search_space_id, + Permission.PUBLIC_SHARING_VIEW.value, + "You don't have permission to view public share links", + ) result = await session.execute( select(PublicChatSnapshot) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 688777203..a9751e5d1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -32,6 +32,7 @@ from app.services.chat_session_state_service import ( clear_ai_responding, set_ai_responding, ) +from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.services.connector_service import ConnectorService from app.services.new_streaming_service import VercelStreamingService from app.utils.content_utils import bootstrap_history_from_db @@ -1208,6 +1209,59 @@ async def stream_new_chat( if completion_event: yield completion_event + # Generate LLM title for new chats after first response + # Check if this is the first assistant response by counting existing assistant messages + from app.db import NewChatMessage, NewChatThread + from sqlalchemy import func + + assistant_count_result = await session.execute( + select(func.count(NewChatMessage.id)).filter( + NewChatMessage.thread_id == chat_id, + NewChatMessage.role == "assistant", + ) + ) + assistant_message_count = assistant_count_result.scalar() or 0 + + # Only generate title on the first response (no prior assistant messages) + if assistant_message_count == 0: + generated_title = None + try: + # Generate title using the same LLM + title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm + # Truncate inputs to avoid context length issues + truncated_query = user_query[:500] + truncated_response = accumulated_text[:1000] + title_result = await title_chain.ainvoke({ + "user_query": truncated_query, + "assistant_response": truncated_response, + }) + + # Extract and clean the title + if title_result and hasattr(title_result, "content"): + raw_title = title_result.content.strip() + # Validate the title (reasonable length) + if raw_title and len(raw_title) <= 100: + # Remove any quotes or extra formatting + generated_title = raw_title.strip('"\'') + except Exception: + generated_title = None + + # Only update if LLM succeeded (keep truncated prompt title as fallback) + if generated_title: + # Fetch thread and update title + thread_result = await session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + thread = thread_result.scalars().first() + if thread: + thread.title = generated_title + await session.commit() + + # Notify frontend of the title update + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) + # Finish the step and message yield streaming_service.format_finish_step() yield streaming_service.format_finish() diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 4be2fe525..696cdf25e 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -23,17 +23,20 @@ from app.db import ( get_default_roles_config, get_user_db, ) +from app.utils.refresh_tokens import create_refresh_token logger = logging.getLogger(__name__) class BearerResponse(BaseModel): access_token: str + refresh_token: str token_type: str SECRET = config.SECRET_KEY + if config.AUTH_TYPE == "GOOGLE": from httpx_oauth.clients.google import GoogleOAuth2 @@ -183,7 +186,10 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: - return JWTStrategy(secret=SECRET, lifetime_seconds=3600 * 24) + return JWTStrategy( + secret=SECRET, + lifetime_seconds=config.ACCESS_TOKEN_LIFETIME_SECONDS, + ) # # COOKIE AUTH | Uncomment if you want to use cookie auth. @@ -209,9 +215,30 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]: # BEARER AUTH CODE. class CustomBearerTransport(BearerTransport): async def get_login_response(self, token: str) -> Response: - bearer_response = BearerResponse(access_token=token, token_type="bearer") - redirect_url = f"{config.NEXT_FRONTEND_URL}/auth/callback?token={bearer_response.access_token}" + import jwt + + # Decode JWT to get user_id for refresh token creation + try: + payload = jwt.decode(token, SECRET, algorithms=["HS256"], options={"verify_aud": False}) + user_id = uuid.UUID(payload.get("sub")) + refresh_token = await create_refresh_token(user_id) + except Exception as e: + logger.error(f"Failed to create refresh token: {e}") + # Fall back to response without refresh token + refresh_token = "" + + bearer_response = BearerResponse( + access_token=token, + refresh_token=refresh_token, + token_type="bearer", + ) + if config.AUTH_TYPE == "GOOGLE": + redirect_url = ( + f"{config.NEXT_FRONTEND_URL}/auth/callback" + f"?token={bearer_response.access_token}" + f"&refresh_token={bearer_response.refresh_token}" + ) return RedirectResponse(redirect_url, status_code=302) else: return JSONResponse(bearer_response.model_dump()) diff --git a/surfsense_backend/app/utils/refresh_tokens.py b/surfsense_backend/app/utils/refresh_tokens.py new file mode 100644 index 000000000..8c0312ba8 --- /dev/null +++ b/surfsense_backend/app/utils/refresh_tokens.py @@ -0,0 +1,153 @@ +"""Utilities for managing refresh tokens.""" + +import hashlib +import logging +import secrets +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, update + +from app.config import config +from app.db import RefreshToken, async_session_maker + +logger = logging.getLogger(__name__) + + +def generate_refresh_token() -> str: + """Generate a cryptographically secure refresh token.""" + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + """Hash a token for secure storage.""" + return hashlib.sha256(token.encode()).hexdigest() + + +async def create_refresh_token( + user_id: uuid.UUID, + family_id: uuid.UUID | None = None, +) -> str: + """ + Create and store a new refresh token for a user. + + Args: + user_id: The user's ID + family_id: Optional family ID for token rotation + + Returns: + The plaintext refresh token + """ + token = generate_refresh_token() + token_hash = hash_token(token) + expires_at = datetime.now(UTC) + timedelta( + seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS + ) + + if family_id is None: + family_id = uuid.uuid4() + + async with async_session_maker() as session: + refresh_token = RefreshToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + family_id=family_id, + ) + session.add(refresh_token) + await session.commit() + + return token + + +async def validate_refresh_token(token: str) -> RefreshToken | None: + """ + Validate a refresh token. Handles reuse detection. + + Args: + token: The plaintext refresh token + + Returns: + RefreshToken if valid, None otherwise + """ + token_hash = hash_token(token) + + async with async_session_maker() as session: + result = await session.execute( + select(RefreshToken).where(RefreshToken.token_hash == token_hash) + ) + refresh_token = result.scalars().first() + + if not refresh_token: + return None + + # Reuse detection: revoked token used while family has active tokens + if refresh_token.is_revoked: + active = await session.execute( + select(RefreshToken).where( + RefreshToken.family_id == refresh_token.family_id, + RefreshToken.is_revoked == False, # noqa: E712 + RefreshToken.expires_at > datetime.now(UTC), + ) + ) + if active.scalars().first(): + # Revoke entire family + await session.execute( + update(RefreshToken) + .where(RefreshToken.family_id == refresh_token.family_id) + .values(is_revoked=True) + ) + await session.commit() + logger.warning(f"Token reuse detected for user {refresh_token.user_id}") + return None + + if refresh_token.is_expired: + return None + + return refresh_token + + +async def rotate_refresh_token(old_token: RefreshToken) -> str: + """Revoke old token and create new one in same family.""" + async with async_session_maker() as session: + await session.execute( + update(RefreshToken) + .where(RefreshToken.id == old_token.id) + .values(is_revoked=True) + ) + await session.commit() + + return await create_refresh_token(old_token.user_id, old_token.family_id) + + +async def revoke_refresh_token(token: str) -> bool: + """ + Revoke a single refresh token by its plaintext value. + + Args: + token: The plaintext refresh token + + Returns: + True if token was found and revoked, False otherwise + """ + token_hash = hash_token(token) + + async with async_session_maker() as session: + result = await session.execute( + update(RefreshToken) + .where(RefreshToken.token_hash == token_hash) + .values(is_revoked=True) + ) + await session.commit() + return result.rowcount > 0 + + +async def revoke_all_user_tokens(user_id: uuid.UUID) -> None: + """Revoke all refresh tokens for a user (logout all devices).""" + async with async_session_maker() as session: + await session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id) + .values(is_revoked=True) + ) + await session.commit() 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 9b462fcbc..1a535539d 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 @@ -437,7 +437,10 @@ export default function NewChatPage() { let isNewThread = false; if (!currentThreadId) { try { - const newThread = await createThread(searchSpaceId, "New Chat"); + // Create thread with truncated prompt as initial title + const initialTitle = + userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : ""); + const newThread = await createThread(searchSpaceId, initialTitle); currentThreadId = newThread.id; setThreadId(currentThreadId); // Set currentThread so ChatHeader can show share button immediately @@ -827,6 +830,26 @@ export default function NewChatPage() { break; } + case "data-thread-title-update": { + // Handle thread title update from LLM-generated title + const titleData = parsed.data as { threadId: number; title: string }; + if (titleData?.title && titleData?.threadId === currentThreadId) { + // Update current thread state with new title + setCurrentThread((prev) => + prev ? { ...prev, title: titleData.title } : prev + ); + // Invalidate thread list to refresh sidebar + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId)], + }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ + queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)], + }); + } + break; + } + case "error": throw new Error(parsed.errorText || "Server error"); } diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx index b7040b4e3..3424113a9 100644 --- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx +++ b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { APP_VERSION } from "@/lib/env-config"; import { cn } from "@/lib/utils"; export interface SettingsNavItem { @@ -148,6 +149,11 @@ export function UserSettingsSidebar({ ); })} + + {/* Version display */} +
+

v{APP_VERSION}

+
); diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index e3295df7c..230cda81a 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -3,7 +3,7 @@ import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; -import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils"; +import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; import { trackLoginSuccess } from "@/lib/posthog/events"; interface TokenHandlerProps { @@ -35,8 +35,9 @@ const TokenHandler = ({ // Only run on client-side if (typeof window === "undefined") return; - // Get token from URL parameters + // Get tokens from URL parameters const token = searchParams.get(tokenParamName); + const refreshToken = searchParams.get("refresh_token"); if (token) { try { @@ -50,10 +51,15 @@ const TokenHandler = ({ // Clear the flag for future logins sessionStorage.removeItem("login_success_tracked"); - // Store token in localStorage using both methods for compatibility + // Store access token in localStorage using both methods for compatibility localStorage.setItem(storageKey, token); setBearerToken(token); + // Store refresh token if provided + if (refreshToken) { + setRefreshToken(refreshToken); + } + // Check if there's a saved redirect path from before the auth flow const savedRedirectPath = getAndClearRedirectPath(); diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx index 3dac745cf..233a41a1f 100644 --- a/surfsense_web/components/UserDropdown.tsx +++ b/surfsense_web/components/UserDropdown.tsx @@ -1,7 +1,8 @@ "use client"; -import { BadgeCheck, LogOut } from "lucide-react"; +import { BadgeCheck, Loader2, LogOut } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -13,6 +14,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { logout } from "@/lib/auth-utils"; import { cleanupElectric } from "@/lib/electric/client"; import { resetUser, trackLogout } from "@/lib/posthog/events"; @@ -26,8 +28,11 @@ export function UserDropdown({ }; }) { const router = useRouter(); + const [isLoggingOut, setIsLoggingOut] = useState(false); const handleLogout = async () => { + if (isLoggingOut) return; + setIsLoggingOut(true); try { // Track logout event and reset PostHog identity trackLogout(); @@ -41,15 +46,17 @@ export function UserDropdown({ console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err); } + // Revoke refresh token on server and clear all tokens from localStorage + await logout(); + if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); window.location.href = "/"; } } catch (error) { console.error("Error during logout:", error); - // Optionally, provide user feedback + // Even if there's an error, try to clear tokens and redirect + await logout(); if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); window.location.href = "/"; } } @@ -85,9 +92,17 @@ export function UserDropdown({ - - - Log out + + {isLoggingOut ? ( + + ) : ( + + )} + {isLoggingOut ? "Logging out..." : "Log out"} diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 4fd2446c3..5cdd287de 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -4,20 +4,19 @@ import { ErrorPrimitive, MessagePrimitive, useAssistantState, + useMessage, } from "@assistant-ui/react"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { addingCommentToMessageIdAtom, - clearTargetCommentIdAtom, commentsCollapsedAtom, commentsEnabledAtom, targetCommentIdAtom, } from "@/atoms/chat/current-thread.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ThinkingStepsContext, @@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
-
@@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => { // Target comment navigation - read target from global atom const targetCommentId = useAtomValue(targetCommentIdAtom); - const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); // Check if target comment belongs to this message (including replies) const hasTargetComment = useMemo(() => { @@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => { }; const AssistantActionBar: FC = () => { + const { isLast } = useMessage(); + return ( { - - - - - + {/* Only allow regenerating the last assistant message */} + {isLast && ( + + + + + + )} ); }; diff --git a/surfsense_web/components/assistant-ui/branch-picker.tsx b/surfsense_web/components/assistant-ui/branch-picker.tsx deleted file mode 100644 index ee4addd2a..000000000 --- a/surfsense_web/components/assistant-ui/branch-picker.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { BranchPickerPrimitive } from "@assistant-ui/react"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import type { FC } from "react"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { cn } from "@/lib/utils"; - -export const BranchPicker: FC = ({ className, ...rest }) => { - return ( - - - - - - - - / - - - - - - - - ); -}; diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 896b8c748..1ae8aef3c 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -4,7 +4,6 @@ import { FileText, PencilIcon } from "lucide-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 { @@ -95,24 +94,47 @@ export const UserMessage: FC = () => { )} - - ); }; const UserActionBar: FC = () => { + const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); + + // Get current message ID + const currentMessageId = useAssistantState(({ message }) => message?.id); + + // Find the last user message ID in the thread (computed once, memoized by selector) + const lastUserMessageId = useAssistantState(({ thread }) => { + const messages = thread.messages; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + return messages[i].id; + } + } + return null; + }); + + // Simple comparison - no iteration needed per message + const isLastUserMessage = currentMessageId === lastUserMessageId; + + // Show edit button only on the last user message and when thread is not running + const canEdit = isLastUserMessage && !isThreadRunning; + return ( - - - - - + {/* Only allow editing the last user message */} + {canEdit && ( + + + + + + )} ); }; diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 96bd0ef30..5c6399ce0 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/breadcrumb"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; +import { getThreadFull } from "@/lib/chat/thread-persistence"; import { cacheKeys } from "@/lib/query-client/cache-keys"; interface BreadcrumbItemInterface { @@ -34,6 +35,16 @@ export function DashboardBreadcrumb() { enabled: !!searchSpaceId, }); + // Extract chat thread ID from pathname for chat pages + const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null; + + // Fetch thread details when on a chat page with a thread ID + const { data: threadData } = useQuery({ + queryKey: ["threads", searchSpaceId, "detail", chatThreadId], + queryFn: () => getThreadFull(Number(chatThreadId)), + enabled: !!chatThreadId && !!searchSpaceId, + }); + // State to store document title for editor breadcrumb const [documentTitle, setDocumentTitle] = useState(null); @@ -144,10 +155,11 @@ export function DashboardBreadcrumb() { } // Handle new-chat sub-sections (thread IDs) - // Don't show thread ID in breadcrumb - users identify chats by content, not by ID + // Show the chat title if available, otherwise fall back to "Chat" if (section === "new-chat") { + const chatLabel = threadData?.title || t("chat") || "Chat"; breadcrumbs.push({ - label: t("chat") || "Chat", + label: chatLabel, }); return breadcrumbs; } diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index b87cc4883..66d2f419a 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react"; +import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -21,10 +21,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { useInbox } from "@/hooks/use-inbox"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence"; +import { logout } from "@/lib/auth-utils"; import { cleanupElectric } from "@/lib/electric/client"; import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -109,7 +111,6 @@ export function LayoutDataProvider({ // This ensures each tab has independent pagination and data loading const userId = user?.id ? String(user.id) : null; - // Mentions: Only fetch "new_mention" type notifications const { inboxItems: mentionItems, unreadCount: mentionUnreadCount, @@ -121,11 +122,9 @@ export function LayoutDataProvider({ markAllAsRead: markAllMentionsAsRead, } = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); - // Status: Fetch all types (will be filtered client-side to status types) - // We pass null to get all, then InboxSidebar filters to status types const { inboxItems: statusItems, - unreadCount: statusUnreadCount, + unreadCount: allUnreadCount, loading: statusLoading, loadingMore: statusLoadingMore, hasMore: statusHasMore, @@ -134,8 +133,8 @@ export function LayoutDataProvider({ markAllAsRead: markAllStatusAsRead, } = useInbox(userId, Number(searchSpaceId) || null, null); - // Combined unread count for nav badge (mentions take priority for visibility) - const totalUnreadCount = mentionUnreadCount + statusUnreadCount; + const totalUnreadCount = allUnreadCount; + const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -207,6 +206,12 @@ export function LayoutDataProvider({ const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeletingChat, setIsDeletingChat] = useState(false); + // Rename dialog state + const [showRenameChatDialog, setShowRenameChatDialog] = useState(false); + const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null); + const [newChatTitle, setNewChatTitle] = useState(""); + const [isRenamingChat, setIsRenamingChat] = useState(false); + // Delete/Leave search space dialog state const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false); const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false); @@ -421,6 +426,12 @@ export function LayoutDataProvider({ setShowDeleteChatDialog(true); }, []); + const handleChatRename = useCallback((chat: ChatItem) => { + setChatToRename({ id: chat.id, name: chat.name }); + setNewChatTitle(chat.name); + setShowRenameChatDialog(true); + }, []); + const handleChatArchive = useCallback( async (chat: ChatItem) => { const newArchivedState = !chat.archived; @@ -464,12 +475,15 @@ export function LayoutDataProvider({ console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err); } + // Revoke refresh token on server and clear all tokens from localStorage + await logout(); + if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); router.push("/"); } } catch (error) { console.error("Error during logout:", error); + await logout(); router.push("/"); } }, [router]); @@ -501,6 +515,29 @@ export function LayoutDataProvider({ } }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); + // Rename handler + const confirmRenameChat = useCallback(async () => { + if (!chatToRename || !newChatTitle.trim()) return; + setIsRenamingChat(true); + try { + await updateThread(chatToRename.id, { title: newChatTitle.trim() }); + toast.success(tSidebar("chat_renamed") || "Chat renamed"); + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); + // Invalidate thread detail for breadcrumb update + queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] }); + } catch (error) { + console.error("Error renaming thread:", error); + toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); + } finally { + setIsRenamingChat(false); + setShowRenameChatDialog(false); + setChatToRename(null); + setNewChatTitle(""); + } + }, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]); + // Page usage const pageUsage = user ? { @@ -529,6 +566,7 @@ export function LayoutDataProvider({ activeChatId={currentChatId} onNewChat={handleNewChat} onChatSelect={handleChatSelect} + onChatRename={handleChatRename} onChatDelete={handleChatDelete} onChatArchive={handleChatArchive} onViewAllSharedChats={handleViewAllSharedChats} @@ -562,7 +600,7 @@ export function LayoutDataProvider({ }, status: { items: statusItems, - unreadCount: statusUnreadCount, + unreadCount: statusOnlyUnreadCount, loading: statusLoading, loadingMore: statusLoadingMore, hasMore: statusHasMore, @@ -621,6 +659,57 @@ export function LayoutDataProvider({ + {/* Rename Chat Dialog */} + + + + + + {tSidebar("rename_chat") || "Rename Chat"} + + + {tSidebar("rename_chat_description") || "Enter a new name for this conversation."} + + + setNewChatTitle(e.target.value)} + placeholder={tSidebar("chat_title_placeholder") || "Chat title"} + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) { + confirmRenameChat(); + } + }} + /> + + + + + + + {/* Delete Search Space Dialog */} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index a33149669..3a8255e7a 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -54,6 +54,7 @@ interface LayoutShellProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -91,6 +92,7 @@ export function LayoutShell({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -149,6 +151,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} @@ -218,6 +221,7 @@ export function LayoutShell({ activeChatId={activeChatId} onNewChat={onNewChat} onChatSelect={onChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 6db6782d0..ba2989145 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react"; +import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { @@ -17,6 +17,7 @@ interface ChatListItemProps { isActive?: boolean; archived?: boolean; onClick?: () => void; + onRename?: () => void; onArchive?: () => void; onDelete?: () => void; } @@ -26,6 +27,7 @@ export function ChatListItem({ isActive, archived, onClick, + onRename, onArchive, onDelete, }: ChatListItemProps) { @@ -57,15 +59,26 @@ export function ChatListItem({ {t("more_options")} - - {onArchive && ( - { - e.stopPropagation(); - onArchive(); - }} - > - {archived ? ( + + {onRename && ( + { + e.stopPropagation(); + onRename(); + }} + > + + {t("rename") || "Rename"} + + )} + {onArchive && ( + { + e.stopPropagation(); + onArchive(); + }} + > + {archived ? ( <> {t("unarchive") || "Restore"} diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 9ef49c0d8..f313dd6f9 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -4,7 +4,6 @@ import { useAtom } from "jotai"; import { AlertCircle, AlertTriangle, - AtSign, BellDot, Check, CheckCheck, @@ -15,6 +14,7 @@ import { Inbox, LayoutGrid, ListFilter, + MessageSquare, Search, X, } from "lucide-react"; @@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { + isCommentReplyMetadata, isConnectorIndexingMetadata, isNewMentionMetadata, isPageLimitExceededMetadata, @@ -133,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string { ); } -type InboxTab = "mentions" | "status"; +type InboxTab = "comments" | "status"; type InboxFilter = "all" | "unread"; // Tab-specific data source with independent pagination @@ -186,7 +187,7 @@ export function InboxSidebar({ const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); - const [activeTab, setActiveTab] = useState("mentions"); + const [activeTab, setActiveTab] = useState("comments"); const [activeFilter, setActiveFilter] = useState("all"); const [selectedConnector, setSelectedConnector] = useState(null); const [mounted, setMounted] = useState(false); @@ -233,12 +234,17 @@ export function InboxSidebar({ } }, [activeTab]); - // Get current tab's data source - each tab has independent data and pagination - const currentDataSource = activeTab === "mentions" ? mentions : status; - const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource; + // Both tabs now derive items from status (all types), so use status for pagination + const { loading, loadingMore = false, hasMore = false, loadMore } = status; - // Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion - // Filter to only show status notification types + // Comments tab: mentions and comment replies + const commentsItems = useMemo( + () => + status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"), + [status.items] + ); + + // Status tab: connector indexing, document processing, page limit exceeded, connector deletion const statusItems = useMemo( () => status.items.filter( @@ -270,8 +276,8 @@ export function InboxSidebar({ })); }, [statusItems]); - // Get items for current tab - mentions use their source directly, status uses filtered items - const displayItems = activeTab === "mentions" ? mentions.items : statusItems; + // Get items for current tab + const displayItems = activeTab === "comments" ? commentsItems : statusItems; // Filter items based on filter type, connector filter, and search query const filteredItems = useMemo(() => { @@ -334,9 +340,15 @@ export function InboxSidebar({ return () => observer.disconnect(); }, [loadMore, hasMore, loadingMore, open, searchQuery]); - // Use unread counts from data sources (more accurate than client-side counting) - const unreadMentionsCount = mentions.unreadCount; - const unreadStatusCount = status.unreadCount; + // Unread counts derived from filtered items + const unreadCommentsCount = useMemo( + () => commentsItems.filter((item) => !item.read).length, + [commentsItems] + ); + const unreadStatusCount = useMemo( + () => statusItems.filter((item) => !item.read).length, + [statusItems] + ); const handleItemClick = useCallback( async (item: InboxItem) => { @@ -347,19 +359,15 @@ export function InboxSidebar({ } if (item.type === "new_mention") { - // Use type guard for safe metadata access if (isNewMentionMetadata(item.metadata)) { const searchSpaceId = item.search_space_id; const threadId = item.metadata.thread_id; const commentId = item.metadata.comment_id; if (searchSpaceId && threadId) { - // Pre-set target comment ID before navigation - // This also ensures comments panel is not collapsed if (commentId) { setTargetCommentId(commentId); } - const url = commentId ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; @@ -368,6 +376,24 @@ export function InboxSidebar({ router.push(url); } } + } else if (item.type === "comment_reply") { + if (isCommentReplyMetadata(item.metadata)) { + const searchSpaceId = item.search_space_id; + const threadId = item.metadata.thread_id; + const replyId = item.metadata.reply_id; + + if (searchSpaceId && threadId) { + if (replyId) { + setTargetCommentId(replyId); + } + const url = replyId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}` + : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } + } } else if (item.type === "page_limit_exceeded") { // Navigate to the upgrade/more-pages page if (isPageLimitExceededMetadata(item.metadata)) { @@ -411,24 +437,29 @@ export function InboxSidebar({ }; const getStatusIcon = (item: InboxItem) => { - // For mentions, show the author's avatar with initials fallback - if (item.type === "new_mention") { - // Use type guard for safe metadata access - if (isNewMentionMetadata(item.metadata)) { - const authorName = item.metadata.author_name; - const avatarUrl = item.metadata.author_avatar_url; - const authorEmail = item.metadata.author_email; + // For mentions and comment replies, show the author's avatar + if (item.type === "new_mention" || item.type === "comment_reply") { + const metadata = + item.type === "new_mention" + ? isNewMentionMetadata(item.metadata) + ? item.metadata + : null + : isCommentReplyMetadata(item.metadata) + ? item.metadata + : null; + if (metadata) { return ( - {avatarUrl && } + {metadata.author_avatar_url && ( + + )} - {getInitials(authorName, authorEmail)} + {getInitials(metadata.author_name, metadata.author_email)} ); } - // Fallback for invalid metadata return ( @@ -481,10 +512,10 @@ export function InboxSidebar({ }; const getEmptyStateMessage = () => { - if (activeTab === "mentions") { + if (activeTab === "comments") { return { - title: t("no_mentions") || "No mentions", - hint: t("no_mentions_hint") || "You'll see mentions from others here", + title: t("no_comments") || "No comments", + hint: t("no_comments_hint") || "You'll see mentions and replies here", }; } return { @@ -823,14 +854,14 @@ export function InboxSidebar({ > - - {t("mentions") || "Mentions"} + + {t("comments") || "Comments"} - {formatInboxCount(unreadMentionsCount)} + {formatInboxCount(unreadCommentsCount)} @@ -932,8 +963,8 @@ export function InboxSidebar({ ) : (
- {activeTab === "mentions" ? ( - + {activeTab === "comments" ? ( + ) : ( )} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 3e9d624c9..71d85f600 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -24,6 +24,7 @@ interface MobileSidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -65,6 +66,7 @@ export function MobileSidebar({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -144,6 +146,7 @@ export function MobileSidebar({ onOpenChange(false); }} onChatSelect={handleChatSelect} + onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} onViewAllSharedChats={onViewAllSharedChats} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index fb29448c5..7b53fdc6a 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -35,6 +35,7 @@ interface SidebarProps { activeChatId?: number | null; onNewChat: () => void; onChatSelect: (chat: ChatItem) => void; + onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; onViewAllSharedChats?: () => void; @@ -62,6 +63,7 @@ export function Sidebar({ activeChatId, onNewChat, onChatSelect, + onChatRename, onChatDelete, onChatArchive, onViewAllSharedChats, @@ -183,6 +185,7 @@ export function Sidebar({ isActive={chat.id === activeChatId} archived={chat.archived} onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} onDelete={() => onChatDelete?.(chat)} /> @@ -243,6 +246,7 @@ export function Sidebar({ isActive={chat.id === activeChatId} archived={chat.archived} onClick={() => onChatSelect(chat)} + onRename={() => onChatRename?.(chat)} onArchive={() => onChatArchive?.(chat)} onDelete={() => onChatDelete?.(chat)} /> diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx index 7c96b1dcb..38b3028d2 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -1,7 +1,8 @@ "use client"; -import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react"; +import { Check, ChevronUp, Languages, Laptop, Loader2, LogOut, Moon, Settings, Sun } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -124,6 +125,7 @@ export function SidebarUserProfile({ }: SidebarUserProfileProps) { const t = useTranslations("sidebar"); const { locale, setLocale } = useLocaleContext(); + const [isLoggingOut, setIsLoggingOut] = useState(false); const bgColor = stringToColor(user.email); const initials = getInitials(user.email); const displayName = user.name || user.email.split("@")[0]; @@ -136,6 +138,16 @@ export function SidebarUserProfile({ setTheme?.(newTheme); }; + const handleLogout = async () => { + if (isLoggingOut || !onLogout) return; + setIsLoggingOut(true); + try { + await onLogout(); + } finally { + setIsLoggingOut(false); + } + }; + // Collapsed view - just show avatar with dropdown if (isCollapsed) { return ( @@ -242,9 +254,13 @@ export function SidebarUserProfile({ - - - {t("logout")} + + {isLoggingOut ? ( + + ) : ( + + )} + {isLoggingOut ? t("loggingOut") : t("logout")} @@ -360,9 +376,13 @@ export function SidebarUserProfile({ - - - {t("logout")} + + {isLoggingOut ? ( + + ) : ( + + )} + {isLoggingOut ? t("loggingOut") : t("logout")} diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index fa05f44c1..2e04fa3ba 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -1,8 +1,9 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; import { Globe, User, Users } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; @@ -11,6 +12,7 @@ import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapsh import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; import { type ChatVisibility, type ThreadRecord, @@ -46,6 +48,8 @@ const visibilityOptions: { export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { const queryClient = useQueryClient(); + const router = useRouter(); + const params = useParams(); const [open, setOpen] = useState(false); // Use Jotai atom for visibility (single source of truth) @@ -65,6 +69,16 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS return access.permissions?.includes("public_sharing:create") ?? false; }, [access]); + // Query to check if thread has public snapshots + const { data: snapshotsData } = useQuery({ + queryKey: ["thread-snapshots", thread?.id], + queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }), + enabled: !!thread?.id, + staleTime: 30000, // Cache for 30 seconds + }); + const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0; + const snapshotCount = snapshotsData?.snapshots?.length ?? 0; + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; @@ -106,11 +120,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS try { await createSnapshot({ thread_id: thread.id }); + // Refetch snapshots to show the globe indicator + await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] }); setOpen(false); } catch (error) { console.error("Failed to create public link:", error); } - }, [thread, createSnapshot]); + }, [thread, createSnapshot, queryClient]); // Don't show if no thread (new chat that hasn't been created yet) if (!thread) { @@ -121,112 +137,131 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared"; return ( - - - - - - - - Share settings - - - e.preventDefault()} - > -
- {/* Visibility Options */} - {visibilityOptions.map((option) => { - const isSelected = currentVisibility === option.value; - const Icon = option.icon; - - return ( - + + + Share settings + + + e.preventDefault()} + > +
+ {/* Visibility Options */} + {visibilityOptions.map((option) => { + const isSelected = currentVisibility === option.value; + const Icon = option.icon; + + return ( +
-
-
- - {option.label} - + > +
-

- {option.description} -

-
- - ); - })} - - {canCreatePublicLink && ( - <> - {/* Divider */} -
- - {/* Public Link Option */} - - - )} -
-
- + + ); + })} + + {canCreatePublicLink && ( + <> + {/* Divider */} +
+ + {/* Public Link Option */} + + + )} +
+ + + + {/* Globe indicator when public snapshots exist - clicks to settings */} + {hasPublicSnapshots && ( + + + + + + {snapshotCount === 1 + ? "This chat has a public link" + : `This chat has ${snapshotCount} public links`} + + + )} +
); } diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx index 39757e91e..9d05e0262 100644 --- a/surfsense_web/components/pricing.tsx +++ b/surfsense_web/components/pricing.tsx @@ -17,6 +17,7 @@ interface PricingPlan { price: string; yearlyPrice: string; period: string; + billingText?: string; features: string[]; description: string; buttonText: string; @@ -35,7 +36,7 @@ export function Pricing({ title = "Simple, Transparent Pricing", description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.", }: PricingProps) { - const [isMonthly, setIsMonthly] = useState(true); + const [isMonthly, setIsMonthly] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); const switchRef = useRef(null); @@ -183,7 +184,7 @@ export function Pricing({

- {isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"} + {plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}

    diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index fdad0796a..117be15ec 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -4,44 +4,47 @@ import { Pricing } from "@/components/pricing"; const demoPlans = [ { - name: "COMMUNITY", + name: "FREE", price: "0", yearlyPrice: "0", - period: "forever", + period: "", + billingText: "Includes 30 day PRO trial", features: [ - "Community support", - "Supports 100+ LLMs", - "Supports OpenAI spec and LiteLLM", - "Supports local vLLM or Ollama setups", - "6000+ embedding models", + "Open source on GitHub", + "Upload and chat with 300+ pages of content", + "Connects with 8 popular sources, like Drive and Notion.", + "Includes limited access to ChatGPT, Claude, and DeepSeek models", + "Supports 100+ more LLMs, including Gemini, Llama and many more.", "50+ File extensions supported.", - "Podcasts support with local TTS providers.", - "Connects with 15+ external sources, like Drive and Notion.", + "Generate podcasts in seconds.", "Cross-Browser Extension for dynamic webpages including authenticated content", - "Role-based access control (RBAC)", - "Collaboration and team features", + "Community support on Discord", ], - description: "Open source version with powerful features", - buttonText: "Dive In", - href: "/docs", + description: "Powerful features with some limitations", + buttonText: "Get Started", + href: "/", isPopular: false, }, { - name: "CLOUD", - price: "0", - yearlyPrice: "0", - period: "in beta", + name: "PRO", + price: "10", + yearlyPrice: "10", + period: "user / month", + billingText: "billed annually", features: [ - "Everything in Community", - "Email support", - "Get started in seconds", - "Instant access to new features", - "Easy access from anywhere", - "Remote team management and collaboration", + "Everything in Free", + "Upload and chat with 5,000+ pages of content", + "Connects with 15+ external sources, like Slack and Airtable.", + "Includes extended access to ChatGPT, Claude, and DeepSeek models", + "Collaboration and commenting features", + "Shared BYOK (Bring Your Own Key)", + "Team and role management", + "Planned: Centralized billing", + "Priority support", ], - description: "Instant access for individuals and teams", - buttonText: "Get Started", - href: "/", + description: "The AIknowledge base for individuals and teams", + buttonText: "Upgrade", + href: "/contact", isPopular: true, }, { @@ -49,18 +52,21 @@ const demoPlans = [ price: "Contact Us", yearlyPrice: "Contact Us", period: "", + billingText: "", features: [ - "Everything in Community", - "Priority support", + "Everything in Pro", + "Connect and chat with virtually unlimited pages of content", + "Limit models and/or providers", + "On-prem or VPC deployment", + "Planned: Audit logs and compliance", + "Planned: SSO, OIDC & SAML", + "Planned: Role-based access control (RBAC)", "White-glove setup and deployment", "Monthly managed updates and maintenance", - "On-prem or VPC deployment", - "Audit logs and compliance", - "SSO, OIDC & SAML", - "SLA guarantee", - "Uptime guarantee on VPC", + "SLA commitments", + "Dedicated support", ], - description: "Professional, customized setup for large organizations", + description: "Customized setup for large organizations", buttonText: "Contact Sales", href: "/contact", isPopular: false, 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 index 696d32466..5f0048100 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx @@ -38,6 +38,13 @@ export function PublicChatSnapshotRow({ {snapshot.message_count} + (e.target as HTMLInputElement).select()} + />