Merge pull request #689 from MODSetter/dev

feat: Complete UI overhaul with docs search capabilities and MS Teams support
This commit is contained in:
Rohan Verma 2026-01-12 14:19:12 -08:00 committed by GitHub
commit 764dd05582
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 6960 additions and 2539 deletions

View file

@ -165,6 +165,8 @@ COPY --from=frontend-builder /app/.next/standalone ./
COPY --from=frontend-builder /app/.next/static ./.next/static
COPY --from=frontend-builder /app/public ./public
COPY surfsense_web/content/docs /app/surfsense_web/content/docs
# ====================
# Setup Backend
# ====================

View file

@ -76,6 +76,11 @@ SLACK_CLIENT_ID=your_slack_client_id_here
SLACK_CLIENT_SECRET=your_slack_client_secret_here
SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback
# Teams OAuth Configuration
TEAMS_CLIENT_ID=your_teams_client_id_here
TEAMS_CLIENT_SECRET=your_teams_client_secret_here
TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback
# Embedding Model
# Examples:
# # Get sentence transformers embeddings

View file

@ -0,0 +1,160 @@
"""Add TEAMS_CONNECTOR to SearchSourceConnectorType and DocumentType enums
Revision ID: 59
Revises: 58
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "59"
down_revision: str | None = "58"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Define the ENUM type name and the new value
CONNECTOR_ENUM = "searchsourceconnectortype"
CONNECTOR_NEW_VALUE = "TEAMS_CONNECTOR"
DOCUMENT_ENUM = "documenttype"
DOCUMENT_NEW_VALUE = "TEAMS_CONNECTOR"
def upgrade() -> None:
"""Upgrade schema - add TEAMS_CONNECTOR to connector and document enum safely."""
# Add TEAMS_CONNECTOR to searchsourceconnectortype only if not exists
op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{CONNECTOR_NEW_VALUE}'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{CONNECTOR_ENUM}')
) THEN
ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}';
END IF;
END$$;
"""
)
# Add TEAMS_CONNECTOR to documenttype only if not exists
op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{DOCUMENT_NEW_VALUE}'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{DOCUMENT_ENUM}')
) THEN
ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}';
END IF;
END$$;
"""
)
def downgrade() -> None:
"""Downgrade schema - remove TEAMS_CONNECTOR from connector and document enum."""
# Old enum name
old_connector_enum_name = f"{CONNECTOR_ENUM}_old"
old_document_enum_name = f"{DOCUMENT_ENUM}_old"
# All connector values except TEAMS_CONNECTOR
old_connector_values = (
"SERPER_API",
"TAVILY_API",
"SEARXNG_API",
"LINKUP_API",
"BAIDU_SEARCH_API",
"SLACK_CONNECTOR",
"NOTION_CONNECTOR",
"GITHUB_CONNECTOR",
"LINEAR_CONNECTOR",
"DISCORD_CONNECTOR",
"JIRA_CONNECTOR",
"CONFLUENCE_CONNECTOR",
"CLICKUP_CONNECTOR",
"GOOGLE_CALENDAR_CONNECTOR",
"GOOGLE_GMAIL_CONNECTOR",
"GOOGLE_DRIVE_CONNECTOR",
"AIRTABLE_CONNECTOR",
"LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR",
"WEBCRAWLER_CONNECTOR",
)
# All document values except TEAMS_CONNECTOR
old_document_values = (
"EXTENSION",
"CRAWLED_URL",
"FILE",
"SLACK_CONNECTOR",
"NOTION_CONNECTOR",
"YOUTUBE_VIDEO",
"GITHUB_CONNECTOR",
"LINEAR_CONNECTOR",
"DISCORD_CONNECTOR",
"JIRA_CONNECTOR",
"CONFLUENCE_CONNECTOR",
"CLICKUP_CONNECTOR",
"GOOGLE_CALENDAR_CONNECTOR",
"GOOGLE_GMAIL_CONNECTOR",
"GOOGLE_DRIVE_FILE",
"AIRTABLE_CONNECTOR",
"LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR",
"BOOKSTACK_CONNECTOR",
"CIRCLEBACK",
"NOTE",
)
old_connector_values_sql = ", ".join([f"'{v}'" for v in old_connector_values])
old_document_values_sql = ", ".join([f"'{v}'" for v in old_document_values])
# Table and column names
connector_table_name = "search_source_connectors"
connector_column_name = "connector_type"
document_table_name = "documents"
document_column_name = "document_type"
# Connector Enum Downgrade Steps
# 1. Rename the current connector enum type
op.execute(f"ALTER TYPE {CONNECTOR_ENUM} RENAME TO {old_connector_enum_name}")
# 2. Create the new connector enum type with the old values
op.execute(f"CREATE TYPE {CONNECTOR_ENUM} AS ENUM({old_connector_values_sql})")
# 3. Alter the column to use the new connector enum type
op.execute(
f"""
ALTER TABLE {connector_table_name}
ALTER COLUMN {connector_column_name} TYPE {CONNECTOR_ENUM}
USING {connector_column_name}::text::{CONNECTOR_ENUM}
"""
)
# 4. Drop the old connector enum type
op.execute(f"DROP TYPE {old_connector_enum_name}")
# Document Enum Downgrade Steps
# 1. Rename the current document enum type
op.execute(f"ALTER TYPE {DOCUMENT_ENUM} RENAME TO {old_document_enum_name}")
# 2. Create the new document enum type with the old values
op.execute(f"CREATE TYPE {DOCUMENT_ENUM} AS ENUM({old_document_values_sql})")
# 3. Alter the column to use the new document enum type
op.execute(
f"""
ALTER TABLE {document_table_name}
ALTER COLUMN {document_column_name} TYPE {DOCUMENT_ENUM}
USING {document_column_name}::text::{DOCUMENT_ENUM}
"""
)
# 4. Drop the old document enum type
op.execute(f"DROP TYPE {old_document_enum_name}")

View file

@ -0,0 +1,163 @@
"""Add Surfsense docs tables for global documentation storage
Revision ID: 60
Revises: 59
"""
from collections.abc import Sequence
from alembic import op
from app.config import config
# revision identifiers, used by Alembic.
revision: str = "60"
down_revision: str | None = "59"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Get embedding dimension from config
EMBEDDING_DIM = config.embedding_model_instance.dimension
def upgrade() -> None:
"""Create surfsense_docs_documents and surfsense_docs_chunks tables."""
# Create surfsense_docs_documents table
op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'surfsense_docs_documents'
) THEN
CREATE TABLE surfsense_docs_documents (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
source VARCHAR NOT NULL UNIQUE,
title VARCHAR NOT NULL,
content TEXT NOT NULL,
content_hash VARCHAR NOT NULL,
embedding vector({EMBEDDING_DIM}),
updated_at TIMESTAMP WITH TIME ZONE
);
END IF;
END$$;
"""
)
# Create indexes for surfsense_docs_documents
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_source'
) THEN
CREATE INDEX ix_surfsense_docs_documents_source ON surfsense_docs_documents(source);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_content_hash'
) THEN
CREATE INDEX ix_surfsense_docs_documents_content_hash ON surfsense_docs_documents(content_hash);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_updated_at'
) THEN
CREATE INDEX ix_surfsense_docs_documents_updated_at ON surfsense_docs_documents(updated_at);
END IF;
END$$;
"""
)
# Create surfsense_docs_chunks table
op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'surfsense_docs_chunks'
) THEN
CREATE TABLE surfsense_docs_chunks (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
content TEXT NOT NULL,
embedding vector({EMBEDDING_DIM}),
document_id INTEGER NOT NULL REFERENCES surfsense_docs_documents(id) ON DELETE CASCADE
);
END IF;
END$$;
"""
)
# Create indexes for surfsense_docs_chunks
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'surfsense_docs_chunks' AND indexname = 'ix_surfsense_docs_chunks_document_id'
) THEN
CREATE INDEX ix_surfsense_docs_chunks_document_id ON surfsense_docs_chunks(document_id);
END IF;
END$$;
"""
)
# Create vector indexes for similarity search
op.execute(
"""
CREATE INDEX IF NOT EXISTS surfsense_docs_documents_vector_index
ON surfsense_docs_documents USING hnsw (embedding public.vector_cosine_ops);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_vector_index
ON surfsense_docs_chunks USING hnsw (embedding public.vector_cosine_ops);
"""
)
# Create full-text search indexes (same pattern as documents/chunks tables)
op.execute(
"""
CREATE INDEX IF NOT EXISTS surfsense_docs_documents_search_index
ON surfsense_docs_documents USING gin (to_tsvector('english', content));
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_search_index
ON surfsense_docs_chunks USING gin (to_tsvector('english', content));
"""
)
def downgrade() -> None:
"""Remove surfsense docs tables."""
# Drop full-text search indexes
op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_search_index")
op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_search_index")
# Drop vector indexes
op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_vector_index")
op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_vector_index")
# Drop regular indexes
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_chunks_document_id")
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_updated_at")
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_content_hash")
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_source")
# Drop tables (chunks first due to FK)
op.execute("DROP TABLE IF EXISTS surfsense_docs_chunks")
op.execute("DROP TABLE IF EXISTS surfsense_docs_documents")

View file

@ -26,6 +26,13 @@ SURFSENSE_TOOLS_INSTRUCTIONS = """
<tools>
You have access to the following tools:
0. search_surfsense_docs: Search the official SurfSense documentation.
- Use this tool when the user asks anything about SurfSense itself (the application they are using).
- Args:
- query: The search query about SurfSense
- top_k: Number of documentation chunks to retrieve (default: 10)
- Returns: Documentation content with chunk IDs for citations (prefixed with 'doc-', e.g., [citation:doc-123])
1. search_knowledge_base: Search the user's personal knowledge base for relevant information.
- Args:
- query: The search query - be specific and include key terms
@ -152,6 +159,18 @@ You have access to the following tools:
- Airtable/Notion: Check field values, apply mapping above
</tools>
<tool_call_examples>
- User: "How do I install SurfSense?"
- Call: `search_surfsense_docs(query="installation setup")`
- User: "What connectors does SurfSense support?"
- Call: `search_surfsense_docs(query="available connectors integrations")`
- User: "How do I set up the Notion connector?"
- Call: `search_surfsense_docs(query="Notion connector setup configuration")`
- User: "How do I use Docker to run SurfSense?"
- Call: `search_surfsense_docs(query="Docker installation setup")`
- User: "Fetch all my notes and what's in them?"
- Call: `search_knowledge_base(query="*", top_k=50, connectors_to_search=["NOTE"])`
@ -308,7 +327,7 @@ The documents you receive are structured like this:
</document_content>
</document>
IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124). Do NOT cite document_id.
IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124, doc-45). Do NOT cite document_id.
</document_structure_example>
<citation_format>
@ -319,11 +338,13 @@ IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124). Do NOT cite docume
- NEVER create your own citation format - use the exact chunk_id values from the documents in the [citation:chunk_id] format
- NEVER format citations as clickable links or as markdown links like "([citation:5](https://example.com))". Always use plain square brackets only
- NEVER make up chunk IDs if you are unsure about the chunk_id. It is better to omit the citation than to guess
- Copy the EXACT chunk id from the XML - if it says `<chunk id='doc-123'>`, use [citation:doc-123]
</citation_format>
<citation_examples>
CORRECT citation formats:
- [citation:5]
- [citation:doc-123] (for Surfsense documentation chunks)
- [citation:chunk_id1], [citation:chunk_id2], [citation:chunk_id3]
INCORRECT citation formats (DO NOT use):

View file

@ -6,6 +6,7 @@ To add a new tool, see the documentation in registry.py.
Available tools:
- search_knowledge_base: Search the user's personal knowledge base
- search_surfsense_docs: Search Surfsense documentation for usage help
- generate_podcast: Generate audio podcasts from content
- link_preview: Fetch rich previews for URLs
- display_image: Display images in chat
@ -31,6 +32,7 @@ from .registry import (
get_tool_by_name,
)
from .scrape_webpage import create_scrape_webpage_tool
from .search_surfsense_docs import create_search_surfsense_docs_tool
__all__ = [
# Registry
@ -43,6 +45,7 @@ __all__ = [
"create_link_preview_tool",
"create_scrape_webpage_tool",
"create_search_knowledge_base_tool",
"create_search_surfsense_docs_tool",
# Knowledge base utilities
"format_documents_for_context",
"get_all_tool_names",

View file

@ -26,6 +26,7 @@ _ALL_CONNECTORS: list[str] = [
"EXTENSION",
"FILE",
"SLACK_CONNECTOR",
"TEAMS_CONNECTOR",
"NOTION_CONNECTOR",
"YOUTUBE_VIDEO",
"GITHUB_CONNECTOR",
@ -573,6 +574,7 @@ def create_search_knowledge_base_tool(
- FILE: "User-uploaded documents (PDFs, Word, etc.)" (personal files)
- NOTE: "SurfSense Notes" (notes created inside SurfSense)
- SLACK_CONNECTOR: "Slack conversations and shared content" (personal workspace communications)
- TEAMS_CONNECTOR: "Microsoft Teams messages and conversations" (personal Teams communications)
- NOTION_CONNECTOR: "Notion workspace pages and databases" (personal knowledge management)
- YOUTUBE_VIDEO: "YouTube video transcripts and metadata" (personally saved videos)
- GITHUB_CONNECTOR: "GitHub repository content and issues" (personal repositories and interactions)

View file

@ -48,6 +48,7 @@ from .knowledge_base import create_search_knowledge_base_tool
from .link_preview import create_link_preview_tool
from .podcast import create_generate_podcast_tool
from .scrape_webpage import create_scrape_webpage_tool
from .search_surfsense_docs import create_search_surfsense_docs_tool
# =============================================================================
# Tool Definition
@ -126,6 +127,15 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
requires=[], # firecrawl_api_key is optional
),
# Note: write_todos is now provided by TodoListMiddleware from deepagents
# Surfsense documentation search tool
ToolDefinition(
name="search_surfsense_docs",
description="Search Surfsense documentation for help with using the application",
factory=lambda deps: create_search_surfsense_docs_tool(
db_session=deps["db_session"],
),
requires=["db_session"],
),
# =========================================================================
# ADD YOUR CUSTOM TOOLS BELOW
# =========================================================================

View file

@ -0,0 +1,163 @@
"""
Surfsense documentation search tool.
This tool allows the agent to search the pre-indexed Surfsense documentation
to help users with questions about how to use the application.
The documentation is indexed at deployment time from MDX files and stored
in dedicated tables (surfsense_docs_documents, surfsense_docs_chunks).
"""
import json
from langchain_core.tools import tool
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
def format_surfsense_docs_results(results: list[tuple]) -> str:
"""
Format search results into XML structure for the LLM context.
Uses the same XML structure as format_documents_for_context from knowledge_base.py
but with 'doc-' prefix on chunk IDs. This allows:
- LLM to use consistent [citation:doc-XXX] format
- Frontend to detect 'doc-' prefix and route to surfsense docs endpoint
Args:
results: List of (chunk, document) tuples from the database query
Returns:
Formatted XML string with documentation content and citation-ready chunks
"""
if not results:
return "No relevant Surfsense documentation found for your query."
# Group chunks by document
grouped: dict[int, dict] = {}
for chunk, doc in results:
if doc.id not in grouped:
grouped[doc.id] = {
"document_id": f"doc-{doc.id}",
"document_type": "SURFSENSE_DOCS",
"title": doc.title,
"url": doc.source,
"metadata": {"source": doc.source},
"chunks": [],
}
grouped[doc.id]["chunks"].append(
{
"chunk_id": f"doc-{chunk.id}",
"content": chunk.content,
}
)
# Render XML matching format_documents_for_context structure
parts: list[str] = []
for g in grouped.values():
metadata_json = json.dumps(g["metadata"], ensure_ascii=False)
parts.append("<document>")
parts.append("<document_metadata>")
parts.append(f" <document_id>{g['document_id']}</document_id>")
parts.append(f" <document_type>{g['document_type']}</document_type>")
parts.append(f" <title><![CDATA[{g['title']}]]></title>")
parts.append(f" <url><![CDATA[{g['url']}]]></url>")
parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
parts.append("</document_metadata>")
parts.append("")
parts.append("<document_content>")
for ch in g["chunks"]:
parts.append(
f" <chunk id='{ch['chunk_id']}'><![CDATA[{ch['content']}]]></chunk>"
)
parts.append("</document_content>")
parts.append("</document>")
parts.append("")
return "\n".join(parts).strip()
async def search_surfsense_docs_async(
query: str,
db_session: AsyncSession,
top_k: int = 10,
) -> str:
"""
Search Surfsense documentation using vector similarity.
Args:
query: The search query about Surfsense usage
db_session: Database session for executing queries
top_k: Number of results to return
Returns:
Formatted string with relevant documentation content
"""
# Get embedding for the query
query_embedding = config.embedding_model_instance.embed(query)
# Vector similarity search on chunks, joining with documents
stmt = (
select(SurfsenseDocsChunk, SurfsenseDocsDocument)
.join(
SurfsenseDocsDocument,
SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id,
)
.order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding))
.limit(top_k)
)
result = await db_session.execute(stmt)
rows = result.all()
return format_surfsense_docs_results(rows)
def create_search_surfsense_docs_tool(db_session: AsyncSession):
"""
Factory function to create the search_surfsense_docs tool.
Args:
db_session: Database session for executing queries
Returns:
A configured tool function for searching Surfsense documentation
"""
@tool
async def search_surfsense_docs(query: str, top_k: int = 10) -> str:
"""
Search Surfsense documentation for help with using the application.
Use this tool when the user asks questions about:
- How to use Surfsense features
- Installation and setup instructions
- Configuration options and settings
- Troubleshooting common issues
- Available connectors and integrations
- Browser extension usage
- API documentation
This searches the official Surfsense documentation that was indexed
at deployment time. It does NOT search the user's personal knowledge base.
Args:
query: The search query about Surfsense usage or features
top_k: Number of documentation chunks to retrieve (default: 10)
Returns:
Relevant documentation content formatted with chunk IDs for citations
"""
return await search_surfsense_docs_async(
query=query,
db_session=db_session,
top_k=top_k,
)
return search_surfsense_docs

View file

@ -13,6 +13,7 @@ from app.config import config
from app.db import User, create_db_and_tables, get_async_session
from app.routes import router as crud_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
@ -22,6 +23,8 @@ async def lifespan(app: FastAPI):
await create_db_and_tables()
# Setup LangGraph checkpointer tables for conversation persistence
await setup_checkpointer_tables()
# Seed Surfsense documentation
await seed_surfsense_docs()
yield
# Cleanup: close checkpointer connection on shutdown
await close_checkpointer()

View file

@ -117,6 +117,11 @@ class Config:
DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI")
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
# Microsoft Teams OAuth
TEAMS_CLIENT_ID = os.getenv("TEAMS_CLIENT_ID")
TEAMS_CLIENT_SECRET = os.getenv("TEAMS_CLIENT_SECRET")
TEAMS_REDIRECT_URI = os.getenv("TEAMS_REDIRECT_URI")
# ClickUp OAuth
CLICKUP_CLIENT_ID = os.getenv("CLICKUP_CLIENT_ID")
CLICKUP_CLIENT_SECRET = os.getenv("CLICKUP_CLIENT_SECRET")

View file

@ -0,0 +1,342 @@
"""
Microsoft Teams Connector
A module for interacting with Microsoft Teams Graph API to retrieve teams, channels, and message history.
Supports OAuth-based authentication with token refresh.
"""
import logging
from datetime import UTC, datetime
from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import SearchSourceConnector
from app.routes.teams_add_connector_route import refresh_teams_token
from app.schemas.teams_auth_credentials import TeamsAuthCredentialsBase
from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__)
class TeamsConnector:
"""Class for retrieving teams, channels, and message history from Microsoft Teams."""
# Microsoft Graph API endpoints
GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"
def __init__(
self,
access_token: str | None = None,
session: AsyncSession | None = None,
connector_id: int | None = None,
credentials: TeamsAuthCredentialsBase | None = None,
):
"""
Initialize the TeamsConnector with an access token or OAuth credentials.
Args:
access_token: Microsoft Graph API access token (optional, for backward compatibility)
session: Database session for token refresh (optional)
connector_id: Connector ID for token refresh (optional)
credentials: Teams OAuth credentials (optional, will be loaded from DB if not provided)
"""
self._session = session
self._connector_id = connector_id
self._credentials = credentials
self._access_token = access_token
async def _get_valid_token(self) -> str:
"""
Get valid Microsoft Teams access token, refreshing if needed.
Returns:
Valid access token
Raises:
ValueError: If credentials are missing or invalid
Exception: If token refresh fails
"""
# If we have a direct token (backward compatibility), use it
if (
self._access_token
and self._session is None
and self._connector_id is None
and self._credentials is None
):
return self._access_token
# Load credentials from DB if not provided
if self._credentials is None:
if not self._session or not self._connector_id:
raise ValueError(
"Cannot load credentials: session and connector_id required"
)
result = await self._session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == self._connector_id
)
)
connector = result.scalars().first()
if not connector:
raise ValueError(f"Connector {self._connector_id} not found")
config_data = connector.config.copy()
# Decrypt credentials if they are encrypted
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
try:
token_encryption = TokenEncryption(config.SECRET_KEY)
# Decrypt sensitive fields
if config_data.get("access_token"):
config_data["access_token"] = token_encryption.decrypt_token(
config_data["access_token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
logger.info(
"Decrypted Teams credentials for connector %s",
self._connector_id,
)
except Exception as e:
logger.error(
"Failed to decrypt Teams credentials for connector %s: %s",
self._connector_id,
str(e),
)
raise ValueError(
f"Failed to decrypt Teams credentials: {e!s}"
) from e
try:
self._credentials = TeamsAuthCredentialsBase.from_dict(config_data)
except Exception as e:
raise ValueError(f"Invalid Teams credentials: {e!s}") from e
# Check if token is expired and refreshable
if self._credentials.is_expired and self._credentials.is_refreshable:
try:
logger.info(
"Teams token expired for connector %s, refreshing...",
self._connector_id,
)
# Get connector for refresh
result = await self._session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == self._connector_id
)
)
connector = result.scalars().first()
if not connector:
raise RuntimeError(
f"Connector {self._connector_id} not found; cannot refresh token."
)
# Refresh token
connector = await refresh_teams_token(self._session, connector)
# Reload credentials after refresh
config_data = connector.config.copy()
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
if config_data.get("access_token"):
config_data["access_token"] = token_encryption.decrypt_token(
config_data["access_token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
self._credentials = TeamsAuthCredentialsBase.from_dict(config_data)
logger.info(
"Successfully refreshed Teams token for connector %s",
self._connector_id,
)
except Exception as e:
logger.error(
"Failed to refresh Teams token for connector %s: %s",
self._connector_id,
str(e),
)
raise ValueError(
f"Failed to refresh Teams OAuth credentials: {e!s}"
) from e
return self._credentials.access_token
async def get_joined_teams(self) -> list[dict[str, Any]]:
"""
Get list of all teams the user is a member of.
Returns:
List of team objects with id, display_name, etc.
"""
access_token = await self._get_valid_token()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.GRAPH_API_BASE}/me/joinedTeams",
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
)
if response.status_code != 200:
raise ValueError(
f"Failed to get joined teams: {response.status_code} - {response.text}"
)
data = response.json()
return data.get("value", [])
async def get_team_channels(self, team_id: str) -> list[dict[str, Any]]:
"""
Get list of all channels in a team.
Args:
team_id: The team ID
Returns:
List of channel objects
"""
access_token = await self._get_valid_token()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.GRAPH_API_BASE}/teams/{team_id}/channels",
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
)
if response.status_code != 200:
raise ValueError(
f"Failed to get channels for team {team_id}: {response.status_code} - {response.text}"
)
data = response.json()
return data.get("value", [])
async def get_channel_messages(
self,
team_id: str,
channel_id: str,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> list[dict[str, Any]]:
"""
Get messages from a specific channel with optional date filtering.
Args:
team_id: The team ID
channel_id: The channel ID
start_date: Optional start date for filtering messages
end_date: Optional end date for filtering messages
Returns:
List of message objects
"""
access_token = await self._get_valid_token()
async with httpx.AsyncClient() as client:
url = (
f"{self.GRAPH_API_BASE}/teams/{team_id}/channels/{channel_id}/messages"
)
# Note: The Graph API for channel messages doesn't support $filter parameter
# We fetch all messages and filter them client-side
response = await client.get(
url,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
)
if response.status_code != 200:
raise ValueError(
f"Failed to get messages from channel {channel_id}: {response.status_code} - {response.text}"
)
data = response.json()
messages = data.get("value", [])
# Filter messages by date if needed (client-side filtering)
if start_date or end_date:
# Make sure comparison dates are timezone-aware (UTC)
if start_date and start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=UTC)
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
filtered_messages = []
for message in messages:
created_at_str = message.get("createdDateTime")
if not created_at_str:
continue
# Parse the ISO 8601 datetime string (already timezone-aware)
created_at = datetime.fromisoformat(
created_at_str.replace("Z", "+00:00")
)
# Check if message is within date range
if start_date and created_at < start_date:
continue
if end_date and created_at > end_date:
continue
filtered_messages.append(message)
return filtered_messages
return messages
async def get_message_replies(
self, team_id: str, channel_id: str, message_id: str
) -> list[dict[str, Any]]:
"""
Get replies to a specific message.
Args:
team_id: The team ID
channel_id: The channel ID
message_id: The message ID
Returns:
List of reply message objects
"""
access_token = await self._get_valid_token()
async with httpx.AsyncClient() as client:
url = f"{self.GRAPH_API_BASE}/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies"
response = await client.get(
url,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
)
if response.status_code != 200:
logger.warning(
"Failed to get replies for message %s: %s - %s",
message_id,
response.status_code,
response.text,
)
return []
data = response.json()
return data.get("value", [])

View file

@ -0,0 +1,254 @@
"""
Microsoft Teams History Module
A module for retrieving conversation history from Microsoft Teams channels.
Allows fetching team lists, channel lists, and message history with date range filtering.
"""
import logging
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.teams_connector import TeamsConnector
from app.schemas.teams_auth_credentials import TeamsAuthCredentialsBase
logger = logging.getLogger(__name__)
class TeamsHistory:
"""Class for retrieving conversation history from Microsoft Teams channels."""
def __init__(
self,
access_token: str | None = None,
session: AsyncSession | None = None,
connector_id: int | None = None,
credentials: TeamsAuthCredentialsBase | None = None,
):
"""
Initialize the TeamsHistory class.
Args:
access_token: Microsoft Graph API access token (optional, for backward compatibility)
session: Database session for token refresh (optional)
connector_id: Connector ID for token refresh (optional)
credentials: Teams OAuth credentials (optional, will be loaded from DB if not provided)
"""
self.connector = TeamsConnector(
access_token=access_token,
session=session,
connector_id=connector_id,
credentials=credentials,
)
async def get_all_teams(self) -> list[dict[str, Any]]:
"""
Get list of all teams the user has access to.
Returns:
List of team objects containing team metadata.
"""
try:
teams = await self.connector.get_joined_teams()
logger.info("Retrieved %s teams", len(teams))
return teams
except Exception as e:
logger.error("Error fetching teams: %s", str(e))
raise
async def get_channels_for_team(self, team_id: str) -> list[dict[str, Any]]:
"""
Get list of all channels in a specific team.
Args:
team_id: The ID of the team
Returns:
List of channel objects containing channel metadata.
"""
try:
channels = await self.connector.get_team_channels(team_id)
logger.info("Retrieved %s channels for team %s", len(channels), team_id)
return channels
except Exception as e:
logger.error("Error fetching channels for team %s: %s", team_id, str(e))
raise
async def get_messages_from_channel(
self,
team_id: str,
channel_id: str,
start_date: datetime | None = None,
end_date: datetime | None = None,
include_replies: bool = True,
) -> list[dict[str, Any]]:
"""
Get messages from a specific channel with optional date filtering.
Args:
team_id: The ID of the team
channel_id: The ID of the channel
start_date: Optional start date for filtering messages
end_date: Optional end date for filtering messages
include_replies: Whether to include reply messages (default: True)
Returns:
List of message objects with content and metadata.
"""
try:
messages = await self.connector.get_channel_messages(
team_id, channel_id, start_date, end_date
)
logger.info(
"Retrieved %s messages from channel %s in team %s",
len(messages),
channel_id,
team_id,
)
# Fetch replies if requested
if include_replies:
all_messages = []
for message in messages:
all_messages.append(message)
# Get replies for this message
try:
replies = await self.connector.get_message_replies(
team_id, channel_id, message.get("id")
)
all_messages.extend(replies)
except Exception:
logger.warning(
"Failed to get replies for message %s",
message.get("id"),
exc_info=True,
)
# Continue without replies for this message
logger.info(
"Total messages including replies: %s for channel %s",
len(all_messages),
channel_id,
)
return all_messages
return messages
except Exception as e:
logger.error(
"Error fetching messages from channel %s in team %s: %s",
channel_id,
team_id,
str(e),
)
raise
async def get_all_messages_from_team(
self,
team_id: str,
start_date: datetime | None = None,
end_date: datetime | None = None,
include_replies: bool = True,
) -> dict[str, list[dict[str, Any]]]:
"""
Get all messages from all channels in a team.
Args:
team_id: The ID of the team
start_date: Optional start date for filtering messages
end_date: Optional end date for filtering messages
include_replies: Whether to include reply messages (default: True)
Returns:
Dictionary mapping channel IDs to lists of messages.
"""
try:
channels = await self.get_channels_for_team(team_id)
all_channel_messages = {}
for channel in channels:
channel_id = channel.get("id")
channel_name = channel.get("displayName", "Unknown")
try:
messages = await self.get_messages_from_channel(
team_id, channel_id, start_date, end_date, include_replies
)
all_channel_messages[channel_id] = messages
logger.info(
"Fetched %s messages from channel '%s' (%s)",
len(messages),
channel_name,
channel_id,
)
except Exception:
logger.error(
"Failed to fetch messages from channel '%s' (%s)",
channel_name,
channel_id,
exc_info=True,
)
all_channel_messages[channel_id] = []
return all_channel_messages
except Exception as e:
logger.error("Error fetching messages from team %s: %s", team_id, str(e))
raise
async def get_all_messages(
self,
start_date: datetime | None = None,
end_date: datetime | None = None,
include_replies: bool = True,
) -> dict[str, dict[str, list[dict[str, Any]]]]:
"""
Get all messages from all teams and channels the user has access to.
Args:
start_date: Optional start date for filtering messages
end_date: Optional end date for filtering messages
include_replies: Whether to include reply messages (default: True)
Returns:
Nested dictionary: team_id -> channel_id -> list of messages.
"""
try:
teams = await self.get_all_teams()
all_messages = {}
for team in teams:
team_id = team.get("id")
team_name = team.get("displayName", "Unknown")
try:
team_messages = await self.get_all_messages_from_team(
team_id, start_date, end_date, include_replies
)
all_messages[team_id] = team_messages
total_messages = sum(
len(messages) for messages in team_messages.values()
)
logger.info(
"Fetched %s total messages from team '%s' (%s)",
total_messages,
team_name,
team_id,
)
except Exception:
logger.error(
"Failed to fetch messages from team '%s' (%s)",
team_name,
team_id,
exc_info=True,
)
all_messages[team_id] = {}
return all_messages
except Exception as e:
logger.error("Error fetching all messages: %s", str(e))
raise

View file

@ -36,6 +36,7 @@ class DocumentType(str, Enum):
CRAWLED_URL = "CRAWLED_URL"
FILE = "FILE"
SLACK_CONNECTOR = "SLACK_CONNECTOR"
TEAMS_CONNECTOR = "TEAMS_CONNECTOR"
NOTION_CONNECTOR = "NOTION_CONNECTOR"
YOUTUBE_VIDEO = "YOUTUBE_VIDEO"
GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
@ -62,6 +63,7 @@ class SearchSourceConnectorType(str, Enum):
LINKUP_API = "LINKUP_API"
BAIDU_SEARCH_API = "BAIDU_SEARCH_API" # Baidu AI Search API for Chinese web search
SLACK_CONNECTOR = "SLACK_CONNECTOR"
TEAMS_CONNECTOR = "TEAMS_CONNECTOR"
NOTION_CONNECTOR = "NOTION_CONNECTOR"
GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
LINEAR_CONNECTOR = "LINEAR_CONNECTOR"
@ -426,6 +428,46 @@ class Chunk(BaseModel, TimestampMixin):
document = relationship("Document", back_populates="chunks")
class SurfsenseDocsDocument(BaseModel, TimestampMixin):
"""
Surfsense documentation storage.
Indexed at migration time from MDX files.
"""
__tablename__ = "surfsense_docs_documents"
source = Column(
String, nullable=False, unique=True, index=True
) # File path: "connectors/slack.mdx"
title = Column(String, nullable=False)
content = Column(Text, nullable=False)
content_hash = Column(String, nullable=False, index=True) # For detecting changes
embedding = Column(Vector(config.embedding_model_instance.dimension))
updated_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True)
chunks = relationship(
"SurfsenseDocsChunk",
back_populates="document",
cascade="all, delete-orphan",
)
class SurfsenseDocsChunk(BaseModel, TimestampMixin):
"""Chunk storage for Surfsense documentation."""
__tablename__ = "surfsense_docs_chunks"
content = Column(Text, nullable=False)
embedding = Column(Vector(config.embedding_model_instance.dimension))
document_id = Column(
Integer,
ForeignKey("surfsense_docs_documents.id", ondelete="CASCADE"),
nullable=False,
)
document = relationship("SurfsenseDocsDocument", back_populates="chunks")
class Podcast(BaseModel, TimestampMixin):
"""Podcast model for storing generated podcasts."""

View file

@ -31,6 +31,8 @@ from .rbac_routes import router as rbac_router
from .search_source_connectors_routes import router as search_source_connectors_router
from .search_spaces_routes import router as search_spaces_router
from .slack_add_connector_route import router as slack_add_connector_router
from .surfsense_docs_routes import router as surfsense_docs_router
from .teams_add_connector_route import router as teams_add_connector_router
router = APIRouter()
@ -50,6 +52,7 @@ router.include_router(linear_add_connector_router)
router.include_router(luma_add_connector_router)
router.include_router(notion_add_connector_router)
router.include_router(slack_add_connector_router)
router.include_router(teams_add_connector_router)
router.include_router(discord_add_connector_router)
router.include_router(jira_add_connector_router)
router.include_router(confluence_add_connector_router)
@ -57,3 +60,4 @@ router.include_router(clickup_add_connector_router)
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
router.include_router(logs_router)
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
router.include_router(surfsense_docs_router) # Surfsense documentation for citations

View file

@ -543,7 +543,7 @@ async def index_connector_content(
),
end_date: str = Query(
None,
description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date",
description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date. For calendar connectors (Google Calendar, Luma), future dates can be selected to index upcoming events.",
),
drive_items: GoogleDriveIndexRequest | None = Body(
None,
@ -558,6 +558,7 @@ async def index_connector_content(
Currently supports:
- SLACK_CONNECTOR: Indexes messages from all accessible Slack channels
- TEAMS_CONNECTOR: Indexes messages from all accessible Microsoft Teams channels
- NOTION_CONNECTOR: Indexes pages from all accessible Notion pages
- GITHUB_CONNECTOR: Indexes code and documentation from GitHub repositories
- LINEAR_CONNECTOR: Indexes issues and comments from Linear
@ -616,7 +617,16 @@ async def index_connector_content(
else:
indexing_from = start_date
indexing_to = end_date if end_date else today_str
# For calendar connectors, default to today but allow future dates if explicitly provided
if connector.connector_type in [
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
SearchSourceConnectorType.LUMA_CONNECTOR,
]:
# Default to today if no end_date provided (users can manually select future dates)
indexing_to = today_str if end_date is None else end_date
else:
# For non-calendar connectors, cap at today
indexing_to = end_date if end_date else today_str
if connector.connector_type == SearchSourceConnectorType.SLACK_CONNECTOR:
from app.tasks.celery_tasks.connector_tasks import (
@ -631,6 +641,19 @@ async def index_connector_content(
)
response_message = "Slack indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR:
from app.tasks.celery_tasks.connector_tasks import (
index_teams_messages_task,
)
logger.info(
f"Triggering Teams indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
)
index_teams_messages_task.delay(
connector_id, search_space_id, str(user.id), indexing_from, indexing_to
)
response_message = "Teams indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR:
from app.tasks.celery_tasks.connector_tasks import index_notion_pages_task
@ -857,9 +880,10 @@ async def index_connector_content(
) from e
async def update_connector_last_indexed(session: AsyncSession, connector_id: int):
async def _update_connector_timestamp_by_id(session: AsyncSession, connector_id: int):
"""
Update the last_indexed_at timestamp for a connector.
Update the last_indexed_at timestamp for a connector by its ID.
Internal helper function for routes.
Args:
session: Database session
@ -934,7 +958,7 @@ async def run_slack_indexing(
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0:
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
logger.info(
f"Slack indexing completed successfully: {documents_processed} documents processed"
)
@ -996,7 +1020,7 @@ async def run_notion_indexing(
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0:
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
logger.info(
f"Notion indexing completed successfully: {documents_processed} documents processed"
)
@ -1056,7 +1080,7 @@ async def run_github_indexing(
f"GitHub indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
await session.rollback()
@ -1115,7 +1139,7 @@ async def run_linear_indexing(
f"Linear indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
await session.rollback()
@ -1176,7 +1200,7 @@ async def run_discord_indexing(
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0:
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
logger.info(
f"Discord indexing completed successfully: {documents_processed} documents processed"
)
@ -1188,6 +1212,64 @@ async def run_discord_indexing(
logger.error(f"Error in background Discord indexing task: {e!s}")
async def run_teams_indexing_with_new_session(
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""
Create a new session and run the Microsoft Teams indexing task.
This prevents session leaks by creating a dedicated session for the background task.
"""
async with async_session_maker() as session:
await run_teams_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
async def run_teams_indexing(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""
Background task to run Microsoft Teams indexing.
Args:
session: Database session
connector_id: ID of the Teams connector
search_space_id: ID of the search space
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
"""
try:
from app.tasks.connector_indexers.teams_indexer import index_teams_messages
# Index Teams messages without updating last_indexed_at (we'll do it separately)
documents_processed, error_or_warning = await index_teams_messages(
session=session,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
update_last_indexed=False, # Don't update timestamp in the indexing function
)
# Update last_indexed_at after successful indexing (even if 0 new docs - they were checked)
await _update_connector_timestamp_by_id(session, connector_id)
logger.info(
f"Teams indexing completed successfully: {documents_processed} documents processed. {error_or_warning or ''}"
)
except Exception as e:
logger.error(f"Error in background Teams indexing task: {e!s}")
# Add new helper functions for Jira indexing
async def run_jira_indexing_with_new_session(
connector_id: int,
@ -1236,7 +1318,7 @@ async def run_jira_indexing(
f"Jira indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
@ -1296,7 +1378,7 @@ async def run_confluence_indexing(
f"Confluence indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
@ -1354,7 +1436,7 @@ async def run_clickup_indexing(
f"ClickUp indexing successful for connector {connector_id}. Indexed {indexed_count} tasks."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
@ -1412,7 +1494,7 @@ async def run_airtable_indexing(
f"Airtable indexing successful for connector {connector_id}. Indexed {indexed_count} records."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
@ -1472,7 +1554,7 @@ async def run_google_calendar_indexing(
f"Google Calendar indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
@ -1539,7 +1621,7 @@ async def run_google_gmail_indexing(
f"Google Gmail indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
@ -1623,7 +1705,7 @@ async def run_google_drive_indexing(
f"Google Drive indexing successful for connector {connector_id}. Indexed {total_indexed} documents from {len(items.folders)} folder(s) and {len(items.files)} file(s)."
)
# Update the last indexed timestamp only on full success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
@ -1683,7 +1765,7 @@ async def run_luma_indexing(
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0:
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
logger.info(
f"Luma indexing completed successfully: {documents_processed} documents processed"
)
@ -1743,7 +1825,7 @@ async def run_elasticsearch_indexing(
f"Elasticsearch indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit()
except Exception as e:
await session.rollback()
@ -1802,7 +1884,7 @@ async def run_web_page_indexing(
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0:
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
logger.info(
f"Web page indexing completed successfully: {documents_processed} documents processed"
)
@ -1875,7 +1957,7 @@ async def run_bookstack_indexing(
f"BookStack indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await _update_connector_timestamp_by_id(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(

View file

@ -0,0 +1,89 @@
"""
Routes for Surfsense documentation.
These endpoints support the citation system for Surfsense docs,
allowing the frontend to fetch document details when a user clicks
on a [citation:doc-XXX] link.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import (
SurfsenseDocsChunk,
SurfsenseDocsDocument,
User,
get_async_session,
)
from app.schemas.surfsense_docs import (
SurfsenseDocsChunkRead,
SurfsenseDocsDocumentWithChunksRead,
)
from app.users import current_active_user
router = APIRouter()
@router.get(
"/surfsense-docs/by-chunk/{chunk_id}",
response_model=SurfsenseDocsDocumentWithChunksRead,
)
async def get_surfsense_doc_by_chunk_id(
chunk_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Retrieves a Surfsense documentation document based on a chunk ID.
This endpoint is used by the frontend to resolve [citation:doc-XXX] links.
"""
try:
# Get the chunk
chunk_result = await session.execute(
select(SurfsenseDocsChunk).filter(SurfsenseDocsChunk.id == chunk_id)
)
chunk = chunk_result.scalars().first()
if not chunk:
raise HTTPException(
status_code=404,
detail=f"Surfsense docs chunk with id {chunk_id} not found",
)
# Get the associated document with all its chunks
document_result = await session.execute(
select(SurfsenseDocsDocument)
.options(selectinload(SurfsenseDocsDocument.chunks))
.filter(SurfsenseDocsDocument.id == chunk.document_id)
)
document = document_result.scalars().first()
if not document:
raise HTTPException(
status_code=404,
detail="Surfsense docs document not found",
)
# Sort chunks by ID
sorted_chunks = sorted(document.chunks, key=lambda x: x.id)
return SurfsenseDocsDocumentWithChunksRead(
id=document.id,
title=document.title,
source=document.source,
content=document.content,
chunks=[
SurfsenseDocsChunkRead(id=c.id, content=c.content)
for c in sorted_chunks
],
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve Surfsense documentation: {e!s}",
) from e

View file

@ -0,0 +1,478 @@
"""
Microsoft Teams Connector OAuth Routes.
Handles OAuth 2.0 authentication flow for Microsoft Teams connector using Microsoft Graph API.
"""
import logging
from datetime import UTC, datetime, timedelta
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
User,
get_async_session,
)
from app.schemas.teams_auth_credentials import TeamsAuthCredentialsBase
from app.users import current_active_user
from app.utils.connector_naming import (
check_duplicate_connector,
extract_identifier_from_credentials,
generate_unique_connector_name,
)
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__)
router = APIRouter()
# Microsoft identity platform endpoints
AUTHORIZATION_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
# OAuth scopes for Microsoft Teams (Graph API)
SCOPES = [
"offline_access", # Required for refresh tokens
"User.Read", # Read user profile
"Team.ReadBasic.All", # Read basic team information
"Channel.ReadBasic.All", # Read basic channel information
"ChannelMessage.Read.All", # Read messages in channels
]
# Initialize security utilities
_state_manager = None
_token_encryption = None
def get_state_manager() -> OAuthStateManager:
"""Get or create OAuth state manager instance."""
global _state_manager
if _state_manager is None:
if not config.SECRET_KEY:
raise ValueError("SECRET_KEY must be set for OAuth security")
_state_manager = OAuthStateManager(config.SECRET_KEY)
return _state_manager
def get_token_encryption() -> TokenEncryption:
"""Get or create token encryption instance."""
global _token_encryption
if _token_encryption is None:
if not config.SECRET_KEY:
raise ValueError("SECRET_KEY must be set for token encryption")
_token_encryption = TokenEncryption(config.SECRET_KEY)
return _token_encryption
@router.get("/auth/teams/connector/add")
async def connect_teams(space_id: int, user: User = Depends(current_active_user)):
"""
Initiate Microsoft Teams OAuth flow.
Args:
space_id: The search space ID
user: Current authenticated user
Returns:
Authorization URL for redirect
"""
try:
if not space_id:
raise HTTPException(status_code=400, detail="space_id is required")
if not config.TEAMS_CLIENT_ID:
raise HTTPException(
status_code=500, detail="Microsoft Teams OAuth not configured."
)
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
# Generate secure state parameter with HMAC signature
state_manager = get_state_manager()
state_encoded = state_manager.generate_secure_state(space_id, user.id)
# Build authorization URL
from urllib.parse import urlencode
auth_params = {
"client_id": config.TEAMS_CLIENT_ID,
"response_type": "code",
"redirect_uri": config.TEAMS_REDIRECT_URI,
"response_mode": "query",
"scope": " ".join(SCOPES),
"state": state_encoded,
}
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
logger.info(
"Generated Microsoft Teams OAuth URL for user %s, space %s",
user.id,
space_id,
)
return {"auth_url": auth_url}
except Exception as e:
logger.error(
"Failed to initiate Microsoft Teams OAuth: %s", str(e), exc_info=True
)
raise HTTPException(
status_code=500,
detail=f"Failed to initiate Microsoft Teams OAuth: {e!s}",
) from e
@router.get("/auth/teams/connector/callback")
async def teams_callback(
code: str | None = None,
error: str | None = None,
error_description: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session),
):
"""
Handle Microsoft Teams OAuth callback.
Args:
code: Authorization code from Microsoft (if user granted access)
error: Error code from Microsoft (if user denied access or error occurred)
error_description: Human-readable error description
state: State parameter containing user/space info
session: Database session
Returns:
Redirect response to frontend
"""
try:
# Handle OAuth errors (e.g., user denied access)
if error:
error_msg = error_description or error
logger.warning("Microsoft Teams OAuth error: %s", error_msg)
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=teams_auth_failed&message={error_msg}"
return RedirectResponse(url=redirect_url)
# Validate required parameters
if not code or not state:
raise HTTPException(
status_code=400, detail="Missing required OAuth parameters"
)
# Verify and decode state parameter
state_manager = get_state_manager()
try:
data = state_manager.validate_state(state)
space_id = data["space_id"]
user_id = UUID(data["user_id"])
except (HTTPException, ValueError, KeyError) as e:
logger.error("Invalid OAuth state: %s", str(e))
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=invalid_state"
return RedirectResponse(url=redirect_url)
# Exchange authorization code for access token
token_data = {
"client_id": config.TEAMS_CLIENT_ID,
"client_secret": config.TEAMS_CLIENT_SECRET,
"code": code,
"redirect_uri": config.TEAMS_REDIRECT_URI,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient() as client:
token_response = await client.post(
TOKEN_URL,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
if token_response.status_code != 200:
error_detail = token_response.text
try:
error_json = token_response.json()
error_detail = error_json.get("error_description", error_detail)
except Exception:
pass
raise HTTPException(
status_code=400, detail=f"Token exchange failed: {error_detail}"
)
token_json = token_response.json()
# Extract tokens from response
access_token = token_json.get("access_token")
refresh_token = token_json.get("refresh_token")
if not access_token:
raise HTTPException(
status_code=400, detail="No access token received from Microsoft"
)
# Encrypt sensitive tokens before storing
token_encryption = get_token_encryption()
# Calculate expiration time (UTC, tz-aware)
expires_at = None
if token_json.get("expires_in"):
now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
# Fetch user info from Microsoft Graph API
user_info = {}
tenant_info = {}
try:
async with httpx.AsyncClient() as client:
# Get user profile
user_response = await client.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
)
if user_response.status_code == 200:
user_data = user_response.json()
user_info = {
"user_id": user_data.get("id"),
"user_name": user_data.get("displayName"),
"user_email": user_data.get("mail")
or user_data.get("userPrincipalName"),
}
# Get organization/tenant info
org_response = await client.get(
"https://graph.microsoft.com/v1.0/organization",
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
)
if org_response.status_code == 200:
org_data = org_response.json()
if org_data.get("value") and len(org_data["value"]) > 0:
org = org_data["value"][0]
tenant_info = {
"tenant_id": org.get("id"),
"tenant_name": org.get("displayName"),
}
except Exception as e:
logger.warning(
"Failed to fetch user/tenant info from Microsoft Graph: %s", str(e)
)
# Store the encrypted tokens and user/tenant info in connector config
connector_config = {
"access_token": token_encryption.encrypt_token(access_token),
"refresh_token": token_encryption.encrypt_token(refresh_token)
if refresh_token
else None,
"token_type": token_json.get("token_type", "Bearer"),
"expires_in": token_json.get("expires_in"),
"expires_at": expires_at.isoformat() if expires_at else None,
"scope": token_json.get("scope"),
"tenant_id": tenant_info.get("tenant_id"),
"tenant_name": tenant_info.get("tenant_name"),
"user_id": user_info.get("user_id"),
# Mark that token is encrypted for backward compatibility
"_token_encrypted": True,
}
# Extract unique identifier from connector credentials
connector_identifier = extract_identifier_from_credentials(
SearchSourceConnectorType.TEAMS_CONNECTOR, connector_config
)
# Check for duplicate connector (same tenant already connected)
is_duplicate = await check_duplicate_connector(
session,
SearchSourceConnectorType.TEAMS_CONNECTOR,
space_id,
user_id,
connector_identifier,
)
if is_duplicate:
logger.warning(
"Duplicate Microsoft Teams connector for user %s, space %s, tenant %s",
user_id,
space_id,
tenant_info.get("tenant_name"),
)
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=duplicate_connector&message=This Microsoft Teams tenant is already connected to this space"
return RedirectResponse(url=redirect_url)
# Generate unique connector name
connector_name = await generate_unique_connector_name(
session,
SearchSourceConnectorType.TEAMS_CONNECTOR,
space_id,
user_id,
connector_identifier,
)
# Create new connector
new_connector = SearchSourceConnector(
name=connector_name,
connector_type=SearchSourceConnectorType.TEAMS_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
user_id=user_id,
)
try:
session.add(new_connector)
await session.commit()
await session.refresh(new_connector)
logger.info(
"Successfully created Microsoft Teams connector %s for user %s",
new_connector.id,
user_id,
)
# Redirect to frontend with success
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?success=teams_connected&connector_id={new_connector.id}"
return RedirectResponse(url=redirect_url)
except IntegrityError as e:
await session.rollback()
logger.error(
"Database integrity error creating Teams connector: %s", str(e)
)
redirect_url = (
f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed"
)
return RedirectResponse(url=redirect_url)
except HTTPException:
raise
except (IntegrityError, ValueError) as e:
logger.error("Teams OAuth callback error: %s", str(e), exc_info=True)
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=teams_auth_error"
return RedirectResponse(url=redirect_url)
async def refresh_teams_token(
session: AsyncSession, connector: SearchSourceConnector
) -> SearchSourceConnector:
"""
Refresh Microsoft Teams OAuth tokens.
Args:
session: Database session
connector: The connector to refresh
Returns:
Updated connector with refreshed tokens
Raises:
HTTPException: If token refresh fails
"""
logger.info(
"Refreshing Microsoft Teams OAuth tokens for connector %s", connector.id
)
credentials = TeamsAuthCredentialsBase.from_dict(connector.config)
# Decrypt tokens if they are encrypted
token_encryption = get_token_encryption()
is_encrypted = connector.config.get("_token_encrypted", False)
refresh_token = credentials.refresh_token
if is_encrypted and refresh_token:
try:
refresh_token = token_encryption.decrypt_token(refresh_token)
except Exception as e:
logger.error("Failed to decrypt refresh token: %s", str(e))
raise HTTPException(
status_code=500, detail="Failed to decrypt stored refresh token"
) from e
if not refresh_token:
raise HTTPException(
status_code=400,
detail=f"No refresh token available for connector {connector.id}",
)
# Microsoft uses oauth2/v2.0/token for token refresh
refresh_data = {
"client_id": config.TEAMS_CLIENT_ID,
"client_secret": config.TEAMS_CLIENT_SECRET,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": " ".join(SCOPES),
}
async with httpx.AsyncClient() as client:
token_response = await client.post(
TOKEN_URL,
data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
if token_response.status_code != 200:
error_detail = token_response.text
try:
error_json = token_response.json()
error_detail = error_json.get("error_description", error_detail)
except Exception:
pass
raise HTTPException(
status_code=400, detail=f"Token refresh failed: {error_detail}"
)
token_json = token_response.json()
# Extract new tokens
access_token = token_json.get("access_token")
new_refresh_token = token_json.get("refresh_token")
if not access_token:
raise HTTPException(
status_code=400, detail="No access token received from Microsoft refresh"
)
# Calculate expiration time (UTC, tz-aware)
expires_at = None
expires_in = token_json.get("expires_in")
if expires_in:
now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(expires_in))
# Update credentials object with encrypted tokens
credentials.access_token = token_encryption.encrypt_token(access_token)
if new_refresh_token:
credentials.refresh_token = token_encryption.encrypt_token(new_refresh_token)
credentials.expires_in = expires_in
credentials.expires_at = expires_at
credentials.scope = token_json.get("scope")
# Preserve tenant/user info
if not credentials.tenant_id:
credentials.tenant_id = connector.config.get("tenant_id")
if not credentials.tenant_name:
credentials.tenant_name = connector.config.get("tenant_name")
if not credentials.user_id:
credentials.user_id = connector.config.get("user_id")
# Update connector config with encrypted tokens
credentials_dict = credentials.to_dict()
credentials_dict["_token_encrypted"] = True
connector.config = credentials_dict
await session.commit()
await session.refresh(connector)
logger.info(
"Successfully refreshed Microsoft Teams tokens for connector %s", connector.id
)
return connector

View file

@ -0,0 +1,26 @@
"""
Schemas for Surfsense documentation.
"""
from pydantic import BaseModel, ConfigDict
class SurfsenseDocsChunkRead(BaseModel):
"""Schema for a Surfsense docs chunk."""
id: int
content: str
model_config = ConfigDict(from_attributes=True)
class SurfsenseDocsDocumentWithChunksRead(BaseModel):
"""Schema for a Surfsense docs document with its chunks."""
id: int
title: str
source: str
content: str
chunks: list[SurfsenseDocsChunkRead]
model_config = ConfigDict(from_attributes=True)

View file

@ -0,0 +1,79 @@
"""
Microsoft Teams OAuth credentials schema.
"""
from datetime import UTC, datetime
from pydantic import BaseModel, field_validator
class TeamsAuthCredentialsBase(BaseModel):
"""Microsoft Teams OAuth credentials."""
access_token: str
refresh_token: str | None = None
token_type: str = "Bearer"
expires_in: int | None = None
expires_at: datetime | None = None
scope: str | None = None
tenant_id: str | None = None
tenant_name: str | None = None
user_id: str | None = None
@property
def is_expired(self) -> bool:
"""Check if the credentials have expired."""
if self.expires_at is None:
return False
return self.expires_at <= datetime.now(UTC)
@property
def is_refreshable(self) -> bool:
"""Check if the credentials can be refreshed."""
return self.refresh_token is not None
def to_dict(self) -> dict:
"""Convert credentials to dictionary for storage."""
return {
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"token_type": self.token_type,
"expires_in": self.expires_in,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"scope": self.scope,
"tenant_id": self.tenant_id,
"tenant_name": self.tenant_name,
"user_id": self.user_id,
}
@classmethod
def from_dict(cls, data: dict) -> "TeamsAuthCredentialsBase":
"""Create credentials from dictionary."""
expires_at = None
if data.get("expires_at"):
expires_at = datetime.fromisoformat(data["expires_at"])
return cls(
access_token=data.get("access_token", ""),
refresh_token=data.get("refresh_token"),
token_type=data.get("token_type", "Bearer"),
expires_in=data.get("expires_in"),
expires_at=expires_at,
scope=data.get("scope"),
tenant_id=data.get("tenant_id"),
tenant_name=data.get("tenant_name"),
user_id=data.get("user_id"),
)
@field_validator("expires_at", mode="before")
@classmethod
def ensure_aware_utc(cls, v):
"""Ensure datetime is timezone-aware (UTC)."""
if isinstance(v, str):
if v.endswith("Z"):
return datetime.fromisoformat(v.replace("Z", "+00:00"))
dt = datetime.fromisoformat(v)
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
if isinstance(v, datetime):
return v if v.tzinfo else v.replace(tzinfo=UTC)
return v

View file

@ -2269,6 +2269,80 @@ class ConnectorService:
return result_object, discord_docs
async def search_teams(
self,
user_query: str,
search_space_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> tuple:
"""
Search for Microsoft Teams messages and return both the source information and langchain documents.
Uses combined chunk-level and document-level hybrid search with RRF fusion.
Args:
user_query: The user's query
search_space_id: The search space ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
Returns:
tuple: (sources_info, langchain_documents)
"""
teams_docs = await self._combined_rrf_search(
query_text=user_query,
search_space_id=search_space_id,
document_type="TEAMS_CONNECTOR",
top_k=top_k,
start_date=start_date,
end_date=end_date,
)
# Early return if no results
if not teams_docs:
return {
"id": 53,
"name": "Microsoft Teams",
"type": "TEAMS_CONNECTOR",
"sources": [],
}, []
def _title_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
team_name = metadata.get("team_name", "Unknown Team")
channel_name = metadata.get("channel_name", "Unknown Channel")
message_date = metadata.get("start_date", "")
title = f"Teams: {team_name} - {channel_name}"
if message_date:
title += f" ({message_date})"
return title
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
team_id = metadata.get("team_id", "")
channel_id = metadata.get("channel_id", "")
if team_id and channel_id:
return f"https://teams.microsoft.com/l/channel/{channel_id}/General?groupId={team_id}"
return ""
sources_list = self._build_chunk_sources_from_documents(
teams_docs,
title_fn=_title_fn,
url_fn=_url_fn,
description_fn=lambda chunk, _doc_info, _metadata: chunk.get("content", ""),
)
# Create result object
result_object = {
"id": 53,
"name": "Microsoft Teams",
"type": "TEAMS_CONNECTOR",
"sources": sources_list,
}
return result_object, teams_docs
async def search_luma(
self,
user_query: str,

View file

@ -564,6 +564,49 @@ async def _index_discord_messages(
)
@celery_app.task(name="index_teams_messages", bind=True)
def index_teams_messages_task(
self,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Celery task to index Microsoft Teams messages."""
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_index_teams_messages(
connector_id, search_space_id, user_id, start_date, end_date
)
)
finally:
loop.close()
async def _index_teams_messages(
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Index Microsoft Teams messages with new session."""
from app.routes.search_source_connectors_routes import (
run_teams_indexing,
)
async with get_celery_session_maker()() as session:
await run_teams_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
@celery_app.task(name="index_luma_events", bind=True)
def index_luma_events_task(
self,

View file

@ -45,8 +45,9 @@ async def index_google_calendar_events(
connector_id: ID of the Google Calendar connector
search_space_id: ID of the search space to store documents in
user_id: User ID
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future.
end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events.
Defaults to today if not provided.
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
Returns:
@ -165,8 +166,10 @@ async def index_google_calendar_events(
end_date = None
# Calculate date range
# For calendar connectors, allow future dates to index upcoming events
if start_date is None or end_date is None:
# Fall back to calculating dates based on last_indexed_at
# Default to today (users can manually select future dates if needed)
calculated_end_date = datetime.now()
# Use last_indexed_at as start date if available, otherwise use 30 days ago
@ -178,19 +181,13 @@ async def index_google_calendar_events(
else connector.last_indexed_at
)
# Check if last_indexed_at is in the future or after end_date
if last_indexed_naive > calculated_end_date:
logger.warning(
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead."
)
calculated_start_date = calculated_end_date - timedelta(days=30)
else:
calculated_start_date = last_indexed_naive
logger.info(
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
)
# Allow future dates - use last_indexed_at as start date
calculated_start_date = last_indexed_naive
logger.info(
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
)
else:
calculated_start_date = calculated_end_date - timedelta(
calculated_start_date = datetime.now() - timedelta(
days=30
) # Use 30 days as default for calendar events
logger.info(
@ -205,7 +202,7 @@ async def index_google_calendar_events(
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
)
else:
# Use provided dates
# Use provided dates (including future dates)
start_date_str = start_date
end_date_str = end_date

View file

@ -45,8 +45,9 @@ async def index_luma_events(
connector_id: ID of the Luma connector
search_space_id: ID of the search space to store documents in
user_id: User ID
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future.
end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events.
Defaults to today if not provided.
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
Returns:
@ -116,8 +117,10 @@ async def index_luma_events(
luma_client = LumaConnector(api_key=api_key)
# Calculate date range
# For calendar connectors, allow future dates to index upcoming events
if start_date is None or end_date is None:
# Fall back to calculating dates based on last_indexed_at
# Default to today (users can manually select future dates if needed)
calculated_end_date = datetime.now()
# Use last_indexed_at as start date if available, otherwise use 30 days ago
@ -129,19 +132,13 @@ async def index_luma_events(
else connector.last_indexed_at
)
# Check if last_indexed_at is in the future or after end_date
if last_indexed_naive > calculated_end_date:
logger.warning(
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead."
)
calculated_start_date = calculated_end_date - timedelta(days=30)
else:
calculated_start_date = last_indexed_naive
logger.info(
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
)
# Allow future dates - use last_indexed_at as start date
calculated_start_date = last_indexed_naive
logger.info(
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
)
else:
calculated_start_date = calculated_end_date - timedelta(days=30)
calculated_start_date = datetime.now() - timedelta(days=30)
logger.info(
f"No last_indexed_at found, using {calculated_start_date.strftime('%Y-%m-%d')} (30 days ago) as start date"
)
@ -154,7 +151,7 @@ async def index_luma_events(
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
)
else:
# Use provided dates
# Use provided dates (including future dates)
start_date_str = start_date
end_date_str = end_date

View file

@ -0,0 +1,484 @@
"""
Microsoft Teams connector indexer.
"""
from datetime import UTC
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.teams_history import TeamsHistory
from app.db import Document, DocumentType, SearchSourceConnectorType
from app.services.task_logging_service import TaskLoggingService
from app.utils.document_converters import (
create_document_chunks,
generate_content_hash,
generate_unique_identifier_hash,
)
from .base import (
build_document_metadata_markdown,
calculate_date_range,
check_document_by_unique_identifier,
get_connector_by_id,
get_current_timestamp,
logger,
update_connector_last_indexed,
)
async def index_teams_messages(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
update_last_indexed: bool = True,
) -> tuple[int, str | None]:
"""
Index Microsoft Teams messages from all accessible teams and channels.
Args:
session: Database session
connector_id: ID of the Teams connector
search_space_id: ID of the search space to store documents in
user_id: ID of the user
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
Returns:
Tuple containing (number of documents indexed, error message or None)
"""
task_logger = TaskLoggingService(session, search_space_id)
# Log task start
log_entry = await task_logger.log_task_start(
task_name="teams_messages_indexing",
source="connector_indexing_task",
message=f"Starting Microsoft Teams messages indexing for connector {connector_id}",
metadata={
"connector_id": connector_id,
"user_id": str(user_id),
"start_date": start_date,
"end_date": end_date,
},
)
try:
# Get the connector
await task_logger.log_task_progress(
log_entry,
f"Retrieving Teams connector {connector_id} from database",
{"stage": "connector_retrieval"},
)
connector = await get_connector_by_id(
session, connector_id, SearchSourceConnectorType.TEAMS_CONNECTOR
)
if not connector:
await task_logger.log_task_failure(
log_entry,
f"Connector with ID {connector_id} not found or is not a Teams connector",
"Connector not found",
{"error_type": "ConnectorNotFound"},
)
return (
0,
f"Connector with ID {connector_id} not found or is not a Teams connector",
)
# Initialize Teams client with auto-refresh support
await task_logger.log_task_progress(
log_entry,
f"Initializing Teams client for connector {connector_id}",
{"stage": "client_initialization"},
)
teams_client = TeamsHistory(session=session, connector_id=connector_id)
# Handle 'undefined' string from frontend (treat as None)
if start_date == "undefined" or start_date == "":
start_date = None
if end_date == "undefined" or end_date == "":
end_date = None
# Calculate date range
await task_logger.log_task_progress(
log_entry,
"Calculating date range for Teams indexing",
{
"stage": "date_calculation",
"provided_start_date": start_date,
"provided_end_date": end_date,
},
)
start_date_str, end_date_str = calculate_date_range(
connector, start_date, end_date, default_days_back=365
)
logger.info(
"Indexing Teams messages from %s to %s", start_date_str, end_date_str
)
await task_logger.log_task_progress(
log_entry,
f"Fetching Teams from {start_date_str} to {end_date_str}",
{
"stage": "fetch_teams",
"start_date": start_date_str,
"end_date": end_date_str,
},
)
# Get all teams
try:
teams = await teams_client.get_all_teams()
except Exception as e:
await task_logger.log_task_failure(
log_entry,
f"Failed to get Teams for connector {connector_id}",
str(e),
{"error_type": "TeamsFetchError"},
)
return 0, f"Failed to get Teams: {e!s}"
if not teams:
await task_logger.log_task_success(
log_entry,
f"No Teams found for connector {connector_id}",
{"teams_found": 0},
)
return 0, "No Teams found"
# Track the number of documents indexed
documents_indexed = 0
documents_skipped = 0
skipped_channels = []
await task_logger.log_task_progress(
log_entry,
f"Starting to process {len(teams)} Teams",
{"stage": "process_teams", "total_teams": len(teams)},
)
# Convert date strings to datetime objects for filtering
from datetime import datetime
start_datetime = None
end_datetime = None
if start_date_str:
# Parse as naive datetime and make it timezone-aware (UTC)
start_datetime = datetime.strptime(start_date_str, "%Y-%m-%d").replace(
tzinfo=UTC
)
if end_date_str:
# Parse as naive datetime, set to end of day, and make it timezone-aware (UTC)
end_datetime = datetime.strptime(end_date_str, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=UTC
)
# Process each team
for team in teams:
team_id = team.get("id")
team_name = team.get("displayName", "Unknown Team")
try:
# Get channels for this team
channels = await teams_client.get_channels_for_team(team_id)
if not channels:
logger.info("No channels found in team %s", team_name)
continue
# Process each channel in the team
for channel in channels:
channel_id = channel.get("id")
channel_name = channel.get("displayName", "Unknown Channel")
try:
# Get messages for this channel
messages = await teams_client.get_messages_from_channel(
team_id,
channel_id,
start_datetime,
end_datetime,
include_replies=True,
)
if not messages:
logger.info(
"No messages found in channel %s of team %s for the specified date range.",
channel_name,
team_name,
)
documents_skipped += 1
continue
# Process each message
for msg in messages:
# Skip deleted messages or empty content
if msg.get("deletedDateTime"):
continue
# Extract message details
message_id = msg.get("id", "")
created_datetime = msg.get("createdDateTime", "")
from_user = msg.get("from", {})
user_name = from_user.get("user", {}).get(
"displayName", "Unknown User"
)
user_email = from_user.get("user", {}).get(
"userPrincipalName", "Unknown Email"
)
# Extract message content
body = msg.get("body", {})
content_type = body.get("contentType", "text")
msg_text = body.get("content", "")
# Skip empty messages
if not msg_text or msg_text.strip() == "":
continue
# Format document metadata
metadata_sections = [
(
"METADATA",
[
f"TEAM_NAME: {team_name}",
f"TEAM_ID: {team_id}",
f"CHANNEL_NAME: {channel_name}",
f"CHANNEL_ID: {channel_id}",
f"MESSAGE_TIMESTAMP: {created_datetime}",
f"MESSAGE_USER_NAME: {user_name}",
f"MESSAGE_USER_EMAIL: {user_email}",
f"CONTENT_TYPE: {content_type}",
],
),
(
"CONTENT",
[
f"FORMAT: {content_type}",
"TEXT_START",
msg_text,
"TEXT_END",
],
),
]
# Build the document string
combined_document_string = build_document_metadata_markdown(
metadata_sections
)
# Generate unique identifier hash for this Teams message
unique_identifier = f"{team_id}_{channel_id}_{message_id}"
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.TEAMS_CONNECTOR,
unique_identifier,
search_space_id,
)
# Generate content hash
content_hash = generate_content_hash(
combined_document_string, search_space_id
)
# Check if document with this unique identifier already exists
existing_document = (
await check_document_by_unique_identifier(
session, unique_identifier_hash
)
)
if existing_document:
# Document exists - check if content has changed
if existing_document.content_hash == content_hash:
logger.info(
"Document for Teams message %s in channel %s unchanged. Skipping.",
message_id,
channel_name,
)
documents_skipped += 1
continue
else:
# Content has changed - update the existing document
logger.info(
"Content changed for Teams message %s in channel %s. Updating document.",
message_id,
channel_name,
)
# Update chunks and embedding
chunks = await create_document_chunks(
combined_document_string
)
doc_embedding = (
config.embedding_model_instance.embed(
combined_document_string
)
)
# Update existing document
existing_document.content = combined_document_string
existing_document.content_hash = content_hash
existing_document.embedding = doc_embedding
existing_document.document_metadata = {
"team_name": team_name,
"team_id": team_id,
"channel_name": channel_name,
"channel_id": channel_id,
"start_date": start_date_str,
"end_date": end_date_str,
"message_count": len(messages),
"indexed_at": datetime.now().strftime(
"%Y-%m-%d %H:%M:%S"
),
}
# Delete old chunks and add new ones
existing_document.chunks = chunks
existing_document.updated_at = (
get_current_timestamp()
)
documents_indexed += 1
logger.info(
"Successfully updated Teams message %s",
message_id,
)
continue
# Document doesn't exist - create new one
# Process chunks
chunks = await create_document_chunks(
combined_document_string
)
doc_embedding = config.embedding_model_instance.embed(
combined_document_string
)
# Create and store new document
document = Document(
search_space_id=search_space_id,
title=f"Teams - {team_name} - {channel_name}",
document_type=DocumentType.TEAMS_CONNECTOR,
document_metadata={
"team_name": team_name,
"team_id": team_id,
"channel_name": channel_name,
"channel_id": channel_id,
"start_date": start_date_str,
"end_date": end_date_str,
"message_count": len(messages),
"indexed_at": datetime.now().strftime(
"%Y-%m-%d %H:%M:%S"
),
},
content=combined_document_string,
embedding=doc_embedding,
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
updated_at=get_current_timestamp(),
)
session.add(document)
documents_indexed += 1
# Batch commit every 10 documents
if documents_indexed % 10 == 0:
logger.info(
"Committing batch: %s Teams messages processed so far",
documents_indexed,
)
await session.commit()
logger.info(
"Successfully indexed channel %s in team %s with %s messages",
channel_name,
team_name,
len(messages),
)
except Exception as e:
logger.error(
"Error processing channel %s in team %s: %s",
channel_name,
team_name,
str(e),
)
skipped_channels.append(
f"{team_name}/{channel_name} (processing error)"
)
documents_skipped += 1
continue
except Exception as e:
logger.error("Error processing team %s: %s", team_name, str(e))
continue
# Update the last_indexed_at timestamp for the connector only if requested
# and if we successfully indexed at least one document
total_processed = documents_indexed
if total_processed > 0:
await update_connector_last_indexed(session, connector, update_last_indexed)
# Final commit for any remaining documents not yet committed in batches
logger.info(
"Final commit: Total %s Teams messages processed", documents_indexed
)
await session.commit()
# Prepare result message
result_message = None
if skipped_channels:
result_message = f"Processed {total_processed} messages. Skipped {len(skipped_channels)} channels: {', '.join(skipped_channels)}"
else:
result_message = f"Processed {total_processed} messages."
# Log success
await task_logger.log_task_success(
log_entry,
f"Successfully completed Teams indexing for connector {connector_id}",
{
"messages_processed": total_processed,
"documents_indexed": documents_indexed,
"documents_skipped": documents_skipped,
"skipped_channels_count": len(skipped_channels),
"result_message": result_message,
},
)
logger.info(
"Teams indexing completed: %s new messages, %s skipped",
documents_indexed,
documents_skipped,
)
return total_processed, result_message
except SQLAlchemyError as db_error:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Database error during Teams indexing for connector {connector_id}",
str(db_error),
{"error_type": "SQLAlchemyError"},
)
logger.error("Database error: %s", str(db_error))
return 0, f"Database error: {db_error!s}"
except Exception as e:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Failed to index Teams messages for connector {connector_id}",
str(e),
{"error_type": type(e).__name__},
)
logger.error("Failed to index Teams messages: %s", str(e))
return 0, f"Failed to index Teams messages: {e!s}"

View file

@ -0,0 +1,229 @@
"""
Surfsense documentation indexer.
Indexes MDX documentation files at startup.
"""
import hashlib
import logging
import re
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import config
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
logger = logging.getLogger(__name__)
# Path to docs relative to project root
DOCS_DIR = (
Path(__file__).resolve().parent.parent.parent.parent
/ "surfsense_web"
/ "content"
/ "docs"
)
def parse_mdx_frontmatter(content: str) -> tuple[str, str]:
"""
Parse MDX file to extract frontmatter title and content.
Args:
content: Raw MDX file content
Returns:
Tuple of (title, content_without_frontmatter)
"""
# Match frontmatter between --- markers
frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n"
match = re.match(frontmatter_pattern, content, re.DOTALL)
if match:
frontmatter = match.group(1)
content_without_frontmatter = content[match.end() :]
# Extract title from frontmatter
title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE)
title = title_match.group(1).strip() if title_match else "Untitled"
# Remove quotes if present
title = title.strip("\"'")
return title, content_without_frontmatter.strip()
return "Untitled", content.strip()
def get_all_mdx_files() -> list[Path]:
"""
Get all MDX files from the docs directory.
Returns:
List of Path objects for each MDX file
"""
if not DOCS_DIR.exists():
logger.warning(f"Docs directory not found: {DOCS_DIR}")
return []
return list(DOCS_DIR.rglob("*.mdx"))
def generate_surfsense_docs_content_hash(content: str) -> str:
"""Generate SHA-256 hash for Surfsense docs content."""
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def create_surfsense_docs_chunks(content: str) -> list[SurfsenseDocsChunk]:
"""
Create chunks from Surfsense documentation content.
Args:
content: Document content to chunk
Returns:
List of SurfsenseDocsChunk objects with embeddings
"""
return [
SurfsenseDocsChunk(
content=chunk.text,
embedding=config.embedding_model_instance.embed(chunk.text),
)
for chunk in config.chunker_instance.chunk(content)
]
async def index_surfsense_docs(session: AsyncSession) -> tuple[int, int, int, int]:
"""
Index all Surfsense documentation files.
Args:
session: SQLAlchemy async session
Returns:
Tuple of (created, updated, skipped, deleted) counts
"""
created = 0
updated = 0
skipped = 0
deleted = 0
# Get all existing docs from database
existing_docs_result = await session.execute(
select(SurfsenseDocsDocument).options(
selectinload(SurfsenseDocsDocument.chunks)
)
)
existing_docs = {doc.source: doc for doc in existing_docs_result.scalars().all()}
# Track which sources we've processed
processed_sources = set()
# Get all MDX files
mdx_files = get_all_mdx_files()
logger.info(f"Found {len(mdx_files)} MDX files to index")
for mdx_file in mdx_files:
try:
source = str(mdx_file.relative_to(DOCS_DIR))
processed_sources.add(source)
# Read file content
raw_content = mdx_file.read_text(encoding="utf-8")
title, content = parse_mdx_frontmatter(raw_content)
content_hash = generate_surfsense_docs_content_hash(raw_content)
if source in existing_docs:
existing_doc = existing_docs[source]
# Check if content changed
if existing_doc.content_hash == content_hash:
logger.debug(f"Skipping unchanged: {source}")
skipped += 1
continue
# Content changed - update document
logger.info(f"Updating changed document: {source}")
# Create new chunks
chunks = create_surfsense_docs_chunks(content)
# Update document fields
existing_doc.title = title
existing_doc.content = content
existing_doc.content_hash = content_hash
existing_doc.embedding = config.embedding_model_instance.embed(content)
existing_doc.chunks = chunks
existing_doc.updated_at = datetime.now(UTC)
updated += 1
else:
# New document - create it
logger.info(f"Creating new document: {source}")
chunks = create_surfsense_docs_chunks(content)
document = SurfsenseDocsDocument(
source=source,
title=title,
content=content,
content_hash=content_hash,
embedding=config.embedding_model_instance.embed(content),
chunks=chunks,
updated_at=datetime.now(UTC),
)
session.add(document)
created += 1
except Exception as e:
logger.error(f"Error processing {mdx_file}: {e}", exc_info=True)
continue
# Delete documents for removed files
for source, doc in existing_docs.items():
if source not in processed_sources:
logger.info(f"Deleting removed document: {source}")
await session.delete(doc)
deleted += 1
# Commit all changes
await session.commit()
logger.info(
f"Indexing complete: {created} created, {updated} updated, "
f"{skipped} skipped, {deleted} deleted"
)
return created, updated, skipped, deleted
async def seed_surfsense_docs() -> tuple[int, int, int, int]:
"""
Seed Surfsense documentation into the database.
This function indexes all MDX files from the docs directory.
It handles creating, updating, and deleting docs based on content changes.
Returns:
Tuple of (created, updated, skipped, deleted) counts
Returns (0, 0, 0, 0) if an error occurs
"""
logger.info("Starting Surfsense docs indexing...")
try:
async with async_session_maker() as session:
created, updated, skipped, deleted = await index_surfsense_docs(session)
logger.info(
f"Surfsense docs indexing complete: "
f"created={created}, updated={updated}, skipped={skipped}, deleted={deleted}"
)
return created, updated, skipped, deleted
except Exception as e:
logger.error(f"Failed to seed Surfsense docs: {e}", exc_info=True)
return 0, 0, 0, 0

View file

@ -1,3 +1,4 @@
import logging
import uuid
from fastapi import Depends, Request, Response
@ -12,7 +13,17 @@ from fastapi_users.db import SQLAlchemyUserDatabase
from pydantic import BaseModel
from app.config import config
from app.db import User, get_user_db
from app.db import (
SearchSpace,
SearchSpaceMembership,
SearchSpaceRole,
User,
async_session_maker,
get_default_roles_config,
get_user_db,
)
logger = logging.getLogger(__name__)
class BearerResponse(BaseModel):
@ -36,7 +47,59 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
verification_token_secret = SECRET
async def on_after_register(self, user: User, request: Request | None = None):
print(f"User {user.id} has registered.")
"""
Called after a user registers. Creates a default search space for the user
so they can start chatting immediately without manual setup.
"""
logger.info(f"User {user.id} has registered. Creating default search space...")
try:
async with async_session_maker() as session:
# Create default search space
default_search_space = SearchSpace(
name="My Search Space",
description="Your personal search space",
user_id=user.id,
)
session.add(default_search_space)
await session.flush() # Get the search space ID
# Create default roles
default_roles = get_default_roles_config()
owner_role_id = None
for role_config in default_roles:
db_role = SearchSpaceRole(
name=role_config["name"],
description=role_config["description"],
permissions=role_config["permissions"],
is_default=role_config["is_default"],
is_system_role=role_config["is_system_role"],
search_space_id=default_search_space.id,
)
session.add(db_role)
await session.flush()
if role_config["name"] == "Owner":
owner_role_id = db_role.id
# Create owner membership
owner_membership = SearchSpaceMembership(
user_id=user.id,
search_space_id=default_search_space.id,
role_id=owner_role_id,
is_owner=True,
)
session.add(owner_membership)
await session.commit()
logger.info(
f"Created default search space (ID: {default_search_space.id}) for user {user.id}"
)
except Exception as e:
logger.error(
f"Failed to create default search space for user {user.id}: {e}"
)
async def on_after_forgot_password(
self, user: User, token: str, request: Request | None = None

View file

@ -20,6 +20,7 @@ BASE_NAME_FOR_TYPE = {
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive",
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
SearchSourceConnectorType.SLACK_CONNECTOR: "Slack",
SearchSourceConnectorType.TEAMS_CONNECTOR: "Microsoft Teams",
SearchSourceConnectorType.NOTION_CONNECTOR: "Notion",
SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear",
SearchSourceConnectorType.JIRA_CONNECTOR: "Jira",
@ -53,6 +54,9 @@ def extract_identifier_from_credentials(
if connector_type == SearchSourceConnectorType.SLACK_CONNECTOR:
return credentials.get("team_name")
if connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR:
return credentials.get("tenant_name")
if connector_type == SearchSourceConnectorType.NOTION_CONNECTOR:
return credentials.get("workspace_name")

View file

@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
# Mapping of connector types to their corresponding Celery task names
CONNECTOR_TASK_MAP = {
SearchSourceConnectorType.SLACK_CONNECTOR: "index_slack_messages",
SearchSourceConnectorType.TEAMS_CONNECTOR: "index_teams_messages",
SearchSourceConnectorType.NOTION_CONNECTOR: "index_notion_pages",
SearchSourceConnectorType.GITHUB_CONNECTOR: "index_github_repos",
SearchSourceConnectorType.LINEAR_CONNECTOR: "index_linear_issues",

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python
"""
Seed Surfsense documentation into the database.
CLI wrapper for the seed_surfsense_docs function.
Can be run manually for debugging or re-indexing.
Usage:
python scripts/seed_surfsense_docs.py
"""
import asyncio
import sys
from pathlib import Path
# Add the parent directory to the path so we can import app modules
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
def main():
"""CLI entry point for seeding Surfsense docs."""
print("=" * 50)
print(" Surfsense Documentation Seeding")
print("=" * 50)
created, updated, skipped, deleted = asyncio.run(seed_surfsense_docs())
print()
print("Results:")
print(f" Created: {created}")
print(f" Updated: {updated}")
print(f" Skipped: {skipped}")
print(f" Deleted: {deleted}")
print("=" * 50)
if __name__ == "__main__":
main()

View file

@ -5,7 +5,7 @@ import { Navbar } from "@/components/homepage/navbar";
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar />
{children}
<FooterNew />

View file

@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
@ -17,22 +17,18 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
export function DashboardClientLayout({
children,
searchSpaceId,
navSecondary,
navMain,
}: {
children: React.ReactNode;
searchSpaceId: string;
navSecondary: any[];
navMain: any[];
navSecondary?: any[];
navMain?: any[];
}) {
const t = useTranslations("dashboard");
const router = useRouter();
@ -59,50 +55,15 @@ export function DashboardClientLayout({
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
const hasAttemptedAutoConfig = useRef(false);
// Skip onboarding check if we're already on the onboarding page
const isOnboardingPage = pathname?.includes("/onboard");
// Only owners should see onboarding - invited members use existing config
const isOwner = access?.is_owner ?? false;
// Translate navigation items
const tNavMenu = useTranslations("nav_menu");
const translatedNavMain = useMemo(() => {
return navMain.map((item) => ({
...item,
title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")),
items: item.items?.map((subItem: any) => ({
...subItem,
title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")),
})),
}));
}, [navMain, tNavMenu]);
const translatedNavSecondary = useMemo(() => {
return navSecondary.map((item) => ({
...item,
title: item.title === "All Search Spaces" ? tNavMenu("all_search_spaces") : item.title,
}));
}, [navSecondary, tNavMenu]);
const [open, setOpen] = useState<boolean>(() => {
try {
const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/);
if (match) return match[1] === "true";
} catch {
// ignore
}
return true;
});
useEffect(() => {
// Skip check if already on onboarding page
if (isOnboardingPage) {
setHasCheckedOnboarding(true);
return;
}
// Wait for all data to load
if (
!loading &&
!accessLoading &&
@ -112,19 +73,16 @@ export function DashboardClientLayout({
) {
const onboardingComplete = isOnboardingComplete();
// If onboarding is complete, nothing to do
if (onboardingComplete) {
setHasCheckedOnboarding(true);
return;
}
// Only handle onboarding for owners
if (!isOwner) {
setHasCheckedOnboarding(true);
return;
}
// If global configs available, auto-configure without going to onboard page
if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) {
hasAttemptedAutoConfig.current = true;
setIsAutoConfiguring(true);
@ -149,7 +107,6 @@ export function DashboardClientLayout({
setHasCheckedOnboarding(true);
} catch (error) {
console.error("Auto-configuration failed:", error);
// Fall back to onboard page
router.push(`/dashboard/${searchSpaceId}/onboard`);
} finally {
setIsAutoConfiguring(false);
@ -160,7 +117,6 @@ export function DashboardClientLayout({
return;
}
// No global configs - redirect to onboard page
router.push(`/dashboard/${searchSpaceId}/onboard`);
setHasCheckedOnboarding(true);
}
@ -180,7 +136,6 @@ export function DashboardClientLayout({
refetchPreferences,
]);
// Synchronize active search space and chat IDs with URL
useEffect(() => {
const activeSeacrhSpaceId =
typeof search_space_id === "string"
@ -192,7 +147,6 @@ export function DashboardClientLayout({
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
}, [search_space_id, setActiveSearchSpaceIdState]);
// Show loading screen while checking onboarding status or auto-configuring
if (
(!hasCheckedOnboarding &&
(loading || accessLoading || globalConfigsLoading) &&
@ -220,7 +174,6 @@ export function DashboardClientLayout({
);
}
// Show error screen if there's an error loading preferences (but not on onboarding page)
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
@ -244,33 +197,13 @@ export function DashboardClientLayout({
return (
<DocumentUploadDialogProvider>
<OnboardingTour />
<SidebarProvider className="h-full overflow-hidden" open={open} onOpenChange={setOpen}>
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
<AppSidebarProvider
searchSpaceId={searchSpaceId}
navSecondary={translatedNavSecondary}
navMain={translatedNavMain}
/>
<SidebarInset className="h-full ">
<main className="flex flex-col h-full">
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<div className="hidden md:flex items-center gap-2">
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
</div>
<div className="flex items-center gap-2">
<LanguageSwitcher />
</div>
</div>
</header>
<div className="flex-1 overflow-hidden">{children}</div>
</main>
</SidebarInset>
</SidebarProvider>
<LayoutDataProvider
searchSpaceId={searchSpaceId}
breadcrumb={<DashboardBreadcrumb />}
languageSwitcher={<LanguageSwitcher />}
>
{children}
</LayoutDataProvider>
</DocumentUploadDialogProvider>
);
}

View file

@ -87,7 +87,8 @@ function SettingsSidebar({
<aside
className={cn(
"fixed md:relative left-0 top-0 z-50 md:z-auto",
"w-72 shrink-0 bg-background md:bg-muted/20 h-full flex flex-col",
"w-72 shrink-0 bg-background md:bg-muted/30 h-full flex flex-col",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
@ -177,7 +178,7 @@ function SettingsSidebar({
{/* Footer */}
<div className="p-4">
<p className="text-xs text-muted-foreground text-center">SurfSense Settings</p>
<p className="text-xs text-muted-foreground text-center">Search Space Settings</p>
</div>
</aside>
</>
@ -286,20 +287,24 @@ export default function SettingsPage() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="h-full flex bg-background"
className="fixed inset-0 z-50 flex bg-muted/40"
>
<SettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
<SettingsContent
activeSection={activeSection}
searchSpaceId={searchSpaceId}
onMenuClick={() => setIsSidebarOpen(true)}
/>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<SettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
<SettingsContent
activeSection={activeSection}
searchSpaceId={searchSpaceId}
onMenuClick={() => setIsSidebarOpen(true)}
/>
</div>
</div>
</motion.div>
);
}

View file

@ -3,7 +3,6 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
ArrowLeft,
Calendar,
Check,
Clock,
@ -27,7 +26,7 @@ import {
Users,
} from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@ -144,7 +143,6 @@ const cardVariants = {
};
export default function TeamManagementPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const [activeTab, setActiveTab] = useState("members");
@ -334,14 +332,6 @@ export default function TeamManagementPage() {
<div className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start space-x-3 md:items-center md:space-x-4">
<button
onClick={() => router.push(`/dashboard/${searchSpaceId}`)}
className="flex items-center justify-center h-9 w-9 md:h-10 md:w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors shrink-0"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-4 w-4 md:h-5 md:w-5 text-primary" />
</button>
<div className="flex h-10 w-10 md:h-12 md:w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10 shrink-0">
<Users className="h-5 w-5 md:h-6 md:w-6 text-primary" />
</div>

View file

@ -1,185 +0,0 @@
"use client";
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
import { ArrowLeft } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
const fadeIn = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } },
};
const staggerContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const ApiKeyClient = () => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const router = useRouter();
return (
<div className="flex justify-center w-full min-h-screen py-10 px-4">
<motion.div
className="w-full max-w-3xl"
initial="hidden"
animate="visible"
variants={staggerContainer}
>
<motion.div className="mb-8 text-center" variants={fadeIn}>
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
<p className="text-muted-foreground mt-2">
Your API key for authenticating with the SurfSense API.
</p>
</motion.div>
<motion.div variants={fadeIn}>
<Alert className="mb-8">
<IconKey className="h-4 w-4" />
<AlertTitle>Important</AlertTitle>
<AlertDescription>
Your API key grants full access to your account. Never share it publicly or with
unauthorized users.
</AlertDescription>
</Alert>
</motion.div>
<motion.div variants={fadeIn}>
<Card>
<CardHeader className="text-center">
<CardTitle>Your API Key</CardTitle>
<CardDescription>Use this key to authenticate your API requests.</CardDescription>
</CardHeader>
<CardContent>
<AnimatePresence mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-10 w-full bg-muted animate-pulse rounded-md"
/>
) : apiKey ? (
<motion.div
key="api-key"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="flex items-center space-x-2"
>
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{apiKey}
</motion.div>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="flex-shrink-0"
>
<motion.div
whileTap={{ scale: 0.9 }}
animate={copied ? { scale: [1, 1.2, 1] } : {}}
transition={{ duration: 0.2 }}
>
{copied ? (
<IconCheck className="h-4 w-4" />
) : (
<IconCopy className="h-4 w-4" />
)}
</motion.div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
) : (
<motion.div
key="no-key"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-muted-foreground text-center"
>
No API key found.
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
</motion.div>
<motion.div
className="mt-8"
variants={fadeIn}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
<Card>
<CardContent className="pt-6">
<motion.div
className="space-y-4"
initial="hidden"
animate="visible"
variants={staggerContainer}
>
<motion.div variants={fadeIn}>
<h3 className="font-medium mb-2 text-center">Authentication</h3>
<p className="text-sm text-muted-foreground text-center">
Include your API key in the Authorization header of your requests:
</p>
<motion.pre
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }}
>
<code className="text-xs">
Authorization: Bearer {apiKey || "YOUR_API_KEY"}
</code>
</motion.pre>
</motion.div>
</motion.div>
</CardContent>
</Card>
</motion.div>
</motion.div>
<div>
<button
onClick={() => router.push("/dashboard")}
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-5 w-5 text-primary" />
</button>
</div>
</div>
);
};
export default ApiKeyClient;

View file

@ -1,32 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
// Loading component with animation
const LoadingComponent = () => (
<div className="flex flex-col justify-center items-center min-h-screen">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="text-muted-foreground">Loading API Key Management...</p>
</div>
);
// Dynamically import the ApiKeyClient component
const ApiKeyClient = dynamic(() => import("./api-key-client"), {
ssr: false,
loading: () => <LoadingComponent />,
});
export default function ClientWrapper() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <LoadingComponent />;
}
return <ApiKeyClient />;
}

View file

@ -1,7 +0,0 @@
"use client";
import ClientWrapper from "./client-wrapper";
export default function ApiKeyPage() {
return <ClientWrapper />;
}

View file

@ -1,31 +1,14 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react";
import { motion, type Variants } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { AlertCircle, Loader2, Plus, Search } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { useEffect, useState } from "react";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown";
import { CreateSearchSpaceDialog } from "@/components/layout";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@ -35,29 +18,11 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spotlight } from "@/components/ui/spotlight";
import { Tilt } from "@/components/ui/tilt";
/**
* Formats a date string into a readable format
* @param dateString - The date string to format
* @returns Formatted date string (e.g., "Jan 1, 2023")
*/
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
/**
* Loading screen component with animation
*/
const LoadingScreen = () => {
function LoadingScreen() {
const t = useTranslations("dashboard");
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
@ -71,7 +36,7 @@ const LoadingScreen = () => {
<CardContent className="flex justify-center py-6">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
>
<Loader2 className="h-12 w-12 text-primary" />
</motion.div>
@ -83,23 +48,20 @@ const LoadingScreen = () => {
</motion.div>
</div>
);
};
}
/**
* Error screen component with animation
*/
const ErrorScreen = ({ message }: { message: string }) => {
function ErrorScreen({ message }: { message: string }) {
const t = useTranslations("dashboard");
const router = useRouter();
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="w-full max-w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<Card className="w-full max-w-[400px] border-destructive/20 bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
@ -108,7 +70,7 @@ const ErrorScreen = ({ message }: { message: string }) => {
<CardDescription>{t("something_wrong")}</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
<Alert variant="destructive" className="border-destructive/30 bg-destructive/10">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("error_details")}</AlertTitle>
<AlertDescription className="mt-2">{message}</AlertDescription>
@ -124,258 +86,62 @@ const ErrorScreen = ({ message }: { message: string }) => {
</motion.div>
</div>
);
};
}
const DashboardPage = () => {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
// Animation variants
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants: Variants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
type: "spring",
stiffness: 300,
damping: 24,
},
},
};
const {
data: searchSpaces = [],
isLoading: loading,
error,
refetch: refreshSearchSpaces,
} = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom);
// Create user object for UserDropdown
const customUser = {
name: user?.email ? user.email.split("@")[0] : "User",
email:
user?.email ||
(isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
avatar: "/icon-128.png", // Default avatar
};
if (loading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
const handleDeleteSearchSpace = async (id: number) => {
await deleteSearchSpace({ id });
refreshSearchSpaces();
};
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
const t = useTranslations("searchSpace");
return (
<motion.div
className="container mx-auto py-6 md:py-10 px-4"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div className="flex flex-col space-y-4 md:space-y-6" variants={itemVariants}>
<div className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center md:space-x-4">
<Logo className="w-8 h-8 md:w-10 md:h-10 rounded-md shrink-0 hidden md:block" />
<div className="flex flex-col space-y-0.5 md:space-y-2">
<h1 className="text-xl md:text-4xl font-bold">{t("surfsense_dashboard")}</h1>
<p className="text-sm md:text-base text-muted-foreground">{t("welcome_message")}</p>
</div>
</div>
<div className="flex items-center space-x-2 md:space-x-3 shrink-0">
<UserDropdown user={customUser} />
<ThemeTogglerComponent />
</div>
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Search className="h-10 w-10 text-primary" />
</div>
<div className="flex flex-col space-y-6 mt-6">
<div className="flex justify-between items-center">
<h2 className="text-lg md:text-2xl font-semibold">{t("your_search_spaces")}</h2>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Link href="/dashboard/searchspaces">
<Button className="h-8 md:h-10 text-[11px] md:text-sm px-3 md:px-4">
<Plus className="mr-1 md:mr-2 h-3 w-3 md:h-4 md:w-4" />
{t("create_search_space")}
</Button>
</Link>
</motion.div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{searchSpaces &&
searchSpaces.length > 0 &&
searchSpaces.map((space) => (
<motion.div key={space.id} variants={itemVariants} className="aspect-4/3">
<Tilt
rotationFactor={6}
isRevese
springOptions={{
stiffness: 26.7,
damping: 4.1,
mass: 0.2,
}}
className="group relative rounded-lg h-full"
>
<Spotlight
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
size={248}
springOptions={{
stiffness: 26.7,
damping: 4.1,
mass: 0.2,
}}
/>
<div className="flex flex-col h-full justify-between overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
<div className="relative h-32 w-full overflow-hidden">
<Link href={`/dashboard/${space.id}/new-chat`} key={space.id}>
<Image
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
alt={space.name}
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
width={248}
height={248}
/>
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
</Link>
<div className="absolute top-2 right-2">
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_space_confirm", { name: space.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteSearchSpace(space.id)}
className="bg-destructive hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
<Link
className="flex flex-1 flex-col p-4 cursor-pointer"
href={`/dashboard/${space.id}/new-chat`}
key={space.id}
>
<div className="flex flex-1 flex-col justify-between p-1">
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-base md:text-lg">{space.name}</h3>
{!space.is_owner && (
<Badge
variant="secondary"
className="text-[10px] md:text-xs font-normal"
>
{t("shared")}
</Badge>
)}
</div>
<p className="mt-1 text-xs md:text-sm text-muted-foreground">
{space.description}
</p>
</div>
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("created")} {formatDate(space.created_at)}
</span>
<div className="flex items-center gap-1">
{space.is_owner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
<span>{space.member_count}</span>
</div>
</div>
</div>
</Link>
</div>
</Tilt>
</motion.div>
))}
{searchSpaces.length === 0 && (
<motion.div
variants={itemVariants}
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
>
<div className="rounded-full bg-muted/50 p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-base md:text-lg font-medium mb-2">{t("no_spaces_found")}</h3>
<p className="text-xs md:text-sm text-muted-foreground mb-6">
{t("create_first_space")}
</p>
<Link href="/dashboard/searchspaces">
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("create_search_space")}
</Button>
</Link>
</motion.div>
)}
{searchSpaces.length > 0 && (
<motion.div variants={itemVariants} className="aspect-[4/3]">
<Tilt
rotationFactor={6}
isRevese
springOptions={{
stiffness: 26.7,
damping: 4.1,
mass: 0.2,
}}
className="group relative rounded-lg h-full"
>
<Link href="/dashboard/searchspaces" className="flex h-full">
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
<Plus className="h-8 w-8 md:h-10 md:w-10 mb-2 md:mb-3 text-muted-foreground" />
<span className="text-xs md:text-sm font-medium">
{t("add_new_search_space")}
</span>
</div>
</Link>
</Tilt>
</motion.div>
)}
</div>
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">{t("welcome_title")}</h1>
<p className="max-w-md text-muted-foreground">{t("welcome_description")}</p>
</div>
<Button size="lg" onClick={onCreateClick} className="gap-2">
<Plus className="h-5 w-5" />
{t("create_first_button")}
</Button>
</motion.div>
</motion.div>
</div>
);
};
}
export default DashboardPage;
export default function DashboardPage() {
const router = useRouter();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
useEffect(() => {
if (isLoading) return;
if (searchSpaces.length > 0) {
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
}
}, [isLoading, searchSpaces, router]);
if (isLoading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
if (searchSpaces.length > 0) {
return <LoadingScreen />;
}
return (
<>
<EmptyState onCreateClick={() => setShowCreateDialog(true)} />
<CreateSearchSpaceDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
</>
);
}

View file

@ -1,41 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { SearchSpaceForm } from "@/components/search-space-form";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
export default function SearchSpacesPage() {
const router = useRouter();
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
const handleCreateSearchSpace = async (data: { name: string; description?: string }) => {
const result = await createSearchSpace({
name: data.name,
description: data.description || "",
});
// Track search space creation
trackSearchSpaceCreated(result.id, data.name);
// Redirect to the newly created search space's onboarding
router.push(`/dashboard/${result.id}/onboard`);
return result;
};
return (
<motion.div
className="mx-auto max-w-5xl px-4 py-6 lg:py-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="mx-auto max-w-5xl">
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
</div>
</motion.div>
);
}

View file

@ -0,0 +1,317 @@
"use client";
import {
ArrowLeft,
Check,
ChevronRight,
Copy,
Key,
type LucideIcon,
Menu,
Shield,
X,
} from "lucide-react";
import { AnimatePresence, 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 (
<>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
<aside
className={cn(
"fixed left-0 top-0 z-50 md:relative md:z-auto",
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
<div className="flex items-center justify-between p-4">
<Button
variant="ghost"
onClick={onBackToApp}
className="group h-11 flex-1 justify-start gap-3 px-3 hover:bg-muted"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="userSettingsActiveIndicator"
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{item.label}
</p>
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "translate-x-0 text-primary opacity-100"
: "-translate-x-1 text-muted-foreground/40 opacity-0"
)}
/>
</motion.button>
);
})}
</nav>
<div className="p-4">
<p className="text-center text-xs text-muted-foreground">{t("footer")}</p>
</div>
</aside>
</>
);
}
function ApiKeyContent({ onMenuClick }: { onMenuClick: () => void }) {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="api-key-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("api_key_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const [activeSection, setActiveSection] = useState("api-key");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const navItems: SettingsNavItem[] = [
{
id: "api-key",
label: t("api_key_nav_label"),
description: t("api_key_nav_description"),
icon: Key,
},
];
const handleBackToApp = useCallback(() => {
router.back();
}, [router]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex bg-muted/40"
>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<UserSettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
navItems={navItems}
/>
{activeSection === "api-key" && (
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
</div>
</div>
</motion.div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -379,7 +379,7 @@ export default function InviteAcceptPage() {
href="/"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<Image src="/icon-128.png" alt="SurfSense" width={24} height={24} className="rounded" />
<Image src="/icon-128.svg" alt="SurfSense" width={24} height={24} className="rounded" />
<span className="text-sm font-medium">SurfSense</span>
</Link>
</motion.div>

View file

@ -14,159 +14,165 @@ export default function sitemap(): MetadataRoute.Sitemap {
{
url: "https://www.surfsense.com/",
lastModified,
changeFrequency: "yearly",
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/contact",
lastModified,
changeFrequency: "yearly",
priority: 1,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/pricing",
lastModified,
changeFrequency: "yearly",
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/privacy",
lastModified,
changeFrequency: "monthly",
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/terms",
lastModified,
changeFrequency: "monthly",
changeFrequency: "daily",
priority: 0.9,
},
// Documentation pages
{
url: "https://www.surfsense.com/docs",
lastModified,
changeFrequency: "weekly",
priority: 0.9,
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/docs/installation",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/manual-installation",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.9,
},
// Connector documentation
{
url: "https://www.surfsense.com/docs/connectors/airtable",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/bookstack",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/circleback",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/clickup",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/confluence",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/discord",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/elasticsearch",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/github",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/gmail",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-calendar",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-drive",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/jira",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/linear",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/luma",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-teams",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/notion",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/slack",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/web-crawler",
lastModified,
changeFrequency: "weekly",
changeFrequency: "daily",
priority: 0.8,
},
];

View file

@ -1,11 +1,14 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { userApiService } from "@/lib/apis/user-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const currentUserAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.user.current(),
staleTime: 5 * 60 * 1000, // 5 minutes
// Only fetch user data when a bearer token is present
enabled: !!getBearerToken(),
queryFn: async () => {
return userApiService.getMe();
},

View file

@ -7,7 +7,13 @@ import { cn } from "@/lib/utils";
export const Logo = ({ className }: { className?: string }) => {
return (
<Link href="/">
<Image src="/icon-128.png" className={cn(className)} alt="logo" width={128} height={128} />
<Image
src="/icon-128.svg"
className={cn("dark:invert", className)}
alt="logo"
width={128}
height={128}
/>
</Link>
);
};

View file

@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
@ -25,7 +25,6 @@ const TokenHandler = ({
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
@ -58,14 +57,14 @@ const TokenHandler = ({
const finalRedirectPath = savedRedirectPath || redirectPath;
// Redirect to the appropriate path
router.push(finalRedirectPath);
window.location.href = finalRedirectPath;
} catch (error) {
console.error("Error storing token in localStorage:", error);
// Even if there's an error, try to redirect to the default path
router.push(redirectPath);
window.location.href = redirectPath;
}
}
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
}, [searchParams, tokenParamName, storageKey, redirectPath]);
return (
<div className="flex items-center justify-center min-h-[200px]">

View file

@ -34,14 +34,14 @@ export function UserDropdown({
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");
window.location.href = "/";
}
} catch (error) {
console.error("Error during logout:", error);
// Optionally, provide user feedback
if (typeof window !== "undefined") {
alert("Logout failed. Please try again.");
router.push("/");
window.location.href = "/";
}
}
};

View file

@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { LogActiveTask } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { ConnectorStatusBadge } from "./connector-status-badge";
interface ConnectorCardProps {
id: string;
@ -104,6 +106,15 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
onConnect,
onManage,
}) => {
// Get connector status
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
useConnectorStatus();
const status = getConnectorStatus(connectorType);
const isEnabled = isConnectorEnabled(connectorType);
const statusMessage = getConnectorStatusMessage(connectorType);
const showWarnings = shouldShowWarnings();
// Extract count from active task message during indexing
const indexingCount = extractIndexedCount(activeTask?.message);
@ -139,9 +150,23 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
return description;
};
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10">
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
const cardContent = (
<div
className={cn(
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
status.status === "warning"
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
status.status === "warning"
? "bg-yellow-500/10 border-yellow-500/20 bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
@ -151,8 +176,15 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
{showWarnings && status.status !== "active" && (
<ConnectorStatusBadge
status={status.status}
statusMessage={statusMessage}
className="flex-shrink-0"
/>
)}
</div>
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
@ -179,10 +211,12 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting}
disabled={isConnecting || !isEnabled}
>
{isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : !isEnabled ? (
"Unavailable"
) : isConnected ? (
"Manage"
) : id === "youtube-crawler" ? (
@ -195,4 +229,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</Button>
</div>
);
return cardContent;
};

View file

@ -0,0 +1,92 @@
"use client";
import { AlertTriangle, Ban, Wrench } from "lucide-react";
import type { FC } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ConnectorStatus } from "../config/connector-status-config";
interface ConnectorStatusBadgeProps {
status: ConnectorStatus;
statusMessage?: string | null;
className?: string;
}
export const ConnectorStatusBadge: FC<ConnectorStatusBadgeProps> = ({
status,
statusMessage,
className,
}) => {
if (status === "active") {
return null;
}
const getBadgeConfig = () => {
switch (status) {
case "warning":
return {
icon: AlertTriangle,
className: "text-yellow-500 dark:text-yellow-400",
defaultTitle: "Warning",
};
case "disabled":
return {
icon: Ban,
className: "text-red-500 dark:text-red-400",
defaultTitle: "Disabled",
};
case "maintenance":
return {
icon: Wrench,
className: "text-orange-500 dark:text-orange-400",
defaultTitle: "Maintenance",
};
case "deprecated":
return {
icon: AlertTriangle,
className: "ext-slate-500 dark:text-slate-400",
defaultTitle: "Deprecated",
};
default:
return null;
}
};
const config = getBadgeConfig();
if (!config) return null;
const Icon = config.icon;
// Show statusMessage in tooltip for warning, deprecated, disabled, and maintenance statuses
const shouldUseTooltip =
(status === "warning" ||
status === "deprecated" ||
status === "disabled" ||
status === "maintenance") &&
statusMessage;
const tooltipTitle = shouldUseTooltip ? statusMessage : config.defaultTitle;
// Use Tooltip component for statuses with statusMessage, native title for others
if (shouldUseTooltip) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex items-center justify-center shrink-0", className)}>
<Icon className={cn("size-3.5", config.className)} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{statusMessage}
</TooltipContent>
</Tooltip>
);
}
return (
<span
className={cn("inline-flex items-center justify-center shrink-0", className)}
title={tooltipTitle}
>
<Icon className={cn("size-3.5", config.className)} />
</span>
);
};

View file

@ -0,0 +1,56 @@
"use client";
import { AlertTriangle, X } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface ConnectorWarningBannerProps {
warning: string;
statusMessage?: string | null;
onDismiss?: () => void;
className?: string;
}
export const ConnectorWarningBanner: FC<ConnectorWarningBannerProps> = ({
warning,
statusMessage,
onDismiss,
className,
}) => {
const [isDismissed, setIsDismissed] = useState(false);
if (isDismissed) return null;
const handleDismiss = () => {
setIsDismissed(true);
onDismiss?.();
};
return (
<div
className={cn(
"flex items-start gap-3 p-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 dark:bg-yellow-500/5 mb-4",
className
)}
>
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-[12px] font-medium text-yellow-900 dark:text-yellow-200">{warning}</p>
{statusMessage && (
<p className="text-[11px] text-yellow-700 dark:text-yellow-300 mt-1">{statusMessage}</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={handleDismiss}
className="shrink-0 p-0.5 rounded hover:bg-yellow-500/20 transition-colors"
aria-label="Dismiss warning"
>
<X className="size-3.5 text-yellow-700 dark:text-yellow-300" />
</button>
)}
</div>
);
};

View file

@ -1,6 +1,6 @@
"use client";
import { format, subDays, subYears } from "date-fns";
import { addDays, format, subDays, subYears } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
@ -14,6 +14,7 @@ interface DateRangeSelectorProps {
endDate: Date | undefined;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
allowFutureDates?: boolean; // Allow future dates for calendar connectors
}
export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
@ -21,6 +22,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
endDate,
onStartDateChange,
onEndDateChange,
allowFutureDates = false,
}) => {
const handleLast30Days = () => {
const today = new Date();
@ -28,6 +30,12 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
onEndDateChange(today);
};
const handleNext30Days = () => {
const today = new Date();
onStartDateChange(today);
onEndDateChange(addDays(today, 30));
};
const handleLastYear = () => {
const today = new Date();
onStartDateChange(subYears(today, 1));
@ -43,8 +51,9 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<h3 className="font-medium text-sm sm:text-base mb-4">Select Date Range</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-6">
Choose how far back you want to sync your data. You can always re-index later with different
dates.
{allowFutureDates
? "Choose the date range to sync your data. You can select future dates to index upcoming events."
: "Choose how far back you want to sync your data. You can always re-index later with different dates."}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -72,7 +81,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
mode="single"
selected={startDate}
onSelect={onStartDateChange}
disabled={(date) => date > new Date()}
disabled={allowFutureDates ? false : (date) => date > new Date()}
/>
</PopoverContent>
</Popover>
@ -102,7 +111,11 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
mode="single"
selected={endDate}
onSelect={onEndDateChange}
disabled={(date) => date > new Date() || (startDate ? date < startDate : false)}
disabled={
allowFutureDates
? (date) => (startDate ? date < startDate : false)
: (date) => date > new Date() || (startDate ? date < startDate : false)
}
/>
</PopoverContent>
</Popover>
@ -129,6 +142,17 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
>
Last 30 Days
</Button>
{allowFutureDates && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleNext30Days}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
>
Next 30 Days
</Button>
)}
<Button
type="button"
variant="outline"

View file

@ -0,0 +1,28 @@
{
"connectorStatuses": {
"SLACK_CONNECTOR": {
"enabled": false,
"status": "disabled",
"statusMessage": "Unavailable due to API changes"
},
"NOTION_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Rate limits may apply"
},
"TEAMS_CONNECTOR": {
"enabled": false,
"status": "maintenance",
"statusMessage": "Temporarily unavailable for maintenance"
},
"JIRA_CONNECTOR": {
"enabled": false,
"status": "deprecated",
"statusMessage": "Deprecated"
}
},
"globalSettings": {
"showWarnings": true,
"allowManualOverride": false
}
}

View file

@ -0,0 +1,38 @@
{
"connectorStatuses": {
"GOOGLE_DRIVE_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"GOOGLE_GMAIL_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"GOOGLE_CALENDAR_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
},
"YOUTUBE_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Doesn't work on cloud version due to YouTube blocks. Will be fixed soon."
},
"WEBCRAWLER_CONNECTOR": {
"enabled": true,
"status": "warning",
"statusMessage": "Some requests may be blocked if not using Firecrawl."
},
"GITHUB_CONNECTOR": {
"enabled": false,
"status": "maintenance",
"statusMessage": "Rework in progress."
}
},
"globalSettings": {
"showWarnings": true,
"allowManualOverride": false
}
}

View file

@ -0,0 +1,74 @@
/**
* Connector Status Configuration
*
* Manages connector statuses (disable/enable, status messages). Edit connector-status-config.json to configure.
* Valid status values: "active", "warning", "disabled", "deprecated", "maintenance".
* Unlisted connectors default to "active" and enabled. See connector-status-config.example.json for reference.
*/
import { z } from "zod";
import rawConnectorStatusConfigData from "./connector-status-config.json";
// Zod schemas for runtime validation and type safety
export const connectorStatusSchema = z.enum([
"active",
"warning",
"disabled",
"deprecated",
"maintenance",
]);
export const connectorStatusConfigSchema = z.object({
enabled: z.boolean(),
status: connectorStatusSchema,
statusMessage: z.string().nullable().optional(),
});
export const connectorStatusMapSchema = z.record(z.string(), connectorStatusConfigSchema);
export const connectorStatusConfigFileSchema = z.object({
connectorStatuses: connectorStatusMapSchema,
globalSettings: z.object({
showWarnings: z.boolean(),
allowManualOverride: z.boolean(),
}),
});
// TypeScript types inferred from Zod schemas
export type ConnectorStatus = z.infer<typeof connectorStatusSchema>;
export type ConnectorStatusConfig = z.infer<typeof connectorStatusConfigSchema>;
export type ConnectorStatusMap = z.infer<typeof connectorStatusMapSchema>;
export type ConnectorStatusConfigFile = z.infer<typeof connectorStatusConfigFileSchema>;
/**
* Validated at runtime via Zod schema; invalid JSON throws at module load time.
*/
export const connectorStatusConfig: ConnectorStatusConfigFile =
connectorStatusConfigFileSchema.parse(rawConnectorStatusConfigData);
/**
* Get default status config for a connector (when not in config file)
* Returns a validated default config
*/
export function getDefaultConnectorStatus(): ConnectorStatusConfig {
return connectorStatusConfigSchema.parse({
enabled: true,
status: "active",
statusMessage: null,
});
}
/**
* Validate a connector status config object
* Useful for validating config loaded from external sources
*/
export function validateConnectorStatusConfig(config: unknown): ConnectorStatusConfigFile {
return connectorStatusConfigFileSchema.parse(config);
}
/**
* Validate a single connector status config
*/
export function validateSingleConnectorStatus(config: unknown): ConnectorStatusConfig {
return connectorStatusConfigSchema.parse(config);
}

View file

@ -173,6 +173,7 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
allowFutureDates={true}
/>
{/* Periodic Sync Config */}

View file

@ -0,0 +1,29 @@
"use client";
import { Info } from "lucide-react";
import type { FC } from "react";
import type { ConnectorConfigProps } from "../index";
export interface TeamsConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const TeamsConfig: FC<TeamsConfigProps> = () => {
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
SurfSense will index messages from Teams channels that you have access to. The app can
only read messages from teams and channels where you are a member. Make sure you're a
member of the teams you want to index before connecting.
</p>
</div>
</div>
</div>
);
};

View file

@ -17,6 +17,7 @@ import { LumaConfig } from "./components/luma-config";
import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
import { TeamsConfig } from "./components/teams-config";
import { WebcrawlerConfig } from "./components/webcrawler-config";
export interface ConnectorConfigProps {
@ -52,6 +53,8 @@ export function getConnectorConfigComponent(
return SlackConfig;
case "DISCORD_CONNECTOR":
return DiscordConfig;
case "TEAMS_CONNECTOR":
return TeamsConfig;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR":

View file

@ -211,6 +211,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
allowFutureDates={
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
connector.connector_type === "LUMA_CONNECTOR"
}
/>
)}

View file

@ -159,6 +159,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
allowFutureDates={
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
config.connectorType === "LUMA_CONNECTOR"
}
/>
)}

View file

@ -51,6 +51,13 @@ export const OAUTH_CONNECTORS = [
connectorType: EnumConnectorName.SLACK_CONNECTOR,
authEndpoint: "/api/v1/auth/slack/connector/add/",
},
{
id: "teams-connector",
title: "Microsoft Teams",
description: "Search Teams messages",
connectorType: EnumConnectorName.TEAMS_CONNECTOR,
authEndpoint: "/api/v1/auth/teams/connector/add/",
},
{
id: "discord-connector",
title: "Discord",

View file

@ -80,6 +80,12 @@ export const useConnectorDialog = () => {
connectorTitle: string;
} | null>(null);
// Track if we came from accounts list when entering edit mode
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
connectorType: string;
connectorTitle: string;
} | null>(null);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@ -960,6 +966,14 @@ export const useConnectorDialog = () => {
return;
}
// Track if we came from accounts list view
// If viewingAccountsType matches this connector type, preserve it
if (viewingAccountsType && viewingAccountsType.connectorType === connector.connector_type) {
setCameFromAccountsList(viewingAccountsType);
} else {
setCameFromAccountsList(null);
}
// Track index with date range opened event
if (connector.is_indexable) {
trackIndexWithDateRangeOpened(
@ -989,7 +1003,7 @@ export const useConnectorDialog = () => {
url.searchParams.set("connectorId", connector.id.toString());
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId]
[searchSpaceId, viewingAccountsType]
);
// Handle saving connector changes
@ -1252,13 +1266,30 @@ export const useConnectorDialog = () => {
// Handle going back from edit view
const handleBackFromEdit = useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// If we came from accounts list view, go back there
if (cameFromAccountsList && editingConnector) {
// Restore accounts list view
setViewingAccountsType(cameFromAccountsList);
setCameFromAccountsList(null);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", cameFromAccountsList.connectorType);
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
} else {
// Otherwise, go back to main connector popup
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
}
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
}, [router, cameFromAccountsList, editingConnector]);
// Handle dialog open/close
const handleOpenChange = useCallback(
@ -1289,6 +1320,7 @@ export const useConnectorDialog = () => {
setConnectorConfig(null);
setConnectingConnectorType(null);
setViewingAccountsType(null);
setCameFromAccountsList(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);

View file

@ -0,0 +1,55 @@
"use client";
import { useMemo } from "react";
import {
type ConnectorStatusConfig,
connectorStatusConfig,
getDefaultConnectorStatus,
} from "../config/connector-status-config";
/**
* Hook to get connector status information
*/
export function useConnectorStatus() {
/**
* Get status configuration for a specific connector type
*/
const getConnectorStatus = (connectorType: string | undefined): ConnectorStatusConfig => {
if (!connectorType) {
return getDefaultConnectorStatus();
}
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
};
/**
* Check if a connector is enabled
*/
const isConnectorEnabled = (connectorType: string | undefined): boolean => {
return getConnectorStatus(connectorType).enabled;
};
/**
* Get status message for a connector
*/
const getConnectorStatusMessage = (connectorType: string | undefined): string | null => {
return getConnectorStatus(connectorType).statusMessage || null;
};
/**
* Check if warnings should be shown globally
*/
const shouldShowWarnings = (): boolean => {
return connectorStatusConfig.globalSettings.showWarnings;
};
return useMemo(
() => ({
getConnectorStatus,
isConnectorEnabled,
getConnectorStatusMessage,
shouldShowWarnings,
}),
[]
);
}

View file

@ -11,6 +11,7 @@
export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
// Direct mappings (connector type matches document type)
SLACK_CONNECTOR: "SLACK_CONNECTOR",
TEAMS_CONNECTOR: "TEAMS_CONNECTOR",
NOTION_CONNECTOR: "NOTION_CONNECTOR",
GITHUB_CONNECTOR: "GITHUB_CONNECTOR",
LINEAR_CONNECTOR: "LINEAR_CONNECTOR",

View file

@ -8,6 +8,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps {
@ -65,54 +66,65 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
onAddAccount,
isConnecting = false,
}) => {
// Get connector status
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
const isEnabled = isConnectorEnabled(connectorType);
const statusMessage = getConnectorStatusMessage(connectorType);
// Filter connectors to only show those of this type
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 sm:px-12 pt-6 sm:pt-10 pb-4 border-b border-border/50 bg-muted">
<div className="flex items-center justify-between gap-4 sm:pr-4">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="size-8 rounded-full shrink-0"
onClick={onBack}
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{getConnectorIcon(connectorType, "size-5")}
</div>
<div>
<h2 className="text-lg font-semibold">{connectorTitle} Accounts</h2>
<p className="text-xs text-muted-foreground">
{typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""}
</p>
</div>
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 border-b border-border/50 bg-muted">
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
>
<ArrowLeft className="size-4" />
Back to connectors
</button>
{/* Connector header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
<div className="flex gap-4 flex-1 w-full sm:w-auto">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20 shrink-0">
{getConnectorIcon(connectorType, "size-7")}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal">
{connectorTitle}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
{statusMessage || "Manage your connector settings and sync configuration"}
</p>
</div>
</div>
{/* Add Account Button with dashed border */}
<button
type="button"
onClick={onAddAccount}
disabled={isConnecting}
disabled={isConnecting || !isEnabled}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg mr-4 border-2 border-dashed border-border/70 text-left transition-all duration-200",
"border-primary/50 hover:bg-primary/5",
"flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg border-2 border-dashed text-left transition-all duration-200 shrink-0 self-center sm:self-auto sm:w-auto",
!isEnabled
? "border-border/30 opacity-50 cursor-not-allowed"
: "border-primary/50 hover:bg-primary/5",
isConnecting && "opacity-50 cursor-not-allowed"
)}
>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
<div className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? (
<Loader2 className="size-3.5 animate-spin text-primary" />
<Loader2 className="size-3 sm:size-3.5 animate-spin text-primary" />
) : (
<Plus className="size-3.5 text-primary" />
<Plus className="size-3 sm:size-3.5 text-primary" />
)}
</div>
<span className="text-[12px] font-medium">
<span className="text-[11px] sm:text-[12px] font-medium">
{isConnecting ? "Connecting..." : "Add Account"}
</span>
</button>
@ -120,7 +132,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 sm:px-12 py-6 sm:py-8">
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8">
{/* Connected Accounts Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {

View file

@ -7,13 +7,19 @@ import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
interface InlineCitationProps {
chunkId: number;
citationNumber: number;
isDocsChunk?: boolean;
}
/**
* Inline citation component for the new chat.
* Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details.
* Supports both regular knowledge base chunks and Surfsense documentation chunks.
*/
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber }) => {
export const InlineCitation: FC<InlineCitationProps> = ({
chunkId,
citationNumber,
isDocsChunk = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
@ -21,10 +27,11 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumbe
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType=""
title="Source"
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
description=""
url=""
isDocsChunk={isDocsChunk}
>
<span
onClick={() => setIsOpen(true)}

View file

@ -15,12 +15,13 @@ import { InlineCitation } from "@/components/assistant-ui/inline-citation";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
// Citation pattern: [citation:CHUNK_ID]
const CITATION_REGEX = /\[citation:(\d+)\]/g;
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering
let chunkIdToCitationNumber: Map<number, number> = new Map();
// Uses string keys to differentiate between doc and regular chunks (e.g., "doc-123" vs "123")
let chunkIdToCitationNumber: Map<string, number> = new Map();
let nextCitationNumber = 1;
/**
@ -33,16 +34,20 @@ export function resetCitationCounter() {
/**
* Gets or assigns a citation number for a chunk ID
* Uses string key to differentiate between doc and regular chunks
*/
function getCitationNumber(chunkId: number): number {
if (!chunkIdToCitationNumber.has(chunkId)) {
chunkIdToCitationNumber.set(chunkId, nextCitationNumber++);
function getCitationNumber(chunkId: number, isDocsChunk: boolean): number {
const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId);
const existingNumber = chunkIdToCitationNumber.get(key);
if (existingNumber === undefined) {
chunkIdToCitationNumber.set(key, nextCitationNumber++);
}
return chunkIdToCitationNumber.get(chunkId)!;
return chunkIdToCitationNumber.get(key)!;
}
/**
* Parses text and replaces [citation:XXX] patterns with InlineCitation components
* Supports both regular chunks [citation:123] and docs chunks [citation:doc-123]
*/
function parseTextWithCitations(text: string): ReactNode[] {
const parts: ReactNode[] = [];
@ -59,14 +64,16 @@ function parseTextWithCitations(text: string): ReactNode[] {
parts.push(text.substring(lastIndex, match.index));
}
// Add the citation component
const chunkId = Number.parseInt(match[1], 10);
const citationNumber = getCitationNumber(chunkId);
// Check if this is a docs chunk (has "doc-" prefix)
const isDocsChunk = match[1] === "doc-";
const chunkId = Number.parseInt(match[2], 10);
const citationNumber = getCitationNumber(chunkId, isDocsChunk);
parts.push(
<InlineCitation
key={`citation-${chunkId}-${instanceIndex}`}
key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
chunkId={chunkId}
citationNumber={citationNumber}
isDocsChunk={isDocsChunk}
/>
);

View file

@ -54,7 +54,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
: "bg-transparent border border-transparent"
)}
>
<div className="flex flex-1 flex-row items-center gap-2">
<div className="flex flex-1 flex-row items-center gap-0.5">
<Logo className="h-8 w-8 rounded-md" />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</div>

View file

@ -0,0 +1 @@
export { useSidebarState } from "./useSidebarState";

View file

@ -0,0 +1,61 @@
"use client";
import { useCallback, useEffect, useState } from "react";
const SIDEBAR_COOKIE_NAME = "sidebar_collapsed";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
interface UseSidebarStateReturn {
isCollapsed: boolean;
setIsCollapsed: (collapsed: boolean) => void;
toggleCollapsed: () => void;
}
export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn {
const [isCollapsed, setIsCollapsedState] = useState(defaultCollapsed);
// Initialize from cookie on mount
useEffect(() => {
try {
const match = document.cookie.match(/(?:^|; )sidebar_collapsed=([^;]+)/);
if (match) {
setIsCollapsedState(match[1] === "true");
}
} catch {
// Ignore cookie read errors
}
}, []);
// Persist to cookie when state changes
const setIsCollapsed = useCallback((collapsed: boolean) => {
setIsCollapsedState(collapsed);
try {
document.cookie = `${SIDEBAR_COOKIE_NAME}=${collapsed}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
} catch {
// Ignore cookie write errors
}
}, []);
const toggleCollapsed = useCallback(() => {
setIsCollapsed(!isCollapsed);
}, [isCollapsed, setIsCollapsed]);
// Keyboard shortcut: Cmd/Ctrl + B
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleCollapsed();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleCollapsed]);
return {
isCollapsed,
setIsCollapsed,
toggleCollapsed,
};
}

View file

@ -0,0 +1,32 @@
export { useSidebarState } from "./hooks";
export { LayoutDataProvider } from "./providers";
export type {
ChatItem,
IconRailProps,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
SidebarSectionProps,
User,
} from "./types/layout.types";
export {
AllSearchSpacesSheet,
ChatListItem,
CreateSearchSpaceDialog,
Header,
IconRail,
LayoutShell,
MobileSidebar,
MobileSidebarTrigger,
NavIcon,
NavSection,
NoteListItem,
PageUsageDisplay,
SearchSpaceAvatar,
Sidebar,
SidebarCollapseButton,
SidebarHeader,
SidebarSection,
SidebarUserProfile,
} from "./ui";

View file

@ -0,0 +1,537 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Logs, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useCallback, useMemo, useState } from "react";
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useLogsSummary } from "@/hooks/use-logs";
import { notesApiService } from "@/lib/apis/notes-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { AllSearchSpacesSheet } from "../ui/sheets";
import { LayoutShell } from "../ui/shell";
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
children: React.ReactNode;
breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
}
export function LayoutDataProvider({
searchSpaceId,
children,
breadcrumb,
languageSwitcher,
}: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
const router = useRouter();
const params = useParams();
const pathname = usePathname();
const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
// Atoms
const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
// Current IDs from URL
const currentChatId = params?.chat_id
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null;
const currentNoteId = params?.note_id
? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id)
: null;
// Fetch current search space
const { data: searchSpace } = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId,
});
// Fetch threads
const { data: threadsData, refetch: refetchThreads } = useQuery({
queryKey: ["threads", searchSpaceId, { limit: 4 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
enabled: !!searchSpaceId,
});
// Fetch notes
const { data: notesData, refetch: refetchNotes } = useQuery({
queryKey: ["notes", searchSpaceId],
queryFn: () =>
notesApiService.getNotes({
search_space_id: Number(searchSpaceId),
page_size: 4,
}),
enabled: !!searchSpaceId,
});
// Poll for active reindexing tasks to show inline loading indicators
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
enablePolling: true,
refetchInterval: 5000,
});
// Create a Set of document IDs that are currently being reindexed
const reindexingDocumentIds = useMemo(() => {
if (!summary?.active_tasks) return new Set<number>();
return new Set(
summary.active_tasks
.filter((task) => task.document_id != null)
.map((task) => task.document_id as number)
);
}, [summary?.active_tasks]);
// All chats/notes sidebars state
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
// Search space sheet and dialog state
const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false);
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
const [isDeletingChat, setIsDeletingChat] = useState(false);
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
const [noteToDelete, setNoteToDelete] = useState<{
id: number;
name: string;
search_space_id: number;
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
id: space.id,
name: space.name,
description: space.description,
isOwner: space.is_owner,
memberCount: space.member_count || 0,
createdAt: space.created_at,
}));
}, [searchSpacesData]);
// Find active search space from list (has is_owner and member_count)
const activeSearchSpace: SearchSpace | null = useMemo(() => {
if (!searchSpaceId || !searchSpaces.length) return null;
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
}, [searchSpaceId, searchSpaces]);
// Transform chats
const chats: ChatItem[] = useMemo(() => {
if (!threadsData?.threads) return [];
return threadsData.threads.map((thread) => ({
id: thread.id,
name: thread.title || `Chat ${thread.id}`,
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
}));
}, [threadsData, searchSpaceId]);
// Transform notes
const notes: NoteItem[] = useMemo(() => {
if (!notesData?.items) return [];
const sortedNotes = [...notesData.items].sort((a, b) => {
const dateA = a.updated_at
? new Date(a.updated_at).getTime()
: new Date(a.created_at).getTime();
const dateB = b.updated_at
? new Date(b.updated_at).getTime()
: new Date(b.created_at).getTime();
return dateB - dateA;
});
return sortedNotes.slice(0, 4).map((note) => ({
id: note.id,
name: note.title,
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
isReindexing: reindexingDocumentIds.has(note.id),
}));
}, [notesData, reindexingDocumentIds]);
// Navigation items
const navItems: NavItem[] = useMemo(
() => [
{
title: "Documents",
url: `/dashboard/${searchSpaceId}/documents`,
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{
title: "Logs",
url: `/dashboard/${searchSpaceId}/logs`,
icon: Logs,
isActive: pathname?.includes("/logs"),
},
],
[searchSpaceId, pathname]
);
// Handlers
const handleSearchSpaceSelect = useCallback(
(id: number) => {
router.push(`/dashboard/${id}/new-chat`);
},
[router]
);
const handleAddSearchSpace = useCallback(() => {
setIsCreateSearchSpaceDialogOpen(true);
}, []);
const handleSeeAllSearchSpaces = useCallback(() => {
setIsAllSearchSpacesSheetOpen(true);
}, []);
const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings");
}, [router]);
const handleSearchSpaceSettings = useCallback(
(id: number) => {
router.push(`/dashboard/${id}/settings`);
},
[router]
);
const handleDeleteSearchSpace = useCallback(
async (id: number) => {
await deleteSearchSpace({ id });
refetchSearchSpaces();
if (Number(searchSpaceId) === id && searchSpaces.length > 1) {
const remaining = searchSpaces.filter((s) => s.id !== id);
if (remaining.length > 0) {
router.push(`/dashboard/${remaining[0].id}/new-chat`);
}
} else if (searchSpaces.length === 1) {
router.push("/dashboard");
}
},
[deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router]
);
const handleNavItemClick = useCallback(
(item: NavItem) => {
router.push(item.url);
},
[router]
);
const handleNewChat = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, [router, searchSpaceId]);
const handleChatSelect = useCallback(
(chat: ChatItem) => {
router.push(chat.url);
},
[router]
);
const handleChatDelete = useCallback((chat: ChatItem) => {
setChatToDelete({ id: chat.id, name: chat.name });
setShowDeleteChatDialog(true);
}, []);
const handleNoteSelect = useCallback(
(note: NoteItem) => {
if (hasUnsavedEditorChanges) {
setPendingNavigation(note.url);
} else {
router.push(note.url);
}
},
[router, hasUnsavedEditorChanges, setPendingNavigation]
);
const handleNoteDelete = useCallback(
(note: NoteItem) => {
setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) });
setShowDeleteNoteDialog(true);
},
[searchSpaceId]
);
const handleAddNote = useCallback(() => {
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
if (hasUnsavedEditorChanges) {
setPendingNavigation(newNoteUrl);
} else {
router.push(newNoteUrl);
}
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings`);
}, [router, searchSpaceId]);
const handleManageMembers = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/team`);
}, [router, searchSpaceId]);
const handleLogout = useCallback(() => {
try {
trackLogout();
resetUser();
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");
}
} catch (error) {
console.error("Error during logout:", error);
router.push("/");
}
}, [router]);
const handleToggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const handleViewAllChats = useCallback(() => {
setIsAllChatsSidebarOpen(true);
}, []);
const handleViewAllNotes = useCallback(() => {
setIsAllNotesSidebarOpen(true);
}, []);
// Delete handlers
const confirmDeleteChat = useCallback(async () => {
if (!chatToDelete) return;
setIsDeletingChat(true);
try {
await deleteThread(chatToDelete.id);
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
if (currentChatId === chatToDelete.id) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
} catch (error) {
console.error("Error deleting thread:", error);
} finally {
setIsDeletingChat(false);
setShowDeleteChatDialog(false);
setChatToDelete(null);
}
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
const confirmDeleteNote = useCallback(async () => {
if (!noteToDelete) return;
setIsDeletingNote(true);
try {
await notesApiService.deleteNote({
search_space_id: noteToDelete.search_space_id,
note_id: noteToDelete.id,
});
refetchNotes();
} catch (error) {
console.error("Error deleting note:", error);
} finally {
setIsDeletingNote(false);
setShowDeleteNoteDialog(false);
setNoteToDelete(null);
}
}, [noteToDelete, refetchNotes]);
// Page usage
const pageUsage = user
? {
pagesUsed: user.pages_used,
pagesLimit: user.pages_limit,
}
: undefined;
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
const isChatPage = pathname?.includes("/new-chat") ?? false;
return (
<>
<LayoutShell
searchSpaces={searchSpaces}
activeSearchSpaceId={Number(searchSpaceId)}
onSearchSpaceSelect={handleSearchSpaceSelect}
onAddSearchSpace={handleAddSearchSpace}
searchSpace={activeSearchSpace}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
activeChatId={currentChatId}
onNewChat={handleNewChat}
onChatSelect={handleChatSelect}
onChatDelete={handleChatDelete}
onViewAllChats={handleViewAllChats}
notes={notes}
activeNoteId={currentNoteId}
onNoteSelect={handleNoteSelect}
onNoteDelete={handleNoteDelete}
onAddNote={handleAddNote}
onViewAllNotes={handleViewAllNotes}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings}
onManageMembers={handleManageMembers}
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
onUserSettings={handleUserSettings}
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={handleToggleTheme}
isChatPage={isChatPage}
>
{children}
</LayoutShell>
{/* Delete Chat Dialog */}
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_chat")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
{t("action_cannot_undone")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteChatDialog(false)}
disabled={isDeletingChat}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteChat}
disabled={isDeletingChat}
className="gap-2"
>
{isDeletingChat ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* All Chats Sidebar */}
<AllChatsSidebar
open={isAllChatsSidebarOpen}
onOpenChange={setIsAllChatsSidebarOpen}
searchSpaceId={searchSpaceId}
/>
{/* All Notes Sidebar */}
<AllNotesSidebar
open={isAllNotesSidebarOpen}
onOpenChange={setIsAllNotesSidebarOpen}
searchSpaceId={searchSpaceId}
onAddNote={handleAddNote}
/>
{/* All Search Spaces Sheet */}
<AllSearchSpacesSheet
open={isAllSearchSpacesSheetOpen}
onOpenChange={setIsAllSearchSpacesSheetOpen}
searchSpaces={searchSpaces}
onSearchSpaceSelect={handleSearchSpaceSelect}
onCreateNew={() => {
setIsAllSearchSpacesSheetOpen(false);
setIsCreateSearchSpaceDialogOpen(true);
}}
onSettings={handleSearchSpaceSettings}
onDelete={handleDeleteSearchSpace}
/>
{/* Create Search Space Dialog */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
{/* Delete Note Dialog */}
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>{t("delete_note")}</span>
</DialogTitle>
<DialogDescription>
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
{t("action_cannot_undone")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteNoteDialog(false)}
disabled={isDeletingNote}
>
{tCommon("cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteNote}
disabled={isDeletingNote}
className="gap-2"
>
{isDeletingNote ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("deleting")}
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1 @@
export { LayoutDataProvider } from "./LayoutDataProvider";

View file

@ -0,0 +1,140 @@
import type { LucideIcon } from "lucide-react";
export interface SearchSpace {
id: number;
name: string;
description?: string | null;
isOwner: boolean;
memberCount: number;
createdAt?: string;
}
export interface User {
email: string;
name?: string;
}
export interface NavItem {
title: string;
url: string;
icon: LucideIcon;
isActive?: boolean;
badge?: string | number;
}
export interface ChatItem {
id: number;
name: string;
url: string;
isActive?: boolean;
}
export interface NoteItem {
id: number;
name: string;
url: string;
isActive?: boolean;
isReindexing?: boolean;
}
export interface PageUsage {
pagesUsed: number;
pagesLimit: number;
}
export interface IconRailProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
className?: string;
}
export interface SidebarHeaderProps {
searchSpace: SearchSpace | null;
onSettings?: () => void;
}
export interface SidebarSectionProps {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
action?: React.ReactNode;
}
export interface NavSectionProps {
items: NavItem[];
onItemClick?: (item: NavItem) => void;
}
export interface ChatsSectionProps {
chats: ChatItem[];
activeChatId?: number | null;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void;
searchSpaceId?: string;
}
export interface NotesSectionProps {
notes: NoteItem[];
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
searchSpaceId?: string;
}
export interface PageUsageDisplayProps {
pagesUsed: number;
pagesLimit: number;
}
export interface SidebarUserProfileProps {
user: User;
searchSpaceId?: string;
onSettings?: () => void;
onManageMembers?: () => void;
onSwitchSearchSpace?: () => void;
onToggleTheme?: () => void;
onLogout?: () => void;
theme?: string;
}
export interface SidebarProps {
searchSpace: SearchSpace | null;
searchSpaceId?: string;
navItems: NavItem[];
chats: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void;
notes: NoteItem[];
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User;
theme?: string;
onSettings?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onToggleTheme?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
className?: string;
}
export interface LayoutShellProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
sidebarProps: Omit<SidebarProps, "className">;
children: React.ReactNode;
className?: string;
}

View file

@ -0,0 +1,161 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Loader2, Plus, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
interface CreateSearchSpaceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
},
});
const handleSubmit = async (values: FormValues) => {
setIsSubmitting(true);
try {
const result = await createSearchSpace({
name: values.name,
description: values.description || "",
});
trackSearchSpaceCreated(result.id, values.name);
// Hard redirect to ensure fresh state
window.location.href = `/dashboard/${result.id}/onboard`;
} catch (error) {
console.error("Failed to create search space:", error);
setIsSubmitting(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
form.reset();
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("create_title")}</DialogTitle>
<DialogDescription>{t("create_description")}</DialogDescription>
</div>
</div>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name_label")}</FormLabel>
<FormControl>
<Input placeholder={t("name_placeholder")} {...field} autoFocus />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description_label")}{" "}
<span className="text-muted-foreground font-normal">
({tCommon("optional")})
</span>
</FormLabel>
<FormControl>
<Input placeholder={t("description_placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("creating")}
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{t("create_button")}
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1 @@
export { CreateSearchSpaceDialog } from "./CreateSearchSpaceDialog";

View file

@ -0,0 +1,49 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface HeaderProps {
breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
theme?: string;
onToggleTheme?: () => void;
mobileMenuTrigger?: React.ReactNode;
}
export function Header({
breadcrumb,
languageSwitcher,
theme,
onToggleTheme,
mobileMenuTrigger,
}: HeaderProps) {
return (
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
{/* Left side - Mobile menu trigger + Breadcrumb */}
<div className="flex flex-1 items-center gap-2 min-w-0">
{mobileMenuTrigger}
<div className="hidden md:block">{breadcrumb}</div>
</div>
{/* Right side - Actions */}
<div className="flex items-center gap-2">
{/* Theme toggle */}
{onToggleTheme && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={onToggleTheme} className="h-8 w-8">
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="sr-only">Toggle theme</span>
</Button>
</TooltipTrigger>
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
</Tooltip>
)}
{languageSwitcher}
</div>
</header>
);
}

View file

@ -0,0 +1 @@
export { Header } from "./Header";

View file

@ -0,0 +1,60 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { SearchSpace } from "../../types/layout.types";
import { SearchSpaceAvatar } from "./SearchSpaceAvatar";
interface IconRailProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
className?: string;
}
export function IconRail({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
className,
}: IconRailProps) {
return (
<div className={cn("flex h-full w-14 flex-col items-center", className)}>
<ScrollArea className="w-full">
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
{searchSpaces.map((searchSpace) => (
<SearchSpaceAvatar
key={searchSpace.id}
name={searchSpace.name}
isActive={searchSpace.id === activeSearchSpaceId}
onClick={() => onSearchSpaceSelect(searchSpace.id)}
size="md"
/>
))}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onAddSearchSpace}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add search space</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Add search space
</TooltipContent>
</Tooltip>
</div>
</ScrollArea>
</div>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface NavIconProps {
icon: LucideIcon;
label: string;
isActive?: boolean;
onClick?: () => void;
}
export function NavIcon({ icon: Icon, label, isActive, onClick }: NavIconProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onClick}
className={cn("h-10 w-10 rounded-lg", isActive && "bg-accent text-accent-foreground")}
>
<Icon className="h-5 w-5" />
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface SearchSpaceAvatarProps {
name: string;
isActive?: boolean;
onClick?: () => void;
size?: "sm" | "md";
}
/**
* Generates a consistent color based on search space name
*/
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = [
"#6366f1", // indigo
"#22c55e", // green
"#f59e0b", // amber
"#ef4444", // red
"#8b5cf6", // violet
"#06b6d4", // cyan
"#ec4899", // pink
"#14b8a6", // teal
];
return colors[Math.abs(hash) % colors.length];
}
/**
* Gets initials from search space name (max 2 chars)
*/
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function SearchSpaceAvatar({
name,
isActive,
onClick,
size = "md",
}: SearchSpaceAvatarProps) {
const bgColor = stringToColor(name);
const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
style={{ backgroundColor: bgColor }}
>
{initials}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{name}
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,3 @@
export { IconRail } from "./IconRail";
export { NavIcon } from "./NavIcon";
export { SearchSpaceAvatar } from "./SearchSpaceAvatar";

View file

@ -0,0 +1,18 @@
export { CreateSearchSpaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
export { AllSearchSpacesSheet } from "./sheets";
export { LayoutShell } from "./shell";
export {
ChatListItem,
MobileSidebar,
MobileSidebarTrigger,
NavSection,
NoteListItem,
PageUsageDisplay,
Sidebar,
SidebarCollapseButton,
SidebarHeader,
SidebarSection,
SidebarUserProfile,
} from "./sidebar";

View file

@ -0,0 +1,241 @@
"use client";
import {
Calendar,
MoreHorizontal,
Search,
Settings,
Share2,
Trash2,
UserCheck,
Users,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { SearchSpace } from "../../types/layout.types";
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface AllSearchSpacesSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchSpaces: SearchSpace[];
onSearchSpaceSelect: (id: number) => void;
onCreateNew?: () => void;
onSettings?: (id: number) => void;
onDelete?: (id: number) => void;
}
export function AllSearchSpacesSheet({
open,
onOpenChange,
searchSpaces,
onSearchSpaceSelect,
onCreateNew,
onSettings,
onDelete,
}: AllSearchSpacesSheetProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [spaceToDelete, setSpaceToDelete] = useState<SearchSpace | null>(null);
const handleSelect = (id: number) => {
onSearchSpaceSelect(id);
onOpenChange(false);
};
const handleSettings = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
onOpenChange(false);
onSettings?.(space.id);
};
const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
setSpaceToDelete(space);
};
const confirmDelete = () => {
if (spaceToDelete) {
onDelete?.(spaceToDelete.id);
setSpaceToDelete(null);
}
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div className="flex flex-col gap-0.5">
<SheetTitle>{t("all_search_spaces")}</SheetTitle>
<SheetDescription>
{t("search_spaces_count", { count: searchSpaces.length })}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
{searchSpaces.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="font-medium">{t("no_search_spaces")}</p>
<p className="text-sm text-muted-foreground">{t("create_first_search_space")}</p>
</div>
{onCreateNew && (
<Button onClick={onCreateNew} className="mt-2">
{t("create_button")}
</Button>
)}
</div>
) : (
searchSpaces.map((space) => (
<button
key={space.id}
type="button"
onClick={() => handleSelect(space.id)}
className="flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium leading-tight">{space.name}</span>
{space.description && (
<span className="text-sm text-muted-foreground line-clamp-2">
{space.description}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{space.memberCount > 1 && (
<Badge variant="outline" className="shrink-0">
<Share2 className="mr-1 h-3 w-3" />
{tCommon("shared")}
</Badge>
)}
{space.isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleSettings(e, space)}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteClick(e, space)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{space.isOwner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
{t("members_count", { count: space.memberCount })}
</span>
{space.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(space.createdAt)}
</span>
)}
</div>
</button>
))
)}
</div>
{searchSpaces.length > 0 && onCreateNew && (
<div className="border-t p-4">
<Button onClick={onCreateNew} variant="outline" className="w-full">
{t("create_new_search_space")}
</Button>
</div>
)}
</SheetContent>
</Sheet>
<AlertDialog open={!!spaceToDelete} onOpenChange={(open) => !open && setSpaceToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_confirm", { name: spaceToDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1 @@
export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet";

View file

@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useSidebarState } from "../../hooks";
import type {
ChatItem,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
} from "../../types/layout.types";
import { Header } from "../header";
import { IconRail } from "../icon-rail";
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
interface LayoutShellProps {
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void;
notes: NoteItem[];
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
theme?: string;
onToggleTheme?: () => void;
defaultCollapsed?: boolean;
isChatPage?: boolean;
children: React.ReactNode;
className?: string;
}
export function LayoutShell({
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
searchSpace,
navItems,
onNavItemClick,
chats,
activeChatId,
onNewChat,
onChatSelect,
onChatDelete,
onViewAllChats,
notes,
activeNoteId,
onNoteSelect,
onNoteDelete,
onAddNote,
onViewAllNotes,
user,
onSettings,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
breadcrumb,
languageSwitcher,
theme,
onToggleTheme,
defaultCollapsed = false,
isChatPage = false,
children,
className,
}: LayoutShellProps) {
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
// Mobile layout
if (isMobile) {
return (
<TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
<Header
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={onToggleTheme}
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
/>
<MobileSidebar
isOpen={mobileMenuOpen}
onOpenChange={setMobileMenuOpen}
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
searchSpace={searchSpace}
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats}
notes={notes}
activeNoteId={activeNoteId}
onNoteSelect={onNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
/>
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</main>
</div>
</TooltipProvider>
);
}
// Desktop layout
return (
<TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}>
<div className="hidden md:flex overflow-hidden">
<IconRail
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
/>
</div>
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
<Sidebar
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onToggleCollapse={toggleCollapsed}
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
activeChatId={activeChatId}
onNewChat={onNewChat}
onChatSelect={onChatSelect}
onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats}
notes={notes}
activeNoteId={activeNoteId}
onNoteSelect={onNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="hidden md:flex border-r shrink-0"
/>
<main className="flex-1 flex flex-col min-w-0">
<Header
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={onToggleTheme}
/>
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
</main>
</div>
</div>
</TooltipProvider>
);
}

View file

@ -0,0 +1 @@
export { LayoutShell } from "./LayoutShell";

View file

@ -0,0 +1,65 @@
"use client";
import { MessageSquare, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
interface ChatListItemProps {
name: string;
isActive?: boolean;
onClick?: () => void;
onDelete?: () => void;
}
export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) {
const t = useTranslations("sidebar");
return (
<div className="group/item relative w-full">
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
"[&>span:last-child]:truncate",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground"
)}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="w-[calc(100%-3rem)] ">{name}</span>
</button>
{/* Actions dropdown */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
className="text-destructive focus:text-destructive"
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View file

@ -0,0 +1,157 @@
"use client";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import type {
ChatItem,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
} from "../../types/layout.types";
import { IconRail } from "../icon-rail";
import { Sidebar } from "./Sidebar";
interface MobileSidebarProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void;
notes: NoteItem[];
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
}
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
return (
<Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}>
<Menu className="h-5 w-5" />
<span className="sr-only">Open menu</span>
</Button>
);
}
export function MobileSidebar({
isOpen,
onOpenChange,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
searchSpace,
navItems,
onNavItemClick,
chats,
activeChatId,
onNewChat,
onChatSelect,
onChatDelete,
onViewAllChats,
notes,
activeNoteId,
onNoteSelect,
onNoteDelete,
onAddNote,
onViewAllNotes,
user,
onSettings,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
}: MobileSidebarProps) {
const handleSearchSpaceSelect = (id: number) => {
onSearchSpaceSelect(id);
};
const handleNavItemClick = (item: NavItem) => {
onNavItemClick?.(item);
onOpenChange(false);
};
const handleChatSelect = (chat: ChatItem) => {
onChatSelect(chat);
onOpenChange(false);
};
const handleNoteSelect = (note: NoteItem) => {
onNoteSelect(note);
onOpenChange(false);
};
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-[320px] p-0 flex">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<div className="shrink-0 border-r bg-muted/40">
<ScrollArea className="h-full">
<IconRail
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={handleSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
/>
</ScrollArea>
</div>
<div className="flex-1 overflow-hidden">
<Sidebar
searchSpace={searchSpace}
isCollapsed={false}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
activeChatId={activeChatId}
onNewChat={() => {
onNewChat();
onOpenChange(false);
}}
onChatSelect={handleChatSelect}
onChatDelete={onChatDelete}
onViewAllChats={onViewAllChats}
notes={notes}
activeNoteId={activeNoteId}
onNoteSelect={handleNoteSelect}
onNoteDelete={onNoteDelete}
onAddNote={onAddNote}
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="w-full border-none"
/>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { NavItem } from "../../types/layout.types";
interface NavSectionProps {
items: NavItem[];
onItemClick?: (item: NavItem) => void;
isCollapsed?: boolean;
}
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
return (
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
{items.map((item) => {
const Icon = item.icon;
// Add data-joyride for onboarding tour
const joyrideAttr =
item.title === "Documents" || item.title.toLowerCase().includes("documents")
? { "data-joyride": "documents-sidebar" }
: {};
if (isCollapsed) {
return (
<Tooltip key={item.url}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onItemClick?.(item)}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
)}
{...joyrideAttr}
>
<Icon className="h-4 w-4" />
<span className="sr-only">{item.title}</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">
{item.title}
{item.badge && ` (${item.badge})`}
</TooltipContent>
</Tooltip>
);
}
return (
<button
key={item.url}
type="button"
onClick={() => onItemClick?.(item)}
className={cn(
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
)}
{...joyrideAttr}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{item.title}</span>
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { FileText, Loader2, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
interface NoteListItemProps {
name: string;
isActive?: boolean;
isReindexing?: boolean;
onClick?: () => void;
onDelete?: () => void;
}
export function NoteListItem({
name,
isActive,
isReindexing,
onClick,
onDelete,
}: NoteListItemProps) {
const t = useTranslations("sidebar");
return (
<div className="group/item relative">
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
"[&>span:last-child]:truncate",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground"
)}
>
{isReindexing ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
) : (
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span>{name}</span>
</button>
{/* Actions dropdown */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
<span className="sr-only">{t("more_options")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
className="text-destructive focus:text-destructive"
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { Mail } from "lucide-react";
import { Progress } from "@/components/ui/progress";
interface PageUsageDisplayProps {
pagesUsed: number;
pagesLimit: number;
}
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
const usagePercentage = (pagesUsed / pagesLimit) * 100;
return (
<div className="px-3 py-3 border-t">
<div className="space-y-2">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
<a
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors"
>
<Mail className="h-3 w-3 shrink-0" />
<span>Contact to increase limits</span>
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,301 @@
"use client";
import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type {
ChatItem,
NavItem,
NoteItem,
PageUsage,
SearchSpace,
User,
} from "../../types/layout.types";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
import { NoteListItem } from "./NoteListItem";
import { PageUsageDisplay } from "./PageUsageDisplay";
import { SidebarCollapseButton } from "./SidebarCollapseButton";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarSection } from "./SidebarSection";
import { SidebarUserProfile } from "./SidebarUserProfile";
interface SidebarProps {
searchSpace: SearchSpace | null;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
activeChatId?: number | null;
onNewChat: () => void;
onChatSelect: (chat: ChatItem) => void;
onChatDelete?: (chat: ChatItem) => void;
onViewAllChats?: () => void;
notes: NoteItem[];
activeNoteId?: number | null;
onNoteSelect: (note: NoteItem) => void;
onNoteDelete?: (note: NoteItem) => void;
onAddNote?: () => void;
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
className?: string;
}
export function Sidebar({
searchSpace,
isCollapsed = false,
onToggleCollapse,
navItems,
onNavItemClick,
chats,
activeChatId,
onNewChat,
onChatSelect,
onChatDelete,
onViewAllChats,
notes,
activeNoteId,
onNoteSelect,
onNoteDelete,
onAddNote,
onViewAllNotes,
user,
onSettings,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
className,
}: SidebarProps) {
const t = useTranslations("sidebar");
return (
<div
className={cn(
"flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-200 overflow-hidden",
isCollapsed ? "w-[60px]" : "w-[240px]",
className
)}
>
{/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? (
<div className="flex h-14 shrink-0 items-center justify-center border-b">
<SidebarCollapseButton
isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})}
/>
</div>
) : (
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b">
<SidebarHeader
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onSettings={onSettings}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
/>
<div className="">
<SidebarCollapseButton
isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})}
/>
</div>
</div>
)}
{/* New chat button */}
<div className="p-2">
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="w-full h-10" onClick={onNewChat}>
<PenSquare className="h-4 w-4" />
<span className="sr-only">{t("new_chat")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t("new_chat")}</TooltipContent>
</Tooltip>
) : (
<Button variant="outline" className="w-full justify-start gap-2" onClick={onNewChat}>
<PenSquare className="h-4 w-4" />
{t("new_chat")}
</Button>
)}
</div>
{/* Platform navigation */}
{navItems.length > 0 && (
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
)}
{/* Scrollable content */}
<ScrollArea className="flex-1">
{isCollapsed ? (
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
{chats.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onToggleCollapse?.()}
>
<MessageSquare className="h-4 w-4" />
<span className="sr-only">{t("recent_chats")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t("recent_chats")} ({chats.length})
</TooltipContent>
</Tooltip>
)}
{notes.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onToggleCollapse?.()}
>
<FileText className="h-4 w-4" />
<span className="sr-only">{t("notes")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t("notes")} ({notes.length})
</TooltipContent>
</Tooltip>
)}
</div>
) : (
<div className="flex flex-col gap-1 py-2 w-[240px]">
<SidebarSection
title={t("recent_chats")}
defaultOpen={true}
action={
onViewAllChats && chats.length > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onViewAllChats}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("view_all_chats")}</TooltipContent>
</Tooltip>
) : undefined
}
>
{chats.length > 0 ? (
<div className="flex flex-col gap-0.5">
{chats.map((chat) => (
<ChatListItem
key={chat.id}
name={chat.name}
isActive={chat.id === activeChatId}
onClick={() => onChatSelect(chat)}
onDelete={() => onChatDelete?.(chat)}
/>
))}
</div>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_recent_chats")}</p>
)}
</SidebarSection>
<SidebarSection
title={t("notes")}
defaultOpen={true}
action={
onViewAllNotes && notes.length > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onViewAllNotes}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("view_all_notes")}</TooltipContent>
</Tooltip>
) : undefined
}
persistentAction={
onAddNote && notes.length > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onAddNote}>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">{t("add_note")}</TooltipContent>
</Tooltip>
) : undefined
}
>
{notes.length > 0 ? (
<div className="flex flex-col gap-0.5">
{notes.map((note) => (
<NoteListItem
key={note.id}
name={note.name}
isActive={note.id === activeNoteId}
isReindexing={note.isReindexing}
onClick={() => onNoteSelect(note)}
onDelete={() => onNoteDelete?.(note)}
/>
))}
</div>
) : onAddNote ? (
<button
type="button"
onClick={onAddNote}
className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
{t("create_new_note")}
</button>
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_notes")}</p>
)}
</SidebarSection>
</div>
)}
</ScrollArea>
{/* Footer */}
<div className="mt-auto">
{pageUsage && !isCollapsed && (
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}
<SidebarUserProfile
user={user}
onUserSettings={onUserSettings}
onLogout={onLogout}
isCollapsed={isCollapsed}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { PanelLeft, PanelLeftClose } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface SidebarCollapseButtonProps {
isCollapsed: boolean;
onToggle: () => void;
}
export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) {
const t = useTranslations("sidebar");
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
<span className="sr-only">
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { SearchSpace } from "../../types/layout.types";
interface SidebarHeaderProps {
searchSpace: SearchSpace | null;
isCollapsed?: boolean;
onSettings?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
className?: string;
}
export function SidebarHeader({
searchSpace,
isCollapsed,
onSettings,
onManageMembers,
onSeeAllSearchSpaces,
className,
}: SidebarHeaderProps) {
const t = useTranslations("sidebar");
return (
<div className={cn("flex shrink-0 items-center", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
"flex h-auto items-center justify-between gap-2 overflow-hidden py-1.5 font-semibold",
isCollapsed ? "w-10" : "w-50"
)}
>
<span className="truncate text-base">
{searchSpace?.name ?? t("select_search_space")}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={onManageMembers}>
<Users className="mr-2 h-4 w-4" />
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("search_space_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
<LayoutGrid className="mr-2 h-4 w-4" />
{t("see_all_search_spaces")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { ChevronRight } from "lucide-react";
import { useState } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
interface SidebarSectionProps {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
action?: React.ReactNode;
persistentAction?: React.ReactNode;
}
export function SidebarSection({
title,
defaultOpen = true,
children,
action,
persistentAction,
}: SidebarSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
<div className="flex items-center group/section">
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
isOpen && "rotate-90"
)}
/>
<span className="uppercase tracking-wider truncate">{title}</span>
</CollapsibleTrigger>
{/* Action button - visible on hover (always visible on mobile) */}
{action && (
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center gap-0.5">
{action}
</div>
)}
{/* Persistent action - always visible */}
{persistentAction && (
<div className="shrink-0 pr-1 flex items-center gap-0.5">{persistentAction}</div>
)}
</div>
<CollapsibleContent className="overflow-hidden">
<div className="px-2 pb-2">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}

View file

@ -0,0 +1,204 @@
"use client";
import { ChevronUp, LogOut, Settings } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { User } from "../../types/layout.types";
interface SidebarUserProfileProps {
user: User;
onUserSettings?: () => void;
onLogout?: () => void;
isCollapsed?: boolean;
}
/**
* Generates a consistent color based on email
*/
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = [
"#6366f1",
"#8b5cf6",
"#a855f7",
"#d946ef",
"#ec4899",
"#f43f5e",
"#ef4444",
"#f97316",
"#eab308",
"#84cc16",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#0ea5e9",
"#3b82f6",
];
return colors[Math.abs(hash) % colors.length];
}
/**
* Gets initials from email
*/
function getInitials(email: string): string {
const name = email.split("@")[0];
const parts = name.split(/[._-]/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function SidebarUserProfile({
user,
onUserSettings,
onLogout,
isCollapsed = false,
}: SidebarUserProfileProps) {
const t = useTranslations("sidebar");
const bgColor = stringToColor(user.email);
const initials = getInitials(user.email);
const displayName = user.name || user.email.split("@")[0];
// Collapsed view - just show avatar with dropdown
if (isCollapsed) {
return (
<div className="border-t p-2">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex h-10 w-full items-center justify-center rounded-md",
"hover:bg-accent transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
>
<div
className="flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
<span className="sr-only">{displayName}</span>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{displayName}</TooltipContent>
</Tooltip>
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
<DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2">
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{displayName}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
// Expanded view
return (
<div className="border-t">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex w-full items-center gap-2 px-2 py-3 text-left",
"hover:bg-accent transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
>
{/* Avatar */}
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
{/* Name and email */}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{displayName}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
{/* Chevron icon */}
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
<DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2">
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{displayName}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,12 @@
export { AllChatsSidebar } from "./AllChatsSidebar";
export { AllNotesSidebar } from "./AllNotesSidebar";
export { ChatListItem } from "./ChatListItem";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { NoteListItem } from "./NoteListItem";
export { PageUsageDisplay } from "./PageUsageDisplay";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";
export { SidebarHeader } from "./SidebarHeader";
export { SidebarSection } from "./SidebarSection";
export { SidebarUserProfile } from "./SidebarUserProfile";

View file

@ -21,10 +21,16 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
} from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
interface SourceDetailPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -34,6 +40,7 @@ interface SourceDetailPanelProps {
description?: string;
url?: string;
children?: ReactNode;
isDocsChunk?: boolean;
}
const formatDocumentType = (type: string) => {
@ -114,6 +121,7 @@ export function SourceDetailPanel({
description,
url,
children,
isDocsChunk = false,
}: SourceDetailPanelProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
@ -131,9 +139,16 @@ export function SourceDetailPanel({
data: documentData,
isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError,
} = useQuery({
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
} = useQuery<DocumentData>({
queryKey: isDocsChunk
? cacheKeys.documents.byChunk(`doc-${chunkId}`)
: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: async () => {
if (isDocsChunk) {
return documentsApiService.getSurfsenseDocByChunk(chunkId);
}
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
},
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000,
});
@ -325,7 +340,7 @@ export function SourceDetailPanel({
{documentData?.title || title || "Source Document"}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{documentData
{documentData && "document_type" in documentData
? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && (
@ -491,7 +506,8 @@ export function SourceDetailPanel({
<ScrollArea className="flex-1" ref={scrollAreaRef}>
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
{/* Document Metadata */}
{documentData.document_metadata &&
{"document_metadata" in documentData &&
documentData.document_metadata &&
Object.keys(documentData.document_metadata).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}

Some files were not shown because too many files have changed in this diff Show more