mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/documents
This commit is contained in:
commit
c132e5ddb0
49 changed files with 1625 additions and 354 deletions
|
|
@ -32,6 +32,11 @@ ELECTRIC_DB_PASSWORD=electric_password
|
||||||
SCHEDULE_CHECKER_INTERVAL=5m
|
SCHEDULE_CHECKER_INTERVAL=5m
|
||||||
|
|
||||||
SECRET_KEY=SECRET
|
SECRET_KEY=SECRET
|
||||||
|
|
||||||
|
# JWT Token Lifetimes (optional, defaults shown)
|
||||||
|
# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day
|
||||||
|
# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks
|
||||||
|
|
||||||
NEXT_FRONTEND_URL=http://localhost:3000
|
NEXT_FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
# Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS)
|
# Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""Add refresh_tokens table for user session management
|
||||||
|
|
||||||
|
Revision ID: 92
|
||||||
|
Revises: 91
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. Create refresh_tokens table with columns:
|
||||||
|
- id (primary key)
|
||||||
|
- user_id (foreign key to user)
|
||||||
|
- token_hash (unique, indexed)
|
||||||
|
- expires_at (indexed)
|
||||||
|
- is_revoked
|
||||||
|
- family_id (indexed, for token rotation tracking)
|
||||||
|
- created_at, updated_at (timestamps)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "92"
|
||||||
|
down_revision: str | None = "91"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create refresh_tokens table (idempotent)."""
|
||||||
|
# Check if table already exists
|
||||||
|
connection = op.get_bind()
|
||||||
|
result = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'refresh_tokens')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table_exists = result.scalar()
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
op.create_table(
|
||||||
|
"refresh_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(256), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
|
||||||
|
sa.Column("is_revoked", sa.Boolean(), nullable=False, default=False),
|
||||||
|
sa.Column("family_id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["user.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes if they don't exist
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_refresh_tokens_user_id ON refresh_tokens (user_id)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS ix_refresh_tokens_token_hash ON refresh_tokens (token_hash)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_refresh_tokens_expires_at ON refresh_tokens (expires_at)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_refresh_tokens_family_id ON refresh_tokens (family_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop refresh_tokens table (idempotent)."""
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_family_id")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_expires_at")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_token_hash")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_refresh_tokens_user_id")
|
||||||
|
op.execute("DROP TABLE IF EXISTS refresh_tokens")
|
||||||
|
|
@ -12,6 +12,7 @@ from app.agents.new_chat.checkpointer import (
|
||||||
from app.config import config, initialize_llm_router
|
from app.config import config, initialize_llm_router
|
||||||
from app.db import User, create_db_and_tables, get_async_session
|
from app.db import User, create_db_and_tables, get_async_session
|
||||||
from app.routes import router as crud_router
|
from app.routes import router as crud_router
|
||||||
|
from app.routes.auth_routes import router as auth_router
|
||||||
from app.schemas import UserCreate, UserRead, UserUpdate
|
from app.schemas import UserCreate, UserRead, UserUpdate
|
||||||
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
|
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
|
||||||
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
|
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
|
||||||
|
|
@ -111,6 +112,9 @@ app.include_router(
|
||||||
tags=["users"],
|
tags=["users"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Include custom auth routes (refresh token, logout)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
|
||||||
if config.AUTH_TYPE == "GOOGLE":
|
if config.AUTH_TYPE == "GOOGLE":
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,14 @@ class Config:
|
||||||
# OAuth JWT
|
# OAuth JWT
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
|
|
||||||
|
# JWT Token Lifetimes
|
||||||
|
ACCESS_TOKEN_LIFETIME_SECONDS = int(
|
||||||
|
os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(24 * 60 * 60)) # 1 day
|
||||||
|
)
|
||||||
|
REFRESH_TOKEN_LIFETIME_SECONDS = int(
|
||||||
|
os.getenv("REFRESH_TOKEN_LIFETIME_SECONDS", str(14 * 24 * 60 * 60)) # 2 weeks
|
||||||
|
)
|
||||||
|
|
||||||
# ETL Service
|
# ETL Service
|
||||||
ETL_SERVICE = os.getenv("ETL_SERVICE")
|
ETL_SERVICE = os.getenv("ETL_SERVICE")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,14 @@ class AirtableHistoryConnector:
|
||||||
|
|
||||||
config_data = connector.config.copy()
|
config_data = connector.config.copy()
|
||||||
|
|
||||||
|
# Check if access_token exists before processing
|
||||||
|
raw_access_token = config_data.get("access_token")
|
||||||
|
if not raw_access_token:
|
||||||
|
raise ValueError(
|
||||||
|
"Airtable access token not found. "
|
||||||
|
"Please reconnect your Airtable account."
|
||||||
|
)
|
||||||
|
|
||||||
# Decrypt credentials if they are encrypted
|
# Decrypt credentials if they are encrypted
|
||||||
token_encrypted = config_data.get("_token_encrypted", False)
|
token_encrypted = config_data.get("_token_encrypted", False)
|
||||||
if token_encrypted and config.SECRET_KEY:
|
if token_encrypted and config.SECRET_KEY:
|
||||||
|
|
@ -98,6 +106,14 @@ class AirtableHistoryConnector:
|
||||||
f"Failed to decrypt Airtable credentials: {e!s}"
|
f"Failed to decrypt Airtable credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
# Final validation after decryption
|
||||||
|
final_token = config_data.get("access_token")
|
||||||
|
if not final_token or (isinstance(final_token, str) and not final_token.strip()):
|
||||||
|
raise ValueError(
|
||||||
|
"Airtable access token is invalid or empty. "
|
||||||
|
"Please reconnect your Airtable account."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._credentials = AirtableAuthCredentialsBase.from_dict(config_data)
|
self._credentials = AirtableAuthCredentialsBase.from_dict(config_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,14 @@ class ConfluenceHistoryConnector:
|
||||||
|
|
||||||
if is_oauth:
|
if is_oauth:
|
||||||
# OAuth 2.0 authentication
|
# OAuth 2.0 authentication
|
||||||
|
# Check if access_token exists before processing
|
||||||
|
raw_access_token = config_data.get("access_token")
|
||||||
|
if not raw_access_token:
|
||||||
|
raise ValueError(
|
||||||
|
"Confluence access token not found. "
|
||||||
|
"Please reconnect your Confluence account."
|
||||||
|
)
|
||||||
|
|
||||||
# Decrypt credentials if they are encrypted
|
# Decrypt credentials if they are encrypted
|
||||||
token_encrypted = config_data.get("_token_encrypted", False)
|
token_encrypted = config_data.get("_token_encrypted", False)
|
||||||
if token_encrypted and config.SECRET_KEY:
|
if token_encrypted and config.SECRET_KEY:
|
||||||
|
|
@ -118,6 +126,14 @@ class ConfluenceHistoryConnector:
|
||||||
f"Failed to decrypt Confluence credentials: {e!s}"
|
f"Failed to decrypt Confluence credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
# Final validation after decryption
|
||||||
|
final_token = config_data.get("access_token")
|
||||||
|
if not final_token or (isinstance(final_token, str) and not final_token.strip()):
|
||||||
|
raise ValueError(
|
||||||
|
"Confluence access token is invalid or empty. "
|
||||||
|
"Please reconnect your Confluence account."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._credentials = AtlassianAuthCredentialsBase.from_dict(
|
self._credentials = AtlassianAuthCredentialsBase.from_dict(
|
||||||
config_data
|
config_data
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ class JiraHistoryConnector:
|
||||||
|
|
||||||
if is_oauth:
|
if is_oauth:
|
||||||
# OAuth 2.0 authentication
|
# OAuth 2.0 authentication
|
||||||
|
# Check if access_token exists before processing
|
||||||
|
raw_access_token = config_data.get("access_token")
|
||||||
|
if not raw_access_token:
|
||||||
|
raise ValueError(
|
||||||
|
"Jira access token not found. "
|
||||||
|
"Please reconnect your Jira account."
|
||||||
|
)
|
||||||
|
|
||||||
if not config.SECRET_KEY:
|
if not config.SECRET_KEY:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"SECRET_KEY not configured but tokens are marked as encrypted"
|
"SECRET_KEY not configured but tokens are marked as encrypted"
|
||||||
|
|
@ -119,6 +127,14 @@ class JiraHistoryConnector:
|
||||||
f"Failed to decrypt Jira credentials: {e!s}"
|
f"Failed to decrypt Jira credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
# Final validation after decryption
|
||||||
|
final_token = config_data.get("access_token")
|
||||||
|
if not final_token or (isinstance(final_token, str) and not final_token.strip()):
|
||||||
|
raise ValueError(
|
||||||
|
"Jira access token is invalid or empty. "
|
||||||
|
"Please reconnect your Jira account."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._credentials = AtlassianAuthCredentialsBase.from_dict(
|
self._credentials = AtlassianAuthCredentialsBase.from_dict(
|
||||||
config_data
|
config_data
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,14 @@ class LinearConnector:
|
||||||
|
|
||||||
config_data = connector.config.copy()
|
config_data = connector.config.copy()
|
||||||
|
|
||||||
|
# Check if access_token exists before processing
|
||||||
|
raw_access_token = config_data.get("access_token")
|
||||||
|
if not raw_access_token:
|
||||||
|
raise ValueError(
|
||||||
|
"Linear access token not found. "
|
||||||
|
"Please reconnect your Linear account."
|
||||||
|
)
|
||||||
|
|
||||||
# Decrypt credentials if they are encrypted
|
# Decrypt credentials if they are encrypted
|
||||||
token_encrypted = config_data.get("_token_encrypted", False)
|
token_encrypted = config_data.get("_token_encrypted", False)
|
||||||
if token_encrypted and config.SECRET_KEY:
|
if token_encrypted and config.SECRET_KEY:
|
||||||
|
|
@ -143,6 +151,14 @@ class LinearConnector:
|
||||||
f"Failed to decrypt Linear credentials: {e!s}"
|
f"Failed to decrypt Linear credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
# Final validation after decryption
|
||||||
|
final_token = config_data.get("access_token")
|
||||||
|
if not final_token or (isinstance(final_token, str) and not final_token.strip()):
|
||||||
|
raise ValueError(
|
||||||
|
"Linear access token is invalid or empty. "
|
||||||
|
"Please reconnect your Linear account."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._credentials = LinearAuthCredentialsBase.from_dict(config_data)
|
self._credentials = LinearAuthCredentialsBase.from_dict(config_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1449,6 +1449,13 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
display_name = Column(String, nullable=True)
|
display_name = Column(String, nullable=True)
|
||||||
avatar_url = Column(String, nullable=True)
|
avatar_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Refresh tokens for this user
|
||||||
|
refresh_tokens = relationship(
|
||||||
|
"RefreshToken",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
class User(SQLAlchemyBaseUserTableUUID, Base):
|
class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||||
|
|
@ -1514,6 +1521,43 @@ else:
|
||||||
display_name = Column(String, nullable=True)
|
display_name = Column(String, nullable=True)
|
||||||
avatar_url = Column(String, nullable=True)
|
avatar_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Refresh tokens for this user
|
||||||
|
refresh_tokens = relationship(
|
||||||
|
"RefreshToken",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Stores refresh tokens for user session management.
|
||||||
|
Each row represents one device/session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user = relationship("User", back_populates="refresh_tokens")
|
||||||
|
token_hash = Column(String(256), unique=True, nullable=False, index=True)
|
||||||
|
expires_at = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
||||||
|
is_revoked = Column(Boolean, default=False, nullable=False)
|
||||||
|
family_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return datetime.now(UTC) >= self.expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
return not self.is_expired and not self.is_revoked
|
||||||
|
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL)
|
engine = create_async_engine(DATABASE_URL)
|
||||||
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
|
||||||
|
|
@ -104,3 +104,33 @@ SUMMARY_PROMPT = (
|
||||||
SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
|
SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
|
||||||
input_variables=["document"], template=SUMMARY_PROMPT
|
input_variables=["document"], template=SUMMARY_PROMPT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Chat Title Generation Prompt
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation.
|
||||||
|
|
||||||
|
<rules>
|
||||||
|
- The title MUST be between 1 and 6 words
|
||||||
|
- The title MUST be on a single line
|
||||||
|
- Capture the main topic or intent of the conversation
|
||||||
|
- Do NOT use quotes, punctuation, or formatting
|
||||||
|
- Do NOT include words like "Chat about" or "Discussion of"
|
||||||
|
- Return ONLY the title, nothing else
|
||||||
|
</rules>
|
||||||
|
|
||||||
|
<user_query>
|
||||||
|
{user_query}
|
||||||
|
</user_query>
|
||||||
|
|
||||||
|
<assistant_response>
|
||||||
|
{assistant_response}
|
||||||
|
</assistant_response>
|
||||||
|
|
||||||
|
Title:"""
|
||||||
|
|
||||||
|
TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate(
|
||||||
|
input_variables=["user_query", "assistant_response"],
|
||||||
|
template=TITLE_GENERATION_PROMPT,
|
||||||
|
)
|
||||||
|
|
|
||||||
93
surfsense_backend/app/routes/auth_routes.py
Normal file
93
surfsense_backend/app/routes/auth_routes.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Authentication routes for refresh token management."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import User, async_session_maker
|
||||||
|
from app.schemas.auth import (
|
||||||
|
LogoutAllResponse,
|
||||||
|
LogoutRequest,
|
||||||
|
LogoutResponse,
|
||||||
|
RefreshTokenRequest,
|
||||||
|
RefreshTokenResponse,
|
||||||
|
)
|
||||||
|
from app.users import current_active_user, get_jwt_strategy
|
||||||
|
from app.utils.refresh_tokens import (
|
||||||
|
revoke_all_user_tokens,
|
||||||
|
revoke_refresh_token,
|
||||||
|
rotate_refresh_token,
|
||||||
|
validate_refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth/jwt", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=RefreshTokenResponse)
|
||||||
|
async def refresh_access_token(request: RefreshTokenRequest):
|
||||||
|
"""
|
||||||
|
Exchange a valid refresh token for a new access token and refresh token.
|
||||||
|
Implements token rotation for security.
|
||||||
|
"""
|
||||||
|
token_record = await validate_refresh_token(request.refresh_token)
|
||||||
|
|
||||||
|
if not token_record:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired refresh token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user from token record
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.id == token_record.user_id)
|
||||||
|
)
|
||||||
|
user = result.scalars().first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate new access token
|
||||||
|
strategy = get_jwt_strategy()
|
||||||
|
access_token = await strategy.write_token(user)
|
||||||
|
|
||||||
|
# Rotate refresh token
|
||||||
|
new_refresh_token = await rotate_refresh_token(token_record)
|
||||||
|
|
||||||
|
logger.info(f"Refreshed token for user {user.id}")
|
||||||
|
|
||||||
|
return RefreshTokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=new_refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/revoke", response_model=LogoutResponse)
|
||||||
|
async def revoke_token(request: LogoutRequest):
|
||||||
|
"""
|
||||||
|
Logout current device by revoking the provided refresh token.
|
||||||
|
Does not require authentication - just the refresh token.
|
||||||
|
"""
|
||||||
|
revoked = await revoke_refresh_token(request.refresh_token)
|
||||||
|
if revoked:
|
||||||
|
logger.info("User logged out from current device - token revoked")
|
||||||
|
else:
|
||||||
|
logger.warning("Logout called but no matching token found to revoke")
|
||||||
|
return LogoutResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout-all", response_model=LogoutAllResponse)
|
||||||
|
async def logout_all_devices(user: User = Depends(current_active_user)):
|
||||||
|
"""
|
||||||
|
Logout from all devices by revoking all refresh tokens for the user.
|
||||||
|
Requires valid access token.
|
||||||
|
"""
|
||||||
|
await revoke_all_user_tokens(user.id)
|
||||||
|
logger.info(f"User {user.id} logged out from all devices")
|
||||||
|
return LogoutAllResponse()
|
||||||
|
|
@ -886,30 +886,8 @@ async def append_message(
|
||||||
# Update thread's updated_at timestamp
|
# Update thread's updated_at timestamp
|
||||||
thread.updated_at = datetime.now(UTC)
|
thread.updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
# Auto-generate title from first user message if title is still default
|
# Note: Title generation now happens in stream_new_chat.py after the first response
|
||||||
if thread.title == "New Chat" and role_str == "user":
|
# using LLM to generate a descriptive title (with truncation as fallback)
|
||||||
# Extract text content for title
|
|
||||||
content = message.content
|
|
||||||
if isinstance(content, str):
|
|
||||||
title_text = content
|
|
||||||
elif isinstance(content, list):
|
|
||||||
# Find first text content
|
|
||||||
title_text = ""
|
|
||||||
for part in content:
|
|
||||||
if isinstance(part, dict) and part.get("type") == "text":
|
|
||||||
title_text = part.get("text", "")
|
|
||||||
break
|
|
||||||
elif isinstance(part, str):
|
|
||||||
title_text = part
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
title_text = str(content)
|
|
||||||
|
|
||||||
# Truncate title
|
|
||||||
if title_text:
|
|
||||||
thread.title = title_text[:100] + (
|
|
||||||
"..." if len(title_text) > 100 else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(db_message)
|
await session.refresh(db_message)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
from .auth import (
|
||||||
|
LogoutAllResponse,
|
||||||
|
LogoutRequest,
|
||||||
|
LogoutResponse,
|
||||||
|
RefreshTokenRequest,
|
||||||
|
RefreshTokenResponse,
|
||||||
|
)
|
||||||
from .base import IDModel, TimestampModel
|
from .base import IDModel, TimestampModel
|
||||||
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
|
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
|
||||||
from .documents import (
|
from .documents import (
|
||||||
|
|
@ -119,6 +126,10 @@ __all__ = [
|
||||||
"LogFilter",
|
"LogFilter",
|
||||||
"LogRead",
|
"LogRead",
|
||||||
"LogUpdate",
|
"LogUpdate",
|
||||||
|
# Auth schemas
|
||||||
|
"LogoutAllResponse",
|
||||||
|
"LogoutRequest",
|
||||||
|
"LogoutResponse",
|
||||||
# Search source connector schemas
|
# Search source connector schemas
|
||||||
"MCPConnectorCreate",
|
"MCPConnectorCreate",
|
||||||
"MCPConnectorRead",
|
"MCPConnectorRead",
|
||||||
|
|
@ -148,6 +159,8 @@ __all__ = [
|
||||||
"PodcastCreate",
|
"PodcastCreate",
|
||||||
"PodcastRead",
|
"PodcastRead",
|
||||||
"PodcastUpdate",
|
"PodcastUpdate",
|
||||||
|
"RefreshTokenRequest",
|
||||||
|
"RefreshTokenResponse",
|
||||||
"RoleCreate",
|
"RoleCreate",
|
||||||
"RoleRead",
|
"RoleRead",
|
||||||
"RoleUpdate",
|
"RoleUpdate",
|
||||||
|
|
|
||||||
35
surfsense_backend/app/schemas/auth.py
Normal file
35
surfsense_backend/app/schemas/auth.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Authentication schemas for refresh token endpoints."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenRequest(BaseModel):
|
||||||
|
"""Request body for token refresh endpoint."""
|
||||||
|
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenResponse(BaseModel):
|
||||||
|
"""Response from token refresh endpoint."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequest(BaseModel):
|
||||||
|
"""Request body for logout endpoint (current device)."""
|
||||||
|
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutResponse(BaseModel):
|
||||||
|
"""Response from logout endpoint (current device)."""
|
||||||
|
|
||||||
|
detail: str = "Successfully logged out"
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutAllResponse(BaseModel):
|
||||||
|
"""Response from logout all devices endpoint."""
|
||||||
|
|
||||||
|
detail: str = "Successfully logged out from all devices"
|
||||||
|
|
@ -5,7 +5,7 @@ Service layer for chat comments and mentions.
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|
@ -103,6 +103,37 @@ async def process_mentions(
|
||||||
return mentions_map
|
return mentions_map
|
||||||
|
|
||||||
|
|
||||||
|
async def get_comment_thread_participants(
|
||||||
|
session: AsyncSession,
|
||||||
|
parent_comment_id: int,
|
||||||
|
exclude_user_ids: set[UUID],
|
||||||
|
) -> list[UUID]:
|
||||||
|
"""
|
||||||
|
Get all unique authors in a comment thread (parent + replies), excluding specified users.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
parent_comment_id: ID of the parent comment
|
||||||
|
exclude_user_ids: Set of user IDs to exclude (e.g., replier, mentioned users)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user UUIDs who have participated in the thread
|
||||||
|
"""
|
||||||
|
query = select(ChatComment.author_id).where(
|
||||||
|
or_(
|
||||||
|
ChatComment.id == parent_comment_id,
|
||||||
|
ChatComment.parent_id == parent_comment_id,
|
||||||
|
),
|
||||||
|
ChatComment.author_id.isnot(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
if exclude_user_ids:
|
||||||
|
query = query.where(ChatComment.author_id.notin_(list(exclude_user_ids)))
|
||||||
|
|
||||||
|
result = await session.execute(query.distinct())
|
||||||
|
return [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
async def get_comments_for_message(
|
async def get_comments_for_message(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
message_id: int,
|
message_id: int,
|
||||||
|
|
@ -436,6 +467,31 @@ async def create_reply(
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify thread participants (excluding replier and mentioned users)
|
||||||
|
mentioned_user_ids = set(mentions_map.keys())
|
||||||
|
exclude_ids = {user.id} | mentioned_user_ids
|
||||||
|
participants = await get_comment_thread_participants(
|
||||||
|
session, comment_id, exclude_ids
|
||||||
|
)
|
||||||
|
for participant_id in participants:
|
||||||
|
if participant_id in mentioned_user_ids:
|
||||||
|
continue
|
||||||
|
await NotificationService.comment_reply.notify_comment_reply(
|
||||||
|
session=session,
|
||||||
|
user_id=participant_id,
|
||||||
|
reply_id=reply.id,
|
||||||
|
parent_comment_id=comment_id,
|
||||||
|
message_id=parent_comment.message_id,
|
||||||
|
thread_id=thread.id,
|
||||||
|
thread_title=thread.title or "Untitled thread",
|
||||||
|
author_id=str(user.id),
|
||||||
|
author_name=author_name,
|
||||||
|
author_avatar_url=user.avatar_url,
|
||||||
|
author_email=user.email,
|
||||||
|
content_preview=content_preview[:200],
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
)
|
||||||
|
|
||||||
author = AuthorResponse(
|
author = AuthorResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
display_name=user.display_name,
|
display_name=user.display_name,
|
||||||
|
|
|
||||||
|
|
@ -479,6 +479,31 @@ class VercelStreamingService:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def format_thread_title_update(self, thread_id: int, title: str) -> str:
|
||||||
|
"""
|
||||||
|
Format a thread title update notification (SurfSense specific).
|
||||||
|
|
||||||
|
This is sent after the first response in a thread to update the
|
||||||
|
auto-generated title based on the conversation content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
thread_id: The ID of the thread being updated
|
||||||
|
title: The new title for the thread
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SSE formatted thread title update data part
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
data: {"type":"data-thread-title-update","data":{"threadId":123,"title":"New Title"}}
|
||||||
|
"""
|
||||||
|
return self.format_data(
|
||||||
|
"thread-title-update",
|
||||||
|
{
|
||||||
|
"threadId": thread_id,
|
||||||
|
"title": title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Error Part
|
# Error Part
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -861,6 +861,98 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class CommentReplyNotificationHandler(BaseNotificationHandler):
|
||||||
|
"""Handler for comment reply notifications."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("comment_reply")
|
||||||
|
|
||||||
|
async def find_notification_by_reply(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reply_id: int,
|
||||||
|
user_id: UUID,
|
||||||
|
) -> Notification | None:
|
||||||
|
query = select(Notification).where(
|
||||||
|
Notification.type == self.notification_type,
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.notification_metadata["reply_id"].astext == str(reply_id),
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def notify_comment_reply(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: UUID,
|
||||||
|
reply_id: int,
|
||||||
|
parent_comment_id: int,
|
||||||
|
message_id: int,
|
||||||
|
thread_id: int,
|
||||||
|
thread_title: str,
|
||||||
|
author_id: str,
|
||||||
|
author_name: str,
|
||||||
|
author_avatar_url: str | None,
|
||||||
|
author_email: str,
|
||||||
|
content_preview: str,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> Notification:
|
||||||
|
existing = await self.find_notification_by_reply(session, reply_id, user_id)
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
f"Notification already exists for reply {reply_id} to user {user_id}"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
title = f"{author_name} replied in a thread"
|
||||||
|
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"reply_id": reply_id,
|
||||||
|
"parent_comment_id": parent_comment_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"thread_title": thread_title,
|
||||||
|
"author_id": author_id,
|
||||||
|
"author_name": author_name,
|
||||||
|
"author_avatar_url": author_avatar_url,
|
||||||
|
"author_email": author_email,
|
||||||
|
"content_preview": content_preview[:200],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
type=self.notification_type,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
notification_metadata=metadata,
|
||||||
|
)
|
||||||
|
session.add(notification)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(notification)
|
||||||
|
logger.info(
|
||||||
|
f"Created comment_reply notification {notification.id} for user {user_id}"
|
||||||
|
)
|
||||||
|
return notification
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
if (
|
||||||
|
"duplicate key" in str(e).lower()
|
||||||
|
or "unique constraint" in str(e).lower()
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"Duplicate notification for reply {reply_id} to user {user_id}"
|
||||||
|
)
|
||||||
|
existing = await self.find_notification_by_reply(
|
||||||
|
session, reply_id, user_id
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class PageLimitNotificationHandler(BaseNotificationHandler):
|
class PageLimitNotificationHandler(BaseNotificationHandler):
|
||||||
"""Handler for page limit exceeded notifications."""
|
"""Handler for page limit exceeded notifications."""
|
||||||
|
|
||||||
|
|
@ -959,6 +1051,7 @@ class NotificationService:
|
||||||
connector_indexing = ConnectorIndexingNotificationHandler()
|
connector_indexing = ConnectorIndexingNotificationHandler()
|
||||||
document_processing = DocumentProcessingNotificationHandler()
|
document_processing = DocumentProcessingNotificationHandler()
|
||||||
mention = MentionNotificationHandler()
|
mention = MentionNotificationHandler()
|
||||||
|
comment_reply = CommentReplyNotificationHandler()
|
||||||
page_limit = PageLimitNotificationHandler()
|
page_limit = PageLimitNotificationHandler()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -366,11 +366,14 @@ async def list_snapshots_for_thread(
|
||||||
if not thread:
|
if not thread:
|
||||||
raise HTTPException(status_code=404, detail="Thread not found")
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
if thread.created_by_id != user.id:
|
# Check permission to view public share links
|
||||||
raise HTTPException(
|
await check_permission(
|
||||||
status_code=403,
|
session,
|
||||||
detail="Only the creator can view snapshots",
|
user,
|
||||||
)
|
thread.search_space_id,
|
||||||
|
Permission.PUBLIC_SHARING_VIEW.value,
|
||||||
|
"You don't have permission to view public share links",
|
||||||
|
)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(PublicChatSnapshot)
|
select(PublicChatSnapshot)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from app.services.chat_session_state_service import (
|
||||||
clear_ai_responding,
|
clear_ai_responding,
|
||||||
set_ai_responding,
|
set_ai_responding,
|
||||||
)
|
)
|
||||||
|
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
from app.services.new_streaming_service import VercelStreamingService
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
from app.utils.content_utils import bootstrap_history_from_db
|
from app.utils.content_utils import bootstrap_history_from_db
|
||||||
|
|
@ -1208,6 +1209,59 @@ async def stream_new_chat(
|
||||||
if completion_event:
|
if completion_event:
|
||||||
yield completion_event
|
yield completion_event
|
||||||
|
|
||||||
|
# Generate LLM title for new chats after first response
|
||||||
|
# Check if this is the first assistant response by counting existing assistant messages
|
||||||
|
from app.db import NewChatMessage, NewChatThread
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
assistant_count_result = await session.execute(
|
||||||
|
select(func.count(NewChatMessage.id)).filter(
|
||||||
|
NewChatMessage.thread_id == chat_id,
|
||||||
|
NewChatMessage.role == "assistant",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assistant_message_count = assistant_count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Only generate title on the first response (no prior assistant messages)
|
||||||
|
if assistant_message_count == 0:
|
||||||
|
generated_title = None
|
||||||
|
try:
|
||||||
|
# Generate title using the same LLM
|
||||||
|
title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm
|
||||||
|
# Truncate inputs to avoid context length issues
|
||||||
|
truncated_query = user_query[:500]
|
||||||
|
truncated_response = accumulated_text[:1000]
|
||||||
|
title_result = await title_chain.ainvoke({
|
||||||
|
"user_query": truncated_query,
|
||||||
|
"assistant_response": truncated_response,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Extract and clean the title
|
||||||
|
if title_result and hasattr(title_result, "content"):
|
||||||
|
raw_title = title_result.content.strip()
|
||||||
|
# Validate the title (reasonable length)
|
||||||
|
if raw_title and len(raw_title) <= 100:
|
||||||
|
# Remove any quotes or extra formatting
|
||||||
|
generated_title = raw_title.strip('"\'')
|
||||||
|
except Exception:
|
||||||
|
generated_title = None
|
||||||
|
|
||||||
|
# Only update if LLM succeeded (keep truncated prompt title as fallback)
|
||||||
|
if generated_title:
|
||||||
|
# Fetch thread and update title
|
||||||
|
thread_result = await session.execute(
|
||||||
|
select(NewChatThread).filter(NewChatThread.id == chat_id)
|
||||||
|
)
|
||||||
|
thread = thread_result.scalars().first()
|
||||||
|
if thread:
|
||||||
|
thread.title = generated_title
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Notify frontend of the title update
|
||||||
|
yield streaming_service.format_thread_title_update(
|
||||||
|
chat_id, generated_title
|
||||||
|
)
|
||||||
|
|
||||||
# Finish the step and message
|
# Finish the step and message
|
||||||
yield streaming_service.format_finish_step()
|
yield streaming_service.format_finish_step()
|
||||||
yield streaming_service.format_finish()
|
yield streaming_service.format_finish()
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,20 @@ from app.db import (
|
||||||
get_default_roles_config,
|
get_default_roles_config,
|
||||||
get_user_db,
|
get_user_db,
|
||||||
)
|
)
|
||||||
|
from app.utils.refresh_tokens import create_refresh_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BearerResponse(BaseModel):
|
class BearerResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
SECRET = config.SECRET_KEY
|
SECRET = config.SECRET_KEY
|
||||||
|
|
||||||
|
|
||||||
if config.AUTH_TYPE == "GOOGLE":
|
if config.AUTH_TYPE == "GOOGLE":
|
||||||
from httpx_oauth.clients.google import GoogleOAuth2
|
from httpx_oauth.clients.google import GoogleOAuth2
|
||||||
|
|
||||||
|
|
@ -183,7 +186,10 @@ async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db
|
||||||
|
|
||||||
|
|
||||||
def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
|
def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
|
||||||
return JWTStrategy(secret=SECRET, lifetime_seconds=3600 * 24)
|
return JWTStrategy(
|
||||||
|
secret=SECRET,
|
||||||
|
lifetime_seconds=config.ACCESS_TOKEN_LIFETIME_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# # COOKIE AUTH | Uncomment if you want to use cookie auth.
|
# # COOKIE AUTH | Uncomment if you want to use cookie auth.
|
||||||
|
|
@ -209,9 +215,30 @@ def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
|
||||||
# BEARER AUTH CODE.
|
# BEARER AUTH CODE.
|
||||||
class CustomBearerTransport(BearerTransport):
|
class CustomBearerTransport(BearerTransport):
|
||||||
async def get_login_response(self, token: str) -> Response:
|
async def get_login_response(self, token: str) -> Response:
|
||||||
bearer_response = BearerResponse(access_token=token, token_type="bearer")
|
import jwt
|
||||||
redirect_url = f"{config.NEXT_FRONTEND_URL}/auth/callback?token={bearer_response.access_token}"
|
|
||||||
|
# Decode JWT to get user_id for refresh token creation
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET, algorithms=["HS256"], options={"verify_aud": False})
|
||||||
|
user_id = uuid.UUID(payload.get("sub"))
|
||||||
|
refresh_token = await create_refresh_token(user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create refresh token: {e}")
|
||||||
|
# Fall back to response without refresh token
|
||||||
|
refresh_token = ""
|
||||||
|
|
||||||
|
bearer_response = BearerResponse(
|
||||||
|
access_token=token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
token_type="bearer",
|
||||||
|
)
|
||||||
|
|
||||||
if config.AUTH_TYPE == "GOOGLE":
|
if config.AUTH_TYPE == "GOOGLE":
|
||||||
|
redirect_url = (
|
||||||
|
f"{config.NEXT_FRONTEND_URL}/auth/callback"
|
||||||
|
f"?token={bearer_response.access_token}"
|
||||||
|
f"&refresh_token={bearer_response.refresh_token}"
|
||||||
|
)
|
||||||
return RedirectResponse(redirect_url, status_code=302)
|
return RedirectResponse(redirect_url, status_code=302)
|
||||||
else:
|
else:
|
||||||
return JSONResponse(bearer_response.model_dump())
|
return JSONResponse(bearer_response.model_dump())
|
||||||
|
|
|
||||||
153
surfsense_backend/app/utils/refresh_tokens.py
Normal file
153
surfsense_backend/app/utils/refresh_tokens.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""Utilities for managing refresh tokens."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.db import RefreshToken, async_session_maker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_refresh_token() -> str:
|
||||||
|
"""Generate a cryptographically secure refresh token."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
"""Hash a token for secure storage."""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_refresh_token(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
family_id: uuid.UUID | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create and store a new refresh token for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's ID
|
||||||
|
family_id: Optional family ID for token rotation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The plaintext refresh token
|
||||||
|
"""
|
||||||
|
token = generate_refresh_token()
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
expires_at = datetime.now(UTC) + timedelta(
|
||||||
|
seconds=config.REFRESH_TOKEN_LIFETIME_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
if family_id is None:
|
||||||
|
family_id = uuid.uuid4()
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
refresh_token = RefreshToken(
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=expires_at,
|
||||||
|
family_id=family_id,
|
||||||
|
)
|
||||||
|
session.add(refresh_token)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_refresh_token(token: str) -> RefreshToken | None:
|
||||||
|
"""
|
||||||
|
Validate a refresh token. Handles reuse detection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The plaintext refresh token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RefreshToken if valid, None otherwise
|
||||||
|
"""
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||||
|
)
|
||||||
|
refresh_token = result.scalars().first()
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Reuse detection: revoked token used while family has active tokens
|
||||||
|
if refresh_token.is_revoked:
|
||||||
|
active = await session.execute(
|
||||||
|
select(RefreshToken).where(
|
||||||
|
RefreshToken.family_id == refresh_token.family_id,
|
||||||
|
RefreshToken.is_revoked == False, # noqa: E712
|
||||||
|
RefreshToken.expires_at > datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if active.scalars().first():
|
||||||
|
# Revoke entire family
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.family_id == refresh_token.family_id)
|
||||||
|
.values(is_revoked=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.warning(f"Token reuse detected for user {refresh_token.user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if refresh_token.is_expired:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return refresh_token
|
||||||
|
|
||||||
|
|
||||||
|
async def rotate_refresh_token(old_token: RefreshToken) -> str:
|
||||||
|
"""Revoke old token and create new one in same family."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.id == old_token.id)
|
||||||
|
.values(is_revoked=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return await create_refresh_token(old_token.user_id, old_token.family_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_refresh_token(token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Revoke a single refresh token by its plaintext value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The plaintext refresh token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token was found and revoked, False otherwise
|
||||||
|
"""
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.token_hash == token_hash)
|
||||||
|
.values(is_revoked=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_all_user_tokens(user_id: uuid.UUID) -> None:
|
||||||
|
"""Revoke all refresh tokens for a user (logout all devices)."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await session.execute(
|
||||||
|
update(RefreshToken)
|
||||||
|
.where(RefreshToken.user_id == user_id)
|
||||||
|
.values(is_revoked=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
@ -437,7 +437,10 @@ export default function NewChatPage() {
|
||||||
let isNewThread = false;
|
let isNewThread = false;
|
||||||
if (!currentThreadId) {
|
if (!currentThreadId) {
|
||||||
try {
|
try {
|
||||||
const newThread = await createThread(searchSpaceId, "New Chat");
|
// Create thread with truncated prompt as initial title
|
||||||
|
const initialTitle =
|
||||||
|
userQuery.trim().slice(0, 100) + (userQuery.trim().length > 100 ? "..." : "");
|
||||||
|
const newThread = await createThread(searchSpaceId, initialTitle);
|
||||||
currentThreadId = newThread.id;
|
currentThreadId = newThread.id;
|
||||||
setThreadId(currentThreadId);
|
setThreadId(currentThreadId);
|
||||||
// Set currentThread so ChatHeader can show share button immediately
|
// Set currentThread so ChatHeader can show share button immediately
|
||||||
|
|
@ -827,6 +830,26 @@ export default function NewChatPage() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "data-thread-title-update": {
|
||||||
|
// Handle thread title update from LLM-generated title
|
||||||
|
const titleData = parsed.data as { threadId: number; title: string };
|
||||||
|
if (titleData?.title && titleData?.threadId === currentThreadId) {
|
||||||
|
// Update current thread state with new title
|
||||||
|
setCurrentThread((prev) =>
|
||||||
|
prev ? { ...prev, title: titleData.title } : prev
|
||||||
|
);
|
||||||
|
// Invalidate thread list to refresh sidebar
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["threads", String(searchSpaceId)],
|
||||||
|
});
|
||||||
|
// Invalidate thread detail for breadcrumb update
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["threads", String(searchSpaceId), "detail", String(titleData.threadId)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
throw new Error(parsed.errorText || "Server error");
|
throw new Error(parsed.errorText || "Server error");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ArrowLeft, ChevronRight, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { APP_VERSION } from "@/lib/env-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface SettingsNavItem {
|
export interface SettingsNavItem {
|
||||||
|
|
@ -148,6 +149,11 @@ export function UserSettingsSidebar({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Version display */}
|
||||||
|
<div className="mt-auto border-t px-6 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground/50">v{APP_VERSION}</p>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
|
||||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||||
|
|
||||||
interface TokenHandlerProps {
|
interface TokenHandlerProps {
|
||||||
|
|
@ -35,8 +35,9 @@ const TokenHandler = ({
|
||||||
// Only run on client-side
|
// Only run on client-side
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// Get token from URL parameters
|
// Get tokens from URL parameters
|
||||||
const token = searchParams.get(tokenParamName);
|
const token = searchParams.get(tokenParamName);
|
||||||
|
const refreshToken = searchParams.get("refresh_token");
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -50,10 +51,15 @@ const TokenHandler = ({
|
||||||
// Clear the flag for future logins
|
// Clear the flag for future logins
|
||||||
sessionStorage.removeItem("login_success_tracked");
|
sessionStorage.removeItem("login_success_tracked");
|
||||||
|
|
||||||
// Store token in localStorage using both methods for compatibility
|
// Store access token in localStorage using both methods for compatibility
|
||||||
localStorage.setItem(storageKey, token);
|
localStorage.setItem(storageKey, token);
|
||||||
setBearerToken(token);
|
setBearerToken(token);
|
||||||
|
|
||||||
|
// Store refresh token if provided
|
||||||
|
if (refreshToken) {
|
||||||
|
setRefreshToken(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there's a saved redirect path from before the auth flow
|
// Check if there's a saved redirect path from before the auth flow
|
||||||
const savedRedirectPath = getAndClearRedirectPath();
|
const savedRedirectPath = getAndClearRedirectPath();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BadgeCheck, LogOut } from "lucide-react";
|
import { BadgeCheck, Loader2, LogOut } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +14,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { logout } from "@/lib/auth-utils";
|
||||||
import { cleanupElectric } from "@/lib/electric/client";
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
|
|
||||||
|
|
@ -26,8 +28,11 @@ export function UserDropdown({
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
if (isLoggingOut) return;
|
||||||
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
// Track logout event and reset PostHog identity
|
// Track logout event and reset PostHog identity
|
||||||
trackLogout();
|
trackLogout();
|
||||||
|
|
@ -41,15 +46,17 @@ export function UserDropdown({
|
||||||
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
|
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revoke refresh token on server and clear all tokens from localStorage
|
||||||
|
await logout();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("surfsense_bearer_token");
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during logout:", error);
|
console.error("Error during logout:", error);
|
||||||
// Optionally, provide user feedback
|
// Even if there's an error, try to clear tokens and redirect
|
||||||
|
await logout();
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("surfsense_bearer_token");
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,9 +92,17 @@ export function UserDropdown({
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout} className="text-xs md:text-sm">
|
<DropdownMenuItem
|
||||||
<LogOut className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4" />
|
onClick={handleLogout}
|
||||||
Log out
|
className="text-xs md:text-sm"
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
>
|
||||||
|
{isLoggingOut ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOut className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
|
)}
|
||||||
|
{isLoggingOut ? "Logging out..." : "Log out"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,19 @@ import {
|
||||||
ErrorPrimitive,
|
ErrorPrimitive,
|
||||||
MessagePrimitive,
|
MessagePrimitive,
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
|
useMessage,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addingCommentToMessageIdAtom,
|
addingCommentToMessageIdAtom,
|
||||||
clearTargetCommentIdAtom,
|
|
||||||
commentsCollapsedAtom,
|
commentsCollapsedAtom,
|
||||||
commentsEnabledAtom,
|
commentsEnabledAtom,
|
||||||
targetCommentIdAtom,
|
targetCommentIdAtom,
|
||||||
} from "@/atoms/chat/current-thread.atom";
|
} from "@/atoms/chat/current-thread.atom";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
|
||||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||||
import {
|
import {
|
||||||
ThinkingStepsContext,
|
ThinkingStepsContext,
|
||||||
|
|
@ -84,7 +83,6 @@ const AssistantMessageInner: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||||
<BranchPicker />
|
|
||||||
<AssistantActionBar />
|
<AssistantActionBar />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -126,7 +124,6 @@ export const AssistantMessage: FC = () => {
|
||||||
|
|
||||||
// Target comment navigation - read target from global atom
|
// Target comment navigation - read target from global atom
|
||||||
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
||||||
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
|
||||||
|
|
||||||
// Check if target comment belongs to this message (including replies)
|
// Check if target comment belongs to this message (including replies)
|
||||||
const hasTargetComment = useMemo(() => {
|
const hasTargetComment = useMemo(() => {
|
||||||
|
|
@ -263,6 +260,8 @@ export const AssistantMessage: FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantActionBar: FC = () => {
|
const AssistantActionBar: FC = () => {
|
||||||
|
const { isLast } = useMessage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root
|
<ActionBarPrimitive.Root
|
||||||
hideWhenRunning
|
hideWhenRunning
|
||||||
|
|
@ -285,11 +284,14 @@ const AssistantActionBar: FC = () => {
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ActionBarPrimitive.ExportMarkdown>
|
</ActionBarPrimitive.ExportMarkdown>
|
||||||
<ActionBarPrimitive.Reload asChild>
|
{/* Only allow regenerating the last assistant message */}
|
||||||
<TooltipIconButton tooltip="Refresh">
|
{isLast && (
|
||||||
<RefreshCwIcon />
|
<ActionBarPrimitive.Reload asChild>
|
||||||
</TooltipIconButton>
|
<TooltipIconButton tooltip="Refresh">
|
||||||
</ActionBarPrimitive.Reload>
|
<RefreshCwIcon />
|
||||||
|
</TooltipIconButton>
|
||||||
|
</ActionBarPrimitive.Reload>
|
||||||
|
)}
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { BranchPickerPrimitive } from "@assistant-ui/react";
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest }) => {
|
|
||||||
return (
|
|
||||||
<BranchPickerPrimitive.Root
|
|
||||||
hideWhenSingleBranch
|
|
||||||
className={cn(
|
|
||||||
"aui-branch-picker-root -ml-2 mr-2 inline-flex items-center text-muted-foreground text-xs",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<BranchPickerPrimitive.Previous asChild>
|
|
||||||
<TooltipIconButton tooltip="Previous">
|
|
||||||
<ChevronLeftIcon />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</BranchPickerPrimitive.Previous>
|
|
||||||
<span className="aui-branch-picker-state font-medium">
|
|
||||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
|
||||||
</span>
|
|
||||||
<BranchPickerPrimitive.Next asChild>
|
|
||||||
<TooltipIconButton tooltip="Next">
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</BranchPickerPrimitive.Next>
|
|
||||||
</BranchPickerPrimitive.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { FileText, PencilIcon } from "lucide-react";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
||||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
|
|
||||||
interface AuthorMetadata {
|
interface AuthorMetadata {
|
||||||
|
|
@ -95,24 +94,47 @@ export const UserMessage: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
|
|
||||||
</MessagePrimitive.Root>
|
</MessagePrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserActionBar: FC = () => {
|
const UserActionBar: FC = () => {
|
||||||
|
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||||
|
|
||||||
|
// Get current message ID
|
||||||
|
const currentMessageId = useAssistantState(({ message }) => message?.id);
|
||||||
|
|
||||||
|
// Find the last user message ID in the thread (computed once, memoized by selector)
|
||||||
|
const lastUserMessageId = useAssistantState(({ thread }) => {
|
||||||
|
const messages = thread.messages;
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].role === "user") {
|
||||||
|
return messages[i].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple comparison - no iteration needed per message
|
||||||
|
const isLastUserMessage = currentMessageId === lastUserMessageId;
|
||||||
|
|
||||||
|
// Show edit button only on the last user message and when thread is not running
|
||||||
|
const canEdit = isLastUserMessage && !isThreadRunning;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root
|
<ActionBarPrimitive.Root
|
||||||
hideWhenRunning
|
hideWhenRunning
|
||||||
autohide="not-last"
|
autohide="not-last"
|
||||||
className="aui-user-action-bar-root flex flex-col items-end"
|
className="aui-user-action-bar-root flex flex-col items-end"
|
||||||
>
|
>
|
||||||
<ActionBarPrimitive.Edit asChild>
|
{/* Only allow editing the last user message */}
|
||||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
{canEdit && (
|
||||||
<PencilIcon />
|
<ActionBarPrimitive.Edit asChild>
|
||||||
</TooltipIconButton>
|
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
||||||
</ActionBarPrimitive.Edit>
|
<PencilIcon />
|
||||||
|
</TooltipIconButton>
|
||||||
|
</ActionBarPrimitive.Edit>
|
||||||
|
)}
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
|
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { getThreadFull } from "@/lib/chat/thread-persistence";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
interface BreadcrumbItemInterface {
|
interface BreadcrumbItemInterface {
|
||||||
|
|
@ -34,6 +35,16 @@ export function DashboardBreadcrumb() {
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract chat thread ID from pathname for chat pages
|
||||||
|
const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null;
|
||||||
|
|
||||||
|
// Fetch thread details when on a chat page with a thread ID
|
||||||
|
const { data: threadData } = useQuery({
|
||||||
|
queryKey: ["threads", searchSpaceId, "detail", chatThreadId],
|
||||||
|
queryFn: () => getThreadFull(Number(chatThreadId)),
|
||||||
|
enabled: !!chatThreadId && !!searchSpaceId,
|
||||||
|
});
|
||||||
|
|
||||||
// State to store document title for editor breadcrumb
|
// State to store document title for editor breadcrumb
|
||||||
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
|
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -144,10 +155,11 @@ export function DashboardBreadcrumb() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new-chat sub-sections (thread IDs)
|
// Handle new-chat sub-sections (thread IDs)
|
||||||
// Don't show thread ID in breadcrumb - users identify chats by content, not by ID
|
// Show the chat title if available, otherwise fall back to "Chat"
|
||||||
if (section === "new-chat") {
|
if (section === "new-chat") {
|
||||||
|
const chatLabel = threadData?.title || t("chat") || "Chat";
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: t("chat") || "Chat",
|
label: chatLabel,
|
||||||
});
|
});
|
||||||
return breadcrumbs;
|
return breadcrumbs;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
@ -21,10 +21,12 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||||
|
import { logout } from "@/lib/auth-utils";
|
||||||
import { cleanupElectric } from "@/lib/electric/client";
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
@ -109,7 +111,6 @@ export function LayoutDataProvider({
|
||||||
// This ensures each tab has independent pagination and data loading
|
// This ensures each tab has independent pagination and data loading
|
||||||
const userId = user?.id ? String(user.id) : null;
|
const userId = user?.id ? String(user.id) : null;
|
||||||
|
|
||||||
// Mentions: Only fetch "new_mention" type notifications
|
|
||||||
const {
|
const {
|
||||||
inboxItems: mentionItems,
|
inboxItems: mentionItems,
|
||||||
unreadCount: mentionUnreadCount,
|
unreadCount: mentionUnreadCount,
|
||||||
|
|
@ -121,11 +122,9 @@ export function LayoutDataProvider({
|
||||||
markAllAsRead: markAllMentionsAsRead,
|
markAllAsRead: markAllMentionsAsRead,
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
|
||||||
|
|
||||||
// Status: Fetch all types (will be filtered client-side to status types)
|
|
||||||
// We pass null to get all, then InboxSidebar filters to status types
|
|
||||||
const {
|
const {
|
||||||
inboxItems: statusItems,
|
inboxItems: statusItems,
|
||||||
unreadCount: statusUnreadCount,
|
unreadCount: allUnreadCount,
|
||||||
loading: statusLoading,
|
loading: statusLoading,
|
||||||
loadingMore: statusLoadingMore,
|
loadingMore: statusLoadingMore,
|
||||||
hasMore: statusHasMore,
|
hasMore: statusHasMore,
|
||||||
|
|
@ -134,8 +133,8 @@ export function LayoutDataProvider({
|
||||||
markAllAsRead: markAllStatusAsRead,
|
markAllAsRead: markAllStatusAsRead,
|
||||||
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||||
|
|
||||||
// Combined unread count for nav badge (mentions take priority for visibility)
|
const totalUnreadCount = allUnreadCount;
|
||||||
const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
|
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
|
||||||
|
|
||||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||||
|
|
@ -207,6 +206,12 @@ export function LayoutDataProvider({
|
||||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||||
const [isDeletingChat, setIsDeletingChat] = useState(false);
|
const [isDeletingChat, setIsDeletingChat] = useState(false);
|
||||||
|
|
||||||
|
// Rename dialog state
|
||||||
|
const [showRenameChatDialog, setShowRenameChatDialog] = useState(false);
|
||||||
|
const [chatToRename, setChatToRename] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const [newChatTitle, setNewChatTitle] = useState("");
|
||||||
|
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||||
|
|
||||||
// Delete/Leave search space dialog state
|
// Delete/Leave search space dialog state
|
||||||
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
|
const [showDeleteSearchSpaceDialog, setShowDeleteSearchSpaceDialog] = useState(false);
|
||||||
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
|
const [showLeaveSearchSpaceDialog, setShowLeaveSearchSpaceDialog] = useState(false);
|
||||||
|
|
@ -421,6 +426,12 @@ export function LayoutDataProvider({
|
||||||
setShowDeleteChatDialog(true);
|
setShowDeleteChatDialog(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleChatRename = useCallback((chat: ChatItem) => {
|
||||||
|
setChatToRename({ id: chat.id, name: chat.name });
|
||||||
|
setNewChatTitle(chat.name);
|
||||||
|
setShowRenameChatDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChatArchive = useCallback(
|
const handleChatArchive = useCallback(
|
||||||
async (chat: ChatItem) => {
|
async (chat: ChatItem) => {
|
||||||
const newArchivedState = !chat.archived;
|
const newArchivedState = !chat.archived;
|
||||||
|
|
@ -464,12 +475,15 @@ export function LayoutDataProvider({
|
||||||
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
|
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revoke refresh token on server and clear all tokens from localStorage
|
||||||
|
await logout();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("surfsense_bearer_token");
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during logout:", error);
|
console.error("Error during logout:", error);
|
||||||
|
await logout();
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
@ -501,6 +515,29 @@ export function LayoutDataProvider({
|
||||||
}
|
}
|
||||||
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
||||||
|
|
||||||
|
// Rename handler
|
||||||
|
const confirmRenameChat = useCallback(async () => {
|
||||||
|
if (!chatToRename || !newChatTitle.trim()) return;
|
||||||
|
setIsRenamingChat(true);
|
||||||
|
try {
|
||||||
|
await updateThread(chatToRename.id, { title: newChatTitle.trim() });
|
||||||
|
toast.success(tSidebar("chat_renamed") || "Chat renamed");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||||
|
// Invalidate thread detail for breadcrumb update
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error renaming thread:", error);
|
||||||
|
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||||
|
} finally {
|
||||||
|
setIsRenamingChat(false);
|
||||||
|
setShowRenameChatDialog(false);
|
||||||
|
setChatToRename(null);
|
||||||
|
setNewChatTitle("");
|
||||||
|
}
|
||||||
|
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
|
||||||
|
|
||||||
// Page usage
|
// Page usage
|
||||||
const pageUsage = user
|
const pageUsage = user
|
||||||
? {
|
? {
|
||||||
|
|
@ -529,6 +566,7 @@ export function LayoutDataProvider({
|
||||||
activeChatId={currentChatId}
|
activeChatId={currentChatId}
|
||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChat}
|
||||||
onChatSelect={handleChatSelect}
|
onChatSelect={handleChatSelect}
|
||||||
|
onChatRename={handleChatRename}
|
||||||
onChatDelete={handleChatDelete}
|
onChatDelete={handleChatDelete}
|
||||||
onChatArchive={handleChatArchive}
|
onChatArchive={handleChatArchive}
|
||||||
onViewAllSharedChats={handleViewAllSharedChats}
|
onViewAllSharedChats={handleViewAllSharedChats}
|
||||||
|
|
@ -562,7 +600,7 @@ export function LayoutDataProvider({
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
items: statusItems,
|
items: statusItems,
|
||||||
unreadCount: statusUnreadCount,
|
unreadCount: statusOnlyUnreadCount,
|
||||||
loading: statusLoading,
|
loading: statusLoading,
|
||||||
loadingMore: statusLoadingMore,
|
loadingMore: statusLoadingMore,
|
||||||
hasMore: statusHasMore,
|
hasMore: statusHasMore,
|
||||||
|
|
@ -621,6 +659,57 @@ export function LayoutDataProvider({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Rename Chat Dialog */}
|
||||||
|
<Dialog open={showRenameChatDialog} onOpenChange={setShowRenameChatDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<PencilIcon className="h-5 w-5" />
|
||||||
|
<span>{tSidebar("rename_chat") || "Rename Chat"}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{tSidebar("rename_chat_description") || "Enter a new name for this conversation."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={newChatTitle}
|
||||||
|
onChange={(e) => setNewChatTitle(e.target.value)}
|
||||||
|
placeholder={tSidebar("chat_title_placeholder") || "Chat title"}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !isRenamingChat && newChatTitle.trim()) {
|
||||||
|
confirmRenameChat();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowRenameChatDialog(false)}
|
||||||
|
disabled={isRenamingChat}
|
||||||
|
>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={confirmRenameChat}
|
||||||
|
disabled={isRenamingChat || !newChatTitle.trim()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isRenamingChat ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
{tSidebar("renaming") || "Renaming..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
{tSidebar("rename") || "Rename"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Search Space Dialog */}
|
{/* Delete Search Space Dialog */}
|
||||||
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
<Dialog open={showDeleteSearchSpaceDialog} onOpenChange={setShowDeleteSearchSpaceDialog}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ interface LayoutShellProps {
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatRename?: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onChatArchive?: (chat: ChatItem) => void;
|
onChatArchive?: (chat: ChatItem) => void;
|
||||||
onViewAllSharedChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
|
|
@ -91,6 +92,7 @@ export function LayoutShell({
|
||||||
activeChatId,
|
activeChatId,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onChatSelect,
|
onChatSelect,
|
||||||
|
onChatRename,
|
||||||
onChatDelete,
|
onChatDelete,
|
||||||
onChatArchive,
|
onChatArchive,
|
||||||
onViewAllSharedChats,
|
onViewAllSharedChats,
|
||||||
|
|
@ -149,6 +151,7 @@ export function LayoutShell({
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onChatSelect={onChatSelect}
|
onChatSelect={onChatSelect}
|
||||||
|
onChatRename={onChatRename}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onChatArchive={onChatArchive}
|
onChatArchive={onChatArchive}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
|
|
@ -218,6 +221,7 @@ export function LayoutShell({
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onChatSelect={onChatSelect}
|
onChatSelect={onChatSelect}
|
||||||
|
onChatRename={onChatRename}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onChatArchive={onChatArchive}
|
onChatArchive={onChatArchive}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
|
import { ArchiveIcon, MessageSquare, MoreHorizontal, PencilIcon, RotateCcwIcon, Trash2 } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,6 +17,7 @@ interface ChatListItemProps {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +27,7 @@ export function ChatListItem({
|
||||||
isActive,
|
isActive,
|
||||||
archived,
|
archived,
|
||||||
onClick,
|
onClick,
|
||||||
|
onRename,
|
||||||
onArchive,
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ChatListItemProps) {
|
}: ChatListItemProps) {
|
||||||
|
|
@ -57,15 +59,26 @@ export function ChatListItem({
|
||||||
<span className="sr-only">{t("more_options")}</span>
|
<span className="sr-only">{t("more_options")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" side="right">
|
<DropdownMenuContent align="end" side="right">
|
||||||
{onArchive && (
|
{onRename && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onArchive();
|
onRename();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{archived ? (
|
<PencilIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("rename") || "Rename"}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onArchive && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onArchive();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{archived ? (
|
||||||
<>
|
<>
|
||||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||||
<span>{t("unarchive") || "Restore"}</span>
|
<span>{t("unarchive") || "Restore"}</span>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
AtSign,
|
|
||||||
BellDot,
|
BellDot,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
|
@ -15,6 +14,7 @@ import {
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -46,6 +46,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import {
|
import {
|
||||||
|
isCommentReplyMetadata,
|
||||||
isConnectorIndexingMetadata,
|
isConnectorIndexingMetadata,
|
||||||
isNewMentionMetadata,
|
isNewMentionMetadata,
|
||||||
isPageLimitExceededMetadata,
|
isPageLimitExceededMetadata,
|
||||||
|
|
@ -133,7 +134,7 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboxTab = "mentions" | "status";
|
type InboxTab = "comments" | "status";
|
||||||
type InboxFilter = "all" | "unread";
|
type InboxFilter = "all" | "unread";
|
||||||
|
|
||||||
// Tab-specific data source with independent pagination
|
// Tab-specific data source with independent pagination
|
||||||
|
|
@ -186,7 +187,7 @@ export function InboxSidebar({
|
||||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
@ -233,12 +234,17 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
// Get current tab's data source - each tab has independent data and pagination
|
// Both tabs now derive items from status (all types), so use status for pagination
|
||||||
const currentDataSource = activeTab === "mentions" ? mentions : status;
|
const { loading, loadingMore = false, hasMore = false, loadMore } = status;
|
||||||
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
|
|
||||||
|
|
||||||
// Status tab includes: connector indexing, document processing, page limit exceeded, connector deletion
|
// Comments tab: mentions and comment replies
|
||||||
// Filter to only show status notification types
|
const commentsItems = useMemo(
|
||||||
|
() =>
|
||||||
|
status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"),
|
||||||
|
[status.items]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status tab: connector indexing, document processing, page limit exceeded, connector deletion
|
||||||
const statusItems = useMemo(
|
const statusItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
status.items.filter(
|
status.items.filter(
|
||||||
|
|
@ -270,8 +276,8 @@ export function InboxSidebar({
|
||||||
}));
|
}));
|
||||||
}, [statusItems]);
|
}, [statusItems]);
|
||||||
|
|
||||||
// Get items for current tab - mentions use their source directly, status uses filtered items
|
// Get items for current tab
|
||||||
const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
|
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
||||||
|
|
||||||
// Filter items based on filter type, connector filter, and search query
|
// Filter items based on filter type, connector filter, and search query
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
|
|
@ -334,9 +340,15 @@ export function InboxSidebar({
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
||||||
|
|
||||||
// Use unread counts from data sources (more accurate than client-side counting)
|
// Unread counts derived from filtered items
|
||||||
const unreadMentionsCount = mentions.unreadCount;
|
const unreadCommentsCount = useMemo(
|
||||||
const unreadStatusCount = status.unreadCount;
|
() => commentsItems.filter((item) => !item.read).length,
|
||||||
|
[commentsItems]
|
||||||
|
);
|
||||||
|
const unreadStatusCount = useMemo(
|
||||||
|
() => statusItems.filter((item) => !item.read).length,
|
||||||
|
[statusItems]
|
||||||
|
);
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleItemClick = useCallback(
|
||||||
async (item: InboxItem) => {
|
async (item: InboxItem) => {
|
||||||
|
|
@ -347,19 +359,15 @@ export function InboxSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "new_mention") {
|
if (item.type === "new_mention") {
|
||||||
// Use type guard for safe metadata access
|
|
||||||
if (isNewMentionMetadata(item.metadata)) {
|
if (isNewMentionMetadata(item.metadata)) {
|
||||||
const searchSpaceId = item.search_space_id;
|
const searchSpaceId = item.search_space_id;
|
||||||
const threadId = item.metadata.thread_id;
|
const threadId = item.metadata.thread_id;
|
||||||
const commentId = item.metadata.comment_id;
|
const commentId = item.metadata.comment_id;
|
||||||
|
|
||||||
if (searchSpaceId && threadId) {
|
if (searchSpaceId && threadId) {
|
||||||
// Pre-set target comment ID before navigation
|
|
||||||
// This also ensures comments panel is not collapsed
|
|
||||||
if (commentId) {
|
if (commentId) {
|
||||||
setTargetCommentId(commentId);
|
setTargetCommentId(commentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = commentId
|
const url = commentId
|
||||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||||
|
|
@ -368,6 +376,24 @@ export function InboxSidebar({
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (item.type === "comment_reply") {
|
||||||
|
if (isCommentReplyMetadata(item.metadata)) {
|
||||||
|
const searchSpaceId = item.search_space_id;
|
||||||
|
const threadId = item.metadata.thread_id;
|
||||||
|
const replyId = item.metadata.reply_id;
|
||||||
|
|
||||||
|
if (searchSpaceId && threadId) {
|
||||||
|
if (replyId) {
|
||||||
|
setTargetCommentId(replyId);
|
||||||
|
}
|
||||||
|
const url = replyId
|
||||||
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}`
|
||||||
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||||
|
onOpenChange(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (item.type === "page_limit_exceeded") {
|
} else if (item.type === "page_limit_exceeded") {
|
||||||
// Navigate to the upgrade/more-pages page
|
// Navigate to the upgrade/more-pages page
|
||||||
if (isPageLimitExceededMetadata(item.metadata)) {
|
if (isPageLimitExceededMetadata(item.metadata)) {
|
||||||
|
|
@ -411,24 +437,29 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (item: InboxItem) => {
|
const getStatusIcon = (item: InboxItem) => {
|
||||||
// For mentions, show the author's avatar with initials fallback
|
// For mentions and comment replies, show the author's avatar
|
||||||
if (item.type === "new_mention") {
|
if (item.type === "new_mention" || item.type === "comment_reply") {
|
||||||
// Use type guard for safe metadata access
|
const metadata =
|
||||||
if (isNewMentionMetadata(item.metadata)) {
|
item.type === "new_mention"
|
||||||
const authorName = item.metadata.author_name;
|
? isNewMentionMetadata(item.metadata)
|
||||||
const avatarUrl = item.metadata.author_avatar_url;
|
? item.metadata
|
||||||
const authorEmail = item.metadata.author_email;
|
: null
|
||||||
|
: isCommentReplyMetadata(item.metadata)
|
||||||
|
? item.metadata
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
return (
|
return (
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
{metadata.author_avatar_url && (
|
||||||
|
<AvatarImage src={metadata.author_avatar_url} alt={metadata.author_name || "User"} />
|
||||||
|
)}
|
||||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
{getInitials(authorName, authorEmail)}
|
{getInitials(metadata.author_name, metadata.author_email)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Fallback for invalid metadata
|
|
||||||
return (
|
return (
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
|
|
@ -481,10 +512,10 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEmptyStateMessage = () => {
|
const getEmptyStateMessage = () => {
|
||||||
if (activeTab === "mentions") {
|
if (activeTab === "comments") {
|
||||||
return {
|
return {
|
||||||
title: t("no_mentions") || "No mentions",
|
title: t("no_comments") || "No comments",
|
||||||
hint: t("no_mentions_hint") || "You'll see mentions from others here",
|
hint: t("no_comments_hint") || "You'll see mentions and replies here",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -823,14 +854,14 @@ export function InboxSidebar({
|
||||||
>
|
>
|
||||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="mentions"
|
value="comments"
|
||||||
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
>
|
>
|
||||||
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
<AtSign className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
<span>{t("mentions") || "Mentions"}</span>
|
<span>{t("comments") || "Comments"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{formatInboxCount(unreadMentionsCount)}
|
{formatInboxCount(unreadCommentsCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -932,8 +963,8 @@ export function InboxSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
{activeTab === "mentions" ? (
|
{activeTab === "comments" ? (
|
||||||
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
) : (
|
) : (
|
||||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ interface MobileSidebarProps {
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatRename?: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onChatArchive?: (chat: ChatItem) => void;
|
onChatArchive?: (chat: ChatItem) => void;
|
||||||
onViewAllSharedChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
|
|
@ -65,6 +66,7 @@ export function MobileSidebar({
|
||||||
activeChatId,
|
activeChatId,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onChatSelect,
|
onChatSelect,
|
||||||
|
onChatRename,
|
||||||
onChatDelete,
|
onChatDelete,
|
||||||
onChatArchive,
|
onChatArchive,
|
||||||
onViewAllSharedChats,
|
onViewAllSharedChats,
|
||||||
|
|
@ -144,6 +146,7 @@ export function MobileSidebar({
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
onChatSelect={handleChatSelect}
|
onChatSelect={handleChatSelect}
|
||||||
|
onChatRename={onChatRename}
|
||||||
onChatDelete={onChatDelete}
|
onChatDelete={onChatDelete}
|
||||||
onChatArchive={onChatArchive}
|
onChatArchive={onChatArchive}
|
||||||
onViewAllSharedChats={onViewAllSharedChats}
|
onViewAllSharedChats={onViewAllSharedChats}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ interface SidebarProps {
|
||||||
activeChatId?: number | null;
|
activeChatId?: number | null;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onChatSelect: (chat: ChatItem) => void;
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatRename?: (chat: ChatItem) => void;
|
||||||
onChatDelete?: (chat: ChatItem) => void;
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
onChatArchive?: (chat: ChatItem) => void;
|
onChatArchive?: (chat: ChatItem) => void;
|
||||||
onViewAllSharedChats?: () => void;
|
onViewAllSharedChats?: () => void;
|
||||||
|
|
@ -62,6 +63,7 @@ export function Sidebar({
|
||||||
activeChatId,
|
activeChatId,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onChatSelect,
|
onChatSelect,
|
||||||
|
onChatRename,
|
||||||
onChatDelete,
|
onChatDelete,
|
||||||
onChatArchive,
|
onChatArchive,
|
||||||
onViewAllSharedChats,
|
onViewAllSharedChats,
|
||||||
|
|
@ -183,6 +185,7 @@ export function Sidebar({
|
||||||
isActive={chat.id === activeChatId}
|
isActive={chat.id === activeChatId}
|
||||||
archived={chat.archived}
|
archived={chat.archived}
|
||||||
onClick={() => onChatSelect(chat)}
|
onClick={() => onChatSelect(chat)}
|
||||||
|
onRename={() => onChatRename?.(chat)}
|
||||||
onArchive={() => onChatArchive?.(chat)}
|
onArchive={() => onChatArchive?.(chat)}
|
||||||
onDelete={() => onChatDelete?.(chat)}
|
onDelete={() => onChatDelete?.(chat)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -243,6 +246,7 @@ export function Sidebar({
|
||||||
isActive={chat.id === activeChatId}
|
isActive={chat.id === activeChatId}
|
||||||
archived={chat.archived}
|
archived={chat.archived}
|
||||||
onClick={() => onChatSelect(chat)}
|
onClick={() => onChatSelect(chat)}
|
||||||
|
onRename={() => onChatRename?.(chat)}
|
||||||
onArchive={() => onChatArchive?.(chat)}
|
onArchive={() => onChatArchive?.(chat)}
|
||||||
onDelete={() => onChatDelete?.(chat)}
|
onDelete={() => onChatDelete?.(chat)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
|
import { Check, ChevronUp, Languages, Laptop, Loader2, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -124,6 +125,7 @@ export function SidebarUserProfile({
|
||||||
}: SidebarUserProfileProps) {
|
}: SidebarUserProfileProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const { locale, setLocale } = useLocaleContext();
|
const { locale, setLocale } = useLocaleContext();
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const bgColor = stringToColor(user.email);
|
const bgColor = stringToColor(user.email);
|
||||||
const initials = getInitials(user.email);
|
const initials = getInitials(user.email);
|
||||||
const displayName = user.name || user.email.split("@")[0];
|
const displayName = user.name || user.email.split("@")[0];
|
||||||
|
|
@ -136,6 +138,16 @@ export function SidebarUserProfile({
|
||||||
setTheme?.(newTheme);
|
setTheme?.(newTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (isLoggingOut || !onLogout) return;
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await onLogout();
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Collapsed view - just show avatar with dropdown
|
// Collapsed view - just show avatar with dropdown
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -242,9 +254,13 @@ export function SidebarUserProfile({
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
{isLoggingOut ? (
|
||||||
{t("logout")}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isLoggingOut ? t("loggingOut") : t("logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -360,9 +376,13 @@ export function SidebarUserProfile({
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
{isLoggingOut ? (
|
||||||
{t("logout")}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isLoggingOut ? t("loggingOut") : t("logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Globe, User, Users } from "lucide-react";
|
import { Globe, User, Users } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
|
@ -11,6 +12,7 @@ import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapsh
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
|
||||||
import {
|
import {
|
||||||
type ChatVisibility,
|
type ChatVisibility,
|
||||||
type ThreadRecord,
|
type ThreadRecord,
|
||||||
|
|
@ -46,6 +48,8 @@ const visibilityOptions: {
|
||||||
|
|
||||||
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Use Jotai atom for visibility (single source of truth)
|
// Use Jotai atom for visibility (single source of truth)
|
||||||
|
|
@ -65,6 +69,16 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
return access.permissions?.includes("public_sharing:create") ?? false;
|
return access.permissions?.includes("public_sharing:create") ?? false;
|
||||||
}, [access]);
|
}, [access]);
|
||||||
|
|
||||||
|
// Query to check if thread has public snapshots
|
||||||
|
const { data: snapshotsData } = useQuery({
|
||||||
|
queryKey: ["thread-snapshots", thread?.id],
|
||||||
|
queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }),
|
||||||
|
enabled: !!thread?.id,
|
||||||
|
staleTime: 30000, // Cache for 30 seconds
|
||||||
|
});
|
||||||
|
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
||||||
|
const snapshotCount = snapshotsData?.snapshots?.length ?? 0;
|
||||||
|
|
||||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||||
|
|
||||||
|
|
@ -106,11 +120,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createSnapshot({ thread_id: thread.id });
|
await createSnapshot({ thread_id: thread.id });
|
||||||
|
// Refetch snapshots to show the globe indicator
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["thread-snapshots", thread.id] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create public link:", error);
|
console.error("Failed to create public link:", error);
|
||||||
}
|
}
|
||||||
}, [thread, createSnapshot]);
|
}, [thread, createSnapshot, queryClient]);
|
||||||
|
|
||||||
// Don't show if no thread (new chat that hasn't been created yet)
|
// Don't show if no thread (new chat that hasn't been created yet)
|
||||||
if (!thread) {
|
if (!thread) {
|
||||||
|
|
@ -121,112 +137,131 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
|
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<div className={cn("flex items-center gap-1", className)}>
|
||||||
<Tooltip>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<PopoverTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
size="icon"
|
variant="outline"
|
||||||
className={cn(
|
size="icon"
|
||||||
"h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0",
|
className="h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0"
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CurrentIcon className="h-4 w-4" />
|
|
||||||
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Share settings</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="p-1.5 space-y-1">
|
|
||||||
{/* Visibility Options */}
|
|
||||||
{visibilityOptions.map((option) => {
|
|
||||||
const isSelected = currentVisibility === option.value;
|
|
||||||
const Icon = option.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => handleVisibilityChange(option.value)}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
|
||||||
"focus:outline-none",
|
|
||||||
isSelected && "bg-accent/80"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div
|
<CurrentIcon className="h-4 w-4" />
|
||||||
|
<span className="hidden md:inline text-sm">{buttonLabel}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Share settings</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="p-1.5 space-y-1">
|
||||||
|
{/* Visibility Options */}
|
||||||
|
{visibilityOptions.map((option) => {
|
||||||
|
const isSelected = currentVisibility === option.value;
|
||||||
|
const Icon = option.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleVisibilityChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-7 rounded-md shrink-0 grid place-items-center",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
isSelected ? "bg-primary/10" : "bg-muted"
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
|
"focus:outline-none",
|
||||||
|
isSelected && "bg-accent/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 block",
|
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||||
isSelected ? "text-primary" : "text-muted-foreground"
|
isSelected ? "bg-primary/10" : "bg-muted"
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
</div>
|
<Icon
|
||||||
<div className="flex-1 text-left min-w-0">
|
className={cn(
|
||||||
<div className="flex items-center gap-1.5">
|
"size-4 block",
|
||||||
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
isSelected ? "text-primary" : "text-muted-foreground"
|
||||||
{option.label}
|
)}
|
||||||
</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
<div className="flex-1 text-left min-w-0">
|
||||||
{option.description}
|
<div className="flex items-center gap-1.5">
|
||||||
</p>
|
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
||||||
</div>
|
{option.label}
|
||||||
</button>
|
</span>
|
||||||
);
|
</div>
|
||||||
})}
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
|
{option.description}
|
||||||
{canCreatePublicLink && (
|
</p>
|
||||||
<>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-border my-1" />
|
|
||||||
|
|
||||||
{/* Public Link Option */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCreatePublicLink}
|
|
||||||
disabled={isCreatingSnapshot}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
|
||||||
"focus:outline-none",
|
|
||||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
|
||||||
<Globe className="size-4 block text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
</button>
|
||||||
Creates a shareable snapshot of this chat
|
);
|
||||||
</p>
|
})}
|
||||||
</div>
|
|
||||||
</button>
|
{canCreatePublicLink && (
|
||||||
</>
|
<>
|
||||||
)}
|
{/* Divider */}
|
||||||
</div>
|
<div className="border-t border-border my-1" />
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
{/* Public Link Option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreatePublicLink}
|
||||||
|
disabled={isCreatingSnapshot}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
|
"focus:outline-none",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
||||||
|
<Globe className="size-4 block text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
|
Creates a shareable snapshot of this chat
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
||||||
|
{hasPublicSnapshots && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
|
||||||
|
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{snapshotCount === 1
|
||||||
|
? "This chat has a public link"
|
||||||
|
: `This chat has ${snapshotCount} public links`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface PricingPlan {
|
||||||
price: string;
|
price: string;
|
||||||
yearlyPrice: string;
|
yearlyPrice: string;
|
||||||
period: string;
|
period: string;
|
||||||
|
billingText?: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
description: string;
|
description: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
|
|
@ -35,7 +36,7 @@ export function Pricing({
|
||||||
title = "Simple, Transparent Pricing",
|
title = "Simple, Transparent Pricing",
|
||||||
description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
|
description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
|
||||||
}: PricingProps) {
|
}: PricingProps) {
|
||||||
const [isMonthly, setIsMonthly] = useState(true);
|
const [isMonthly, setIsMonthly] = useState(false);
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
const switchRef = useRef<HTMLButtonElement>(null);
|
const switchRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
|
@ -183,7 +184,7 @@ export function Pricing({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
{isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"}
|
{plan.billingText ?? (isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-5 gap-2 flex flex-col">
|
<ul className="mt-5 gap-2 flex flex-col">
|
||||||
|
|
|
||||||
|
|
@ -4,44 +4,47 @@ import { Pricing } from "@/components/pricing";
|
||||||
|
|
||||||
const demoPlans = [
|
const demoPlans = [
|
||||||
{
|
{
|
||||||
name: "COMMUNITY",
|
name: "FREE",
|
||||||
price: "0",
|
price: "0",
|
||||||
yearlyPrice: "0",
|
yearlyPrice: "0",
|
||||||
period: "forever",
|
period: "",
|
||||||
|
billingText: "Includes 30 day PRO trial",
|
||||||
features: [
|
features: [
|
||||||
"Community support",
|
"Open source on GitHub",
|
||||||
"Supports 100+ LLMs",
|
"Upload and chat with 300+ pages of content",
|
||||||
"Supports OpenAI spec and LiteLLM",
|
"Connects with 8 popular sources, like Drive and Notion.",
|
||||||
"Supports local vLLM or Ollama setups",
|
"Includes limited access to ChatGPT, Claude, and DeepSeek models",
|
||||||
"6000+ embedding models",
|
"Supports 100+ more LLMs, including Gemini, Llama and many more.",
|
||||||
"50+ File extensions supported.",
|
"50+ File extensions supported.",
|
||||||
"Podcasts support with local TTS providers.",
|
"Generate podcasts in seconds.",
|
||||||
"Connects with 15+ external sources, like Drive and Notion.",
|
|
||||||
"Cross-Browser Extension for dynamic webpages including authenticated content",
|
"Cross-Browser Extension for dynamic webpages including authenticated content",
|
||||||
"Role-based access control (RBAC)",
|
"Community support on Discord",
|
||||||
"Collaboration and team features",
|
|
||||||
],
|
],
|
||||||
description: "Open source version with powerful features",
|
description: "Powerful features with some limitations",
|
||||||
buttonText: "Dive In",
|
buttonText: "Get Started",
|
||||||
href: "/docs",
|
href: "/",
|
||||||
isPopular: false,
|
isPopular: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "CLOUD",
|
name: "PRO",
|
||||||
price: "0",
|
price: "10",
|
||||||
yearlyPrice: "0",
|
yearlyPrice: "10",
|
||||||
period: "in beta",
|
period: "user / month",
|
||||||
|
billingText: "billed annually",
|
||||||
features: [
|
features: [
|
||||||
"Everything in Community",
|
"Everything in Free",
|
||||||
"Email support",
|
"Upload and chat with 5,000+ pages of content",
|
||||||
"Get started in seconds",
|
"Connects with 15+ external sources, like Slack and Airtable.",
|
||||||
"Instant access to new features",
|
"Includes extended access to ChatGPT, Claude, and DeepSeek models",
|
||||||
"Easy access from anywhere",
|
"Collaboration and commenting features",
|
||||||
"Remote team management and collaboration",
|
"Shared BYOK (Bring Your Own Key)",
|
||||||
|
"Team and role management",
|
||||||
|
"Planned: Centralized billing",
|
||||||
|
"Priority support",
|
||||||
],
|
],
|
||||||
description: "Instant access for individuals and teams",
|
description: "The AIknowledge base for individuals and teams",
|
||||||
buttonText: "Get Started",
|
buttonText: "Upgrade",
|
||||||
href: "/",
|
href: "/contact",
|
||||||
isPopular: true,
|
isPopular: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -49,18 +52,21 @@ const demoPlans = [
|
||||||
price: "Contact Us",
|
price: "Contact Us",
|
||||||
yearlyPrice: "Contact Us",
|
yearlyPrice: "Contact Us",
|
||||||
period: "",
|
period: "",
|
||||||
|
billingText: "",
|
||||||
features: [
|
features: [
|
||||||
"Everything in Community",
|
"Everything in Pro",
|
||||||
"Priority support",
|
"Connect and chat with virtually unlimited pages of content",
|
||||||
|
"Limit models and/or providers",
|
||||||
|
"On-prem or VPC deployment",
|
||||||
|
"Planned: Audit logs and compliance",
|
||||||
|
"Planned: SSO, OIDC & SAML",
|
||||||
|
"Planned: Role-based access control (RBAC)",
|
||||||
"White-glove setup and deployment",
|
"White-glove setup and deployment",
|
||||||
"Monthly managed updates and maintenance",
|
"Monthly managed updates and maintenance",
|
||||||
"On-prem or VPC deployment",
|
"SLA commitments",
|
||||||
"Audit logs and compliance",
|
"Dedicated support",
|
||||||
"SSO, OIDC & SAML",
|
|
||||||
"SLA guarantee",
|
|
||||||
"Uptime guarantee on VPC",
|
|
||||||
],
|
],
|
||||||
description: "Professional, customized setup for large organizations",
|
description: "Customized setup for large organizations",
|
||||||
buttonText: "Contact Sales",
|
buttonText: "Contact Sales",
|
||||||
href: "/contact",
|
href: "/contact",
|
||||||
isPopular: false,
|
isPopular: false,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ export function PublicChatSnapshotRow({
|
||||||
{snapshot.message_count}
|
{snapshot.message_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={snapshot.public_url}
|
||||||
|
className="mt-2 w-full text-xs text-muted-foreground bg-muted/50 border rounded px-2 py-1 select-all focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,18 @@ export const LLM_MODELS: LLMModel[] = [
|
||||||
},
|
},
|
||||||
|
|
||||||
// Google (Gemini)
|
// Google (Gemini)
|
||||||
|
{
|
||||||
|
value: "gemini-3-flash-preview",
|
||||||
|
label: "Gemini 3 Flash",
|
||||||
|
provider: "GOOGLE",
|
||||||
|
contextWindow: "1M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gemini-3-pro-preview",
|
||||||
|
label: "Gemini 3 Pro",
|
||||||
|
provider: "GOOGLE",
|
||||||
|
contextWindow: "1M",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "gemini-2.5-flash",
|
value: "gemini-2.5-flash",
|
||||||
label: "Gemini 2.5 Flash",
|
label: "Gemini 2.5 Flash",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const inboxItemTypeEnum = z.enum([
|
||||||
"connector_deletion",
|
"connector_deletion",
|
||||||
"document_processing",
|
"document_processing",
|
||||||
"new_mention",
|
"new_mention",
|
||||||
|
"comment_reply",
|
||||||
"page_limit_exceeded",
|
"page_limit_exceeded",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -101,6 +102,19 @@ export const newMentionMetadata = z.object({
|
||||||
content_preview: z.string(),
|
content_preview: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const commentReplyMetadata = z.object({
|
||||||
|
reply_id: z.number(),
|
||||||
|
parent_comment_id: z.number(),
|
||||||
|
message_id: z.number(),
|
||||||
|
thread_id: z.number(),
|
||||||
|
thread_title: z.string(),
|
||||||
|
author_id: z.string(),
|
||||||
|
author_name: z.string(),
|
||||||
|
author_avatar_url: z.string().nullable().optional(),
|
||||||
|
author_email: z.string().optional(),
|
||||||
|
content_preview: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page limit exceeded metadata schema
|
* Page limit exceeded metadata schema
|
||||||
*/
|
*/
|
||||||
|
|
@ -125,6 +139,7 @@ export const inboxItemMetadata = z.union([
|
||||||
connectorDeletionMetadata,
|
connectorDeletionMetadata,
|
||||||
documentProcessingMetadata,
|
documentProcessingMetadata,
|
||||||
newMentionMetadata,
|
newMentionMetadata,
|
||||||
|
commentReplyMetadata,
|
||||||
pageLimitExceededMetadata,
|
pageLimitExceededMetadata,
|
||||||
baseInboxItemMetadata,
|
baseInboxItemMetadata,
|
||||||
]);
|
]);
|
||||||
|
|
@ -168,6 +183,11 @@ export const newMentionInboxItem = inboxItem.extend({
|
||||||
metadata: newMentionMetadata,
|
metadata: newMentionMetadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const commentReplyInboxItem = inboxItem.extend({
|
||||||
|
type: z.literal("comment_reply"),
|
||||||
|
metadata: commentReplyMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
export const pageLimitExceededInboxItem = inboxItem.extend({
|
export const pageLimitExceededInboxItem = inboxItem.extend({
|
||||||
type: z.literal("page_limit_exceeded"),
|
type: z.literal("page_limit_exceeded"),
|
||||||
metadata: pageLimitExceededMetadata,
|
metadata: pageLimitExceededMetadata,
|
||||||
|
|
@ -278,6 +298,10 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM
|
||||||
return newMentionMetadata.safeParse(metadata).success;
|
return newMentionMetadata.safeParse(metadata).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCommentReplyMetadata(metadata: unknown): metadata is CommentReplyMetadata {
|
||||||
|
return commentReplyMetadata.safeParse(metadata).success;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard for PageLimitExceededMetadata
|
* Type guard for PageLimitExceededMetadata
|
||||||
*/
|
*/
|
||||||
|
|
@ -298,6 +322,7 @@ export function parseInboxItemMetadata(
|
||||||
| ConnectorDeletionMetadata
|
| ConnectorDeletionMetadata
|
||||||
| DocumentProcessingMetadata
|
| DocumentProcessingMetadata
|
||||||
| NewMentionMetadata
|
| NewMentionMetadata
|
||||||
|
| CommentReplyMetadata
|
||||||
| PageLimitExceededMetadata
|
| PageLimitExceededMetadata
|
||||||
| null {
|
| null {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -317,6 +342,10 @@ export function parseInboxItemMetadata(
|
||||||
const result = newMentionMetadata.safeParse(metadata);
|
const result = newMentionMetadata.safeParse(metadata);
|
||||||
return result.success ? result.data : null;
|
return result.success ? result.data : null;
|
||||||
}
|
}
|
||||||
|
case "comment_reply": {
|
||||||
|
const result = commentReplyMetadata.safeParse(metadata);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
case "page_limit_exceeded": {
|
case "page_limit_exceeded": {
|
||||||
const result = pageLimitExceededMetadata.safeParse(metadata);
|
const result = pageLimitExceededMetadata.safeParse(metadata);
|
||||||
return result.success ? result.data : null;
|
return result.success ? result.data : null;
|
||||||
|
|
@ -338,6 +367,7 @@ export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata
|
||||||
export type ConnectorDeletionMetadata = z.infer<typeof connectorDeletionMetadata>;
|
export type ConnectorDeletionMetadata = z.infer<typeof connectorDeletionMetadata>;
|
||||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||||
|
export type CommentReplyMetadata = z.infer<typeof commentReplyMetadata>;
|
||||||
export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>;
|
export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>;
|
||||||
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
|
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
|
||||||
export type InboxItem = z.infer<typeof inboxItem>;
|
export type InboxItem = z.infer<typeof inboxItem>;
|
||||||
|
|
@ -345,6 +375,7 @@ export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxIt
|
||||||
export type ConnectorDeletionInboxItem = z.infer<typeof connectorDeletionInboxItem>;
|
export type ConnectorDeletionInboxItem = z.infer<typeof connectorDeletionInboxItem>;
|
||||||
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
|
||||||
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
|
||||||
|
export type CommentReplyInboxItem = z.infer<typeof commentReplyInboxItem>;
|
||||||
export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>;
|
export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>;
|
||||||
|
|
||||||
// API Request/Response types
|
// API Request/Response types
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||||
|
|
||||||
interface UseApiKeyReturn {
|
interface UseApiKeyReturn {
|
||||||
apiKey: string | null;
|
apiKey: string | null;
|
||||||
|
|
@ -33,60 +34,17 @@ export function useApiKey(): UseApiKeyReturn {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fallbackCopyTextToClipboard = (text: string) => {
|
|
||||||
const textArea = document.createElement("textarea");
|
|
||||||
textArea.value = text;
|
|
||||||
|
|
||||||
// Avoid scrolling to bottom
|
|
||||||
textArea.style.top = "0";
|
|
||||||
textArea.style.left = "0";
|
|
||||||
textArea.style.position = "fixed";
|
|
||||||
textArea.style.opacity = "0";
|
|
||||||
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.focus();
|
|
||||||
textArea.select();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const successful = document.execCommand("copy");
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
|
|
||||||
if (successful) {
|
|
||||||
setCopied(true);
|
|
||||||
toast.success("API key copied to clipboard");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
toast.error("Failed to copy API key");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Fallback: Oops, unable to copy", err);
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
toast.error("Failed to copy API key");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = useCallback(async () => {
|
const copyToClipboard = useCallback(async () => {
|
||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
|
|
||||||
try {
|
const success = await copyToClipboardUtil(apiKey);
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (success) {
|
||||||
// Use Clipboard API if available and in secure context
|
setCopied(true);
|
||||||
await navigator.clipboard.writeText(apiKey);
|
toast.success("API key copied to clipboard");
|
||||||
setCopied(true);
|
setTimeout(() => {
|
||||||
toast.success("API key copied to clipboard");
|
setCopied(false);
|
||||||
|
}, 2000);
|
||||||
setTimeout(() => {
|
} else {
|
||||||
setCopied(false);
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
// Fallback for non-secure contexts or browsers without clipboard API
|
|
||||||
fallbackCopyTextToClipboard(apiKey);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy:", err);
|
|
||||||
toast.error("Failed to copy API key");
|
toast.error("Failed to copy API key");
|
||||||
}
|
}
|
||||||
}, [apiKey]);
|
}, [apiKey]);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ZodType } from "zod";
|
import type { ZodType } from "zod";
|
||||||
import { getBearerToken, handleUnauthorized } from "../auth-utils";
|
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
|
||||||
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
|
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
|
||||||
|
|
||||||
enum ResponseType {
|
enum ResponseType {
|
||||||
|
|
@ -17,6 +17,7 @@ export type RequestOptions = {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
body?: any;
|
body?: any;
|
||||||
responseType?: ResponseType;
|
responseType?: ResponseType;
|
||||||
|
_isRetry?: boolean; // Internal flag to prevent infinite retry loops
|
||||||
// Add more options as needed
|
// Add more options as needed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -135,8 +136,23 @@ class BaseApiService {
|
||||||
throw new AppError("Failed to parse response", response.status, response.statusText);
|
throw new AppError("Failed to parse response", response.status, response.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 401 first before other error handling - ensures token is cleared and user redirected
|
// Handle 401 - try to refresh token first (only once)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
if (!options?._isRetry) {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
if (newToken) {
|
||||||
|
// Retry the request with the new token
|
||||||
|
return this.request(url, responseSchema, {
|
||||||
|
...mergedOptions,
|
||||||
|
headers: {
|
||||||
|
...mergedOptions.headers,
|
||||||
|
Authorization: `Bearer ${newToken}`,
|
||||||
|
},
|
||||||
|
_isRetry: true,
|
||||||
|
} as RequestOptions & { responseType?: R });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Refresh failed or retry failed, redirect to login
|
||||||
handleUnauthorized();
|
handleUnauthorized();
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
typeof data === "object" && "detail" in data
|
typeof data === "object" && "detail" in data
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@
|
||||||
|
|
||||||
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
|
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
|
||||||
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
|
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
|
||||||
|
const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
|
||||||
|
|
||||||
|
// Flag to prevent multiple simultaneous refresh attempts
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the current path and redirects to login page
|
* Saves the current path and redirects to login page
|
||||||
|
|
@ -21,8 +26,9 @@ export function handleUnauthorized(): void {
|
||||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the token
|
// Clear both tokens
|
||||||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
|
||||||
// Redirect to home page (which has login options)
|
// Redirect to home page (which has login options)
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
|
|
@ -66,6 +72,71 @@ export function clearBearerToken(): void {
|
||||||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the refresh token from localStorage
|
||||||
|
*/
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the refresh token in localStorage
|
||||||
|
*/
|
||||||
|
export function setRefreshToken(token: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the refresh token from localStorage
|
||||||
|
*/
|
||||||
|
export function clearRefreshToken(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all auth tokens from localStorage
|
||||||
|
*/
|
||||||
|
export function clearAllTokens(): void {
|
||||||
|
clearBearerToken();
|
||||||
|
clearRefreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout the current user by revoking the refresh token and clearing localStorage.
|
||||||
|
* Returns true if logout was successful (or tokens were cleared), false otherwise.
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<boolean> {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
|
||||||
|
// Call backend to revoke the refresh token
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
const response = await fetch(`${backendUrl}/auth/jwt/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn("Failed to revoke refresh token:", response.status, await response.text());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to revoke refresh token on server:", error);
|
||||||
|
// Continue to clear local tokens even if server call fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all tokens from localStorage
|
||||||
|
clearAllTokens();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the user is authenticated (has a token)
|
* Checks if the user is authenticated (has a token)
|
||||||
*/
|
*/
|
||||||
|
|
@ -106,14 +177,67 @@ export function getAuthHeaders(additionalHeaders?: Record<string, string>): Reco
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticated fetch wrapper that handles 401 responses uniformly
|
* Attempts to refresh the access token using the stored refresh token.
|
||||||
* Automatically redirects to login on 401 and saves the current path
|
* Returns the new access token if successful, null otherwise.
|
||||||
|
* Exported for use by API services.
|
||||||
|
*/
|
||||||
|
export async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
// If already refreshing, wait for that request to complete
|
||||||
|
if (isRefreshing && refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRefreshToken = getRefreshToken();
|
||||||
|
if (!currentRefreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
const response = await fetch(`${backendUrl}/auth/jwt/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refresh_token: currentRefreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Refresh failed, clear tokens
|
||||||
|
clearAllTokens();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.access_token && data.refresh_token) {
|
||||||
|
setBearerToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated fetch wrapper that handles 401 responses uniformly.
|
||||||
|
* On 401, attempts to refresh the token and retry the request.
|
||||||
|
* If refresh fails, redirects to login and saves the current path.
|
||||||
*/
|
*/
|
||||||
export async function authenticatedFetch(
|
export async function authenticatedFetch(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit & { skipAuthRedirect?: boolean }
|
options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { skipAuthRedirect = false, ...fetchOptions } = options || {};
|
const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {};
|
||||||
|
|
||||||
const headers = getAuthHeaders(fetchOptions.headers as Record<string, string>);
|
const headers = getAuthHeaders(fetchOptions.headers as Record<string, string>);
|
||||||
|
|
||||||
|
|
@ -124,6 +248,23 @@ export async function authenticatedFetch(
|
||||||
|
|
||||||
// Handle 401 Unauthorized
|
// Handle 401 Unauthorized
|
||||||
if (response.status === 401 && !skipAuthRedirect) {
|
if (response.status === 401 && !skipAuthRedirect) {
|
||||||
|
// Try to refresh the token (unless skipRefresh is set to prevent infinite loops)
|
||||||
|
if (!skipRefresh) {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
if (newToken) {
|
||||||
|
// Retry the original request with the new token
|
||||||
|
const retryHeaders = {
|
||||||
|
...(fetchOptions.headers as Record<string, string>),
|
||||||
|
Authorization: `Bearer ${newToken}`,
|
||||||
|
};
|
||||||
|
return fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: retryHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh failed or was skipped, redirect to login
|
||||||
handleUnauthorized();
|
handleUnauthorized();
|
||||||
throw new Error("Unauthorized: Redirecting to login page");
|
throw new Error("Unauthorized: Redirecting to login page");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
* as it may prevent the sed replacement from working correctly.
|
* as it may prevent the sed replacement from working correctly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import packageJson from "../package.json";
|
||||||
|
|
||||||
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth
|
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth
|
||||||
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
|
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
|
||||||
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
|
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
|
||||||
|
|
@ -28,6 +30,10 @@ export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
|
||||||
// Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__
|
// Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__
|
||||||
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted";
|
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted";
|
||||||
|
|
||||||
|
// App version - defaults to package.json version
|
||||||
|
// Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version
|
||||||
|
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version;
|
||||||
|
|
||||||
// Helper to check if local auth is enabled
|
// Helper to check if local auth is enabled
|
||||||
export const isLocalAuth = () => AUTH_TYPE === "LOCAL";
|
export const isLocalAuth = () => AUTH_TYPE === "LOCAL";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,44 @@ export const formatDate = (date: Date): string => {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard with fallback for older browsers and non-secure contexts.
|
||||||
|
* Returns true if successful, false otherwise.
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
// Use modern Clipboard API if available and in secure context
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Clipboard API failed:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-secure contexts or browsers without Clipboard API
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
|
||||||
|
// Avoid scrolling to bottom
|
||||||
|
textArea.style.top = "0";
|
||||||
|
textArea.style.left = "0";
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.opacity = "0";
|
||||||
|
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
return successful;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fallback copy failed:", err);
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,13 @@
|
||||||
"unarchive": "Restore",
|
"unarchive": "Restore",
|
||||||
"chat_archived": "Chat archived",
|
"chat_archived": "Chat archived",
|
||||||
"chat_unarchived": "Chat restored",
|
"chat_unarchived": "Chat restored",
|
||||||
|
"chat_renamed": "Chat renamed",
|
||||||
|
"error_renaming_chat": "Failed to rename chat",
|
||||||
|
"rename": "Rename",
|
||||||
|
"rename_chat": "Rename Chat",
|
||||||
|
"rename_chat_description": "Enter a new name for this conversation.",
|
||||||
|
"chat_title_placeholder": "Chat title",
|
||||||
|
"renaming": "Renaming...",
|
||||||
"no_archived_chats": "No archived chats",
|
"no_archived_chats": "No archived chats",
|
||||||
"error_archiving_chat": "Failed to archive chat",
|
"error_archiving_chat": "Failed to archive chat",
|
||||||
"new_chat": "New chat",
|
"new_chat": "New chat",
|
||||||
|
|
@ -692,15 +699,19 @@
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
"loggingOut": "Logging out...",
|
||||||
"inbox": "Inbox",
|
"inbox": "Inbox",
|
||||||
"search_inbox": "Search inbox",
|
"search_inbox": "Search inbox",
|
||||||
"mark_all_read": "Mark all as read",
|
"mark_all_read": "Mark all as read",
|
||||||
"mark_as_read": "Mark as read",
|
"mark_as_read": "Mark as read",
|
||||||
"mentions": "Mentions",
|
"mentions": "Mentions",
|
||||||
|
"comments": "Comments",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"no_results_found": "No results found",
|
"no_results_found": "No results found",
|
||||||
"no_mentions": "No mentions",
|
"no_mentions": "No mentions",
|
||||||
"no_mentions_hint": "You'll see mentions from others here",
|
"no_mentions_hint": "You'll see mentions from others here",
|
||||||
|
"no_comments": "No comments",
|
||||||
|
"no_comments_hint": "You'll see mentions and replies here",
|
||||||
"no_status_updates": "No status updates",
|
"no_status_updates": "No status updates",
|
||||||
"no_status_updates_hint": "Document and connector updates will appear here",
|
"no_status_updates_hint": "Document and connector updates will appear here",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
|
|
|
||||||
|
|
@ -660,6 +660,13 @@
|
||||||
"unarchive": "恢复",
|
"unarchive": "恢复",
|
||||||
"chat_archived": "对话已归档",
|
"chat_archived": "对话已归档",
|
||||||
"chat_unarchived": "对话已恢复",
|
"chat_unarchived": "对话已恢复",
|
||||||
|
"chat_renamed": "对话已重命名",
|
||||||
|
"error_renaming_chat": "重命名对话失败",
|
||||||
|
"rename": "重命名",
|
||||||
|
"rename_chat": "重命名对话",
|
||||||
|
"rename_chat_description": "为此对话输入新名称。",
|
||||||
|
"chat_title_placeholder": "对话标题",
|
||||||
|
"renaming": "重命名中...",
|
||||||
"no_archived_chats": "暂无已归档对话",
|
"no_archived_chats": "暂无已归档对话",
|
||||||
"error_archiving_chat": "归档对话失败",
|
"error_archiving_chat": "归档对话失败",
|
||||||
"new_chat": "新对话",
|
"new_chat": "新对话",
|
||||||
|
|
@ -677,15 +684,19 @@
|
||||||
"dark": "深色",
|
"dark": "深色",
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
|
"loggingOut": "正在退出...",
|
||||||
"inbox": "收件箱",
|
"inbox": "收件箱",
|
||||||
"search_inbox": "搜索收件箱",
|
"search_inbox": "搜索收件箱",
|
||||||
"mark_all_read": "全部标记为已读",
|
"mark_all_read": "全部标记为已读",
|
||||||
"mark_as_read": "标记为已读",
|
"mark_as_read": "标记为已读",
|
||||||
"mentions": "提及",
|
"mentions": "提及",
|
||||||
|
"comments": "评论",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"no_results_found": "未找到结果",
|
"no_results_found": "未找到结果",
|
||||||
"no_mentions": "没有提及",
|
"no_mentions": "没有提及",
|
||||||
"no_mentions_hint": "您会在这里看到他人的提及",
|
"no_mentions_hint": "您会在这里看到他人的提及",
|
||||||
|
"no_comments": "没有评论",
|
||||||
|
"no_comments_hint": "您会在这里看到提及和回复",
|
||||||
"no_status_updates": "没有状态更新",
|
"no_status_updates": "没有状态更新",
|
||||||
"no_status_updates_hint": "文档和连接器更新将显示在这里",
|
"no_status_updates_hint": "文档和连接器更新将显示在这里",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue