summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorblackhao <13851610112@163.com>2025-12-10 20:12:21 -0600
committerblackhao <13851610112@163.com>2025-12-10 20:12:21 -0600
commit9646da833bc3d94564c10649b62a378d0190471e (patch)
treed0c9c0584b8c4f167c281f5970f713b239a1d7c5
parent9ba956c7aa601f0e6cd0fe2ede907cbc558fa1b8 (diff)
user data
-rw-r--r--backend/app/auth/__init__.py17
-rw-r--r--backend/app/auth/models.py41
-rw-r--r--backend/app/auth/routes.py222
-rw-r--r--backend/app/auth/utils.py73
-rw-r--r--backend/app/main.py33
-rw-r--r--backend/data/users.dbbin0 -> 20480 bytes
-rw-r--r--backend/requirements.txt6
-rw-r--r--frontend/src/App.tsx56
-rw-r--r--frontend/src/components/LeftSidebar.tsx43
-rw-r--r--frontend/src/components/Sidebar.tsx45
-rw-r--r--frontend/src/pages/AuthPage.tsx303
-rw-r--r--frontend/src/store/authStore.ts157
-rw-r--r--frontend/src/store/flowStore.ts42
13 files changed, 1006 insertions, 32 deletions
diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py
new file mode 100644
index 0000000..8234b6f
--- /dev/null
+++ b/backend/app/auth/__init__.py
@@ -0,0 +1,17 @@
+from .routes import router as auth_router
+from .routes import get_current_user, get_current_user_optional
+from .models import User, get_db, init_db
+from .utils import Token, UserCreate, UserResponse
+
+__all__ = [
+ 'auth_router',
+ 'get_current_user',
+ 'get_current_user_optional',
+ 'User',
+ 'get_db',
+ 'init_db',
+ 'Token',
+ 'UserCreate',
+ 'UserResponse',
+]
+
diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py
new file mode 100644
index 0000000..76c33fa
--- /dev/null
+++ b/backend/app/auth/models.py
@@ -0,0 +1,41 @@
+import os
+from sqlalchemy import Column, Integer, String, DateTime, create_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+from datetime import datetime
+
+# Database configuration
+DATA_ROOT = os.path.abspath(os.getenv("DATA_ROOT", os.path.join(os.getcwd(), "data")))
+DATABASE_PATH = os.path.join(DATA_ROOT, "users.db")
+DATABASE_URL = f"sqlite:///{DATABASE_PATH}"
+
+engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+Base = declarative_base()
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ username = Column(String(50), unique=True, index=True, nullable=False)
+ email = Column(String(100), unique=True, index=True, nullable=False)
+ hashed_password = Column(String(255), nullable=False)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ is_active = Column(Integer, default=1)
+
+
+def init_db():
+ """Initialize database tables"""
+ os.makedirs(DATA_ROOT, exist_ok=True)
+ Base.metadata.create_all(bind=engine)
+
+
+def get_db():
+ """Dependency to get database session"""
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py
new file mode 100644
index 0000000..7f07c2a
--- /dev/null
+++ b/backend/app/auth/routes.py
@@ -0,0 +1,222 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
+from sqlalchemy.orm import Session
+from typing import Optional
+
+from .models import User, get_db
+from .utils import (
+ Token, UserCreate, UserLogin, UserResponse,
+ verify_password, get_password_hash, create_access_token, decode_token
+)
+
+router = APIRouter(prefix="/api/auth", tags=["Authentication"])
+
+# OAuth2 scheme for token extraction
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=True)
+oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
+
+
+async def get_current_user(
+ token: str = Depends(oauth2_scheme),
+ db: Session = Depends(get_db)
+) -> User:
+ """
+ Dependency: Validate JWT token and return current user.
+ Raises 401 if token is invalid or user not found.
+ """
+ username = decode_token(token)
+ if not username:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ user = db.query(User).filter(User.username == username).first()
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User not found",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ if not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="User account is disabled"
+ )
+
+ return user
+
+
+async def get_current_user_optional(
+ token: Optional[str] = Depends(oauth2_scheme_optional),
+ db: Session = Depends(get_db)
+) -> Optional[User]:
+ """
+ Dependency: Try to get current user, but don't fail if not authenticated.
+ Returns None if no valid token.
+ """
+ if not token:
+ return None
+
+ username = decode_token(token)
+ if not username:
+ return None
+
+ user = db.query(User).filter(User.username == username).first()
+ if not user or not user.is_active:
+ return None
+
+ return user
+
+
+@router.get("/check-username/{username}")
+async def check_username(username: str, db: Session = Depends(get_db)):
+ """
+ Check if a username is available.
+ """
+ existing = db.query(User).filter(User.username == username).first()
+ return {"available": existing is None}
+
+
+@router.get("/check-email/{email}")
+async def check_email(email: str, db: Session = Depends(get_db)):
+ """
+ Check if an email is available.
+ """
+ existing = db.query(User).filter(User.email == email).first()
+ return {"available": existing is None}
+
+
+@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+async def register(user_data: UserCreate, db: Session = Depends(get_db)):
+ """
+ Register a new user account.
+ """
+ # Check if username already exists
+ existing_user = db.query(User).filter(User.username == user_data.username).first()
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Username already registered"
+ )
+
+ # Check if email already exists
+ existing_email = db.query(User).filter(User.email == user_data.email).first()
+ if existing_email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email already registered"
+ )
+
+ # Validate password length
+ if len(user_data.password) < 6:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Password must be at least 6 characters"
+ )
+
+ # Create new user
+ user = User(
+ username=user_data.username,
+ email=user_data.email,
+ hashed_password=get_password_hash(user_data.password)
+ )
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+
+ return user
+
+
+@router.post("/login", response_model=Token)
+async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
+ """
+ Login with username and password, returns JWT token.
+ """
+ # Find user by username
+ user = db.query(User).filter(User.username == form_data.username).first()
+
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect username or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ if not verify_password(form_data.password, user.hashed_password):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect username or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ if not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="User account is disabled"
+ )
+
+ # Create access token
+ access_token = create_access_token(data={"sub": user.username})
+
+ return {
+ "access_token": access_token,
+ "token_type": "bearer",
+ "username": user.username
+ }
+
+
+@router.post("/login/json", response_model=Token)
+async def login_json(user_data: UserLogin, db: Session = Depends(get_db)):
+ """
+ Login with JSON body (alternative to form-data).
+ """
+ # Find user by username
+ user = db.query(User).filter(User.username == user_data.username).first()
+
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect username or password",
+ )
+
+ if not verify_password(user_data.password, user.hashed_password):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect username or password",
+ )
+
+ if not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="User account is disabled"
+ )
+
+ # Create access token
+ access_token = create_access_token(data={"sub": user.username})
+
+ return {
+ "access_token": access_token,
+ "token_type": "bearer",
+ "username": user.username
+ }
+
+
+@router.get("/me", response_model=UserResponse)
+async def get_me(current_user: User = Depends(get_current_user)):
+ """
+ Get current authenticated user's info.
+ """
+ return current_user
+
+
+@router.post("/logout")
+async def logout():
+ """
+ Logout endpoint (client should discard the token).
+ JWT tokens are stateless, so this is just for API completeness.
+ """
+ return {"message": "Successfully logged out"}
+
diff --git a/backend/app/auth/utils.py b/backend/app/auth/utils.py
new file mode 100644
index 0000000..5889279
--- /dev/null
+++ b/backend/app/auth/utils.py
@@ -0,0 +1,73 @@
+import os
+import bcrypt
+from datetime import datetime, timedelta
+from typing import Optional
+from jose import JWTError, jwt
+from pydantic import BaseModel, EmailStr
+
+# Configuration - use environment variables in production
+SECRET_KEY = os.getenv("JWT_SECRET_KEY", "contextflow-secret-key-change-in-production-2024")
+ALGORITHM = "HS256"
+ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", "1440")) # 24 hours default
+
+
+# Pydantic models for request/response
+class Token(BaseModel):
+ access_token: str
+ token_type: str
+ username: str
+
+
+class TokenData(BaseModel):
+ username: Optional[str] = None
+
+
+class UserCreate(BaseModel):
+ username: str
+ email: EmailStr
+ password: str
+
+
+class UserLogin(BaseModel):
+ username: str
+ password: str
+
+
+class UserResponse(BaseModel):
+ id: int
+ username: str
+ email: str
+ created_at: datetime
+ is_active: int
+
+ class Config:
+ from_attributes = True
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """Verify a password against its hash"""
+ return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
+
+
+def get_password_hash(password: str) -> str:
+ """Hash a password"""
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
+
+
+def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+ """Create a JWT access token"""
+ to_encode = data.copy()
+ expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
+ to_encode.update({"exp": expire})
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def decode_token(token: str) -> Optional[str]:
+ """Decode a JWT token and return the username"""
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ username: str = payload.get("sub")
+ return username
+ except JWTError:
+ return None
+
diff --git a/backend/app/main.py b/backend/app/main.py
index a5f16af..902d693 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,13 +1,15 @@
import asyncio
import tempfile
import time
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse
from fastapi import UploadFile, File, Form
from pydantic import BaseModel
from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort
from app.services.llm import llm_streamer, generate_title, get_openai_client
+from app.auth import auth_router, get_current_user, init_db, User, get_db
+from app.auth.utils import get_password_hash
from dotenv import load_dotenv
import os
import json
@@ -15,11 +17,15 @@ import shutil
from typing import List, Literal, Optional
from uuid import uuid4
from google import genai
+from sqlalchemy.orm import Session
load_dotenv()
app = FastAPI(title="ContextFlow Backend")
+# Include authentication router
+app.include_router(auth_router)
+
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -28,6 +34,31 @@ app.add_middleware(
allow_headers=["*"],
)
+# Initialize database on startup
+@app.on_event("startup")
+async def startup_event():
+ """Initialize database and create default test user if not exists"""
+ init_db()
+
+ # Create test user if not exists
+ from app.auth.models import SessionLocal
+ db = SessionLocal()
+ try:
+ existing = db.query(User).filter(User.username == "test").first()
+ if not existing:
+ test_user = User(
+ username="test",
+ email="test@contextflow.local",
+ hashed_password=get_password_hash("114514")
+ )
+ db.add(test_user)
+ db.commit()
+ print("[startup] Created default test user (test/114514)")
+ else:
+ print("[startup] Test user already exists")
+ finally:
+ db.close()
+
# --------- Project / Blueprint storage ---------
DATA_ROOT = os.path.abspath(os.getenv("DATA_ROOT", os.path.join(os.getcwd(), "data")))
DEFAULT_USER = "test"
diff --git a/backend/data/users.db b/backend/data/users.db
new file mode 100644
index 0000000..9630889
--- /dev/null
+++ b/backend/data/users.db
Binary files differ
diff --git a/backend/requirements.txt b/backend/requirements.txt
index e340864..a9607fd 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,9 +1,13 @@
fastapi
uvicorn
-pydantic
+pydantic[email]
openai
google-generativeai
python-dotenv
httpx
python-multipart
+# Authentication
+python-jose[cryptography]
+passlib[bcrypt]
+sqlalchemy
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5477ff2..cfbb141 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -12,12 +12,14 @@ import ReactFlow, {
} from 'reactflow';
import 'reactflow/dist/style.css';
import useFlowStore from './store/flowStore';
+import { useAuthStore } from './store/authStore';
import LLMNode from './components/nodes/LLMNode';
import MergedEdge from './components/edges/MergedEdge';
import Sidebar from './components/Sidebar';
import LeftSidebar from './components/LeftSidebar';
import { ContextMenu } from './components/ContextMenu';
-import { Plus, Sun, Moon, LayoutGrid } from 'lucide-react';
+import { Plus, Sun, Moon, LayoutGrid, Loader2 } from 'lucide-react';
+import AuthPage from './pages/AuthPage';
const nodeTypes = {
llmNode: LLMNode,
@@ -397,10 +399,60 @@ function Flow() {
);
}
-export default function App() {
+function AuthWrapper() {
+ const { isAuthenticated, checkAuth, user } = useAuthStore();
+ const { refreshProjectTree, refreshFiles, loadArchivedNodes, clearBlueprint } = useFlowStore();
+ const [checking, setChecking] = useState(true);
+ const [initializing, setInitializing] = useState(false);
+ const prevUserRef = useRef<string | null>(null);
+
+ useEffect(() => {
+ checkAuth().finally(() => setChecking(false));
+ }, [checkAuth]);
+
+ // When user changes (login/logout), refresh all user-specific data
+ useEffect(() => {
+ const currentUsername = user?.username || null;
+
+ // If user changed, reload everything
+ if (isAuthenticated && currentUsername && currentUsername !== prevUserRef.current) {
+ setInitializing(true);
+ prevUserRef.current = currentUsername;
+
+ // Clear old data and load new user's data
+ clearBlueprint();
+ Promise.all([
+ refreshProjectTree(),
+ refreshFiles(),
+ loadArchivedNodes()
+ ]).finally(() => setInitializing(false));
+ } else if (!isAuthenticated) {
+ prevUserRef.current = null;
+ }
+ }, [isAuthenticated, user, refreshProjectTree, refreshFiles, loadArchivedNodes, clearBlueprint]);
+
+ if (checking || initializing) {
+ return (
+ <div className="min-h-screen flex items-center justify-center bg-gray-900">
+ <div className="flex flex-col items-center gap-4">
+ <Loader2 className="animate-spin text-blue-500" size={48} />
+ <p className="text-gray-400">{initializing ? 'Loading workspace...' : 'Loading...'}</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (!isAuthenticated) {
+ return <AuthPage />;
+ }
+
return (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
);
}
+
+export default function App() {
+ return <AuthWrapper />;
+}
diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx
index aff2df8..1df63fe 100644
--- a/frontend/src/components/LeftSidebar.tsx
+++ b/frontend/src/components/LeftSidebar.tsx
@@ -2,9 +2,10 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { useReactFlow } from 'reactflow';
import {
Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare,
- MoreVertical, Download, Upload, Plus, RefreshCw, Edit3, Loader2
+ MoreVertical, Download, Upload, Plus, RefreshCw, Edit3, Loader2, LogOut, User
} from 'lucide-react';
import useFlowStore, { type FSItem, type BlueprintDocument, type FileMeta } from '../store/flowStore';
+import { useAuthStore } from '../store/authStore';
interface LeftSidebarProps {
isOpen: boolean;
@@ -39,6 +40,7 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
serializeBlueprint,
clearBlueprint
} = useFlowStore();
+ const { user, logout } = useAuthStore();
const { setViewport, getViewport } = useReactFlow();
const isDark = theme === 'dark';
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -443,13 +445,38 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
<div className={`p-3 border-b flex justify-between items-center ${
isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50'
}`}>
- <h2 className={`font-bold text-sm uppercase ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Workspace</h2>
- <button
- onClick={onToggle}
- className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
- >
- <ChevronLeft size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
- </button>
+ <div className="flex items-center gap-2">
+ <h2 className={`font-bold text-sm uppercase ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Workspace</h2>
+ {user && (
+ <span className={`text-xs px-2 py-0.5 rounded-full ${
+ isDark ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-600'
+ }`}>
+ <User size={10} className="inline mr-1" />
+ {user.username}
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-1">
+ {user && (
+ <button
+ onClick={logout}
+ className={`p-1 rounded transition-colors ${
+ isDark
+ ? 'hover:bg-red-900/50 text-gray-400 hover:text-red-400'
+ : 'hover:bg-red-50 text-gray-500 hover:text-red-500'
+ }`}
+ title="Logout"
+ >
+ <LogOut size={16} />
+ </button>
+ )}
+ <button
+ onClick={onToggle}
+ className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+ >
+ <ChevronLeft size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
+ </button>
+ </div>
</div>
{/* Tabs */}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index a8dd82e..17050aa 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useReactFlow } from 'reactflow';
import useFlowStore from '../store/flowStore';
+import { useAuthStore } from '../store/authStore';
import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, FileMeta } from '../store/flowStore';
import ReactMarkdown from 'react-markdown';
-import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link } from 'lucide-react';
+import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, LogOut } from 'lucide-react';
interface SidebarProps {
isOpen: boolean;
@@ -19,6 +20,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath,
saveCurrentBlueprint
} = useFlowStore();
+ const { getAuthHeader, user, logout } = useAuthStore();
const { setCenter, getViewport } = useReactFlow();
const isDark = theme === 'dark';
const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact');
@@ -295,7 +297,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
try {
const response = await fetch('http://localhost:8000/api/run_node_stream', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({
node_id: runningNodeId,
incoming_contexts: [{ messages: context }],
@@ -386,7 +388,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
try {
const res = await fetch('http://localhost:8000/api/summarize', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({
content: selectedNode.data.response,
model: summaryModel
@@ -412,7 +414,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
try {
const res = await fetch('http://localhost:8000/api/generate_title', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({ user_prompt: userPrompt, response })
});
@@ -489,7 +491,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
const res = await fetch('http://localhost:8000/api/summarize', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({
content,
model_name: 'gpt-5-nano',
@@ -1003,7 +1005,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
// Call LLM API with current messages as context
const response = await fetch('http://localhost:8000/api/run_node_stream', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({
node_id: 'quick_chat_temp',
incoming_contexts: [{ messages: messagesBeforeSend }],
@@ -1835,17 +1837,30 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
{activeTab === 'settings' && (
<div className="space-y-4">
{/* Attachments Section */}
+ {(() => {
+ const isGemini = selectedNode.data.model.startsWith('gemini');
+ return (
<div className={`p-3 rounded border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
Attached Files
</label>
+ {isGemini && (
+ <p className={`text-xs mb-2 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`}>
+ File attachments are not supported for Gemini models.
+ </p>
+ )}
+
<div className="flex gap-2 mb-3">
<button
onClick={() => settingsUploadRef.current?.click()}
+ disabled={isGemini}
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium transition-colors ${
- isDark ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white'
+ isGemini
+ ? 'opacity-50 cursor-not-allowed bg-gray-400 text-gray-200'
+ : isDark ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
+ title={isGemini ? 'Not supported for Gemini models' : 'Upload & Attach'}
>
<Upload size={14} />
Upload & Attach
@@ -1862,11 +1877,15 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
refreshFiles();
setShowAttachModal(true);
}}
+ disabled={isGemini}
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium border transition-colors ${
- isDark
- ? 'border-gray-600 hover:bg-gray-700 text-gray-200'
- : 'border-gray-300 hover:bg-gray-100 text-gray-700'
+ isGemini
+ ? 'opacity-50 cursor-not-allowed border-gray-400 text-gray-400'
+ : isDark
+ ? 'border-gray-600 hover:bg-gray-700 text-gray-200'
+ : 'border-gray-300 hover:bg-gray-100 text-gray-700'
}`}
+ title={isGemini ? 'Not supported for Gemini models' : 'Attach Existing File'}
>
<Link size={14} />
Attach Existing
@@ -1908,6 +1927,8 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
</div>
)}
</div>
+ );
+ })()}
<div>
<label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Merge Strategy</label>
@@ -2562,7 +2583,8 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
</label>
)}
- {/* File Attachment Buttons */}
+ {/* File Attachment Buttons - Hidden for Gemini */}
+ {!quickChatModel.startsWith('gemini') && (
<div className="flex items-center gap-1 ml-auto">
<button
onClick={() => quickChatUploadRef.current?.click()}
@@ -2603,6 +2625,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
onChange={handleQuickChatUpload}
/>
</div>
+ )}
</div>
{/* Input Area */}
diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx
new file mode 100644
index 0000000..d213190
--- /dev/null
+++ b/frontend/src/pages/AuthPage.tsx
@@ -0,0 +1,303 @@
+import React, { useState, useCallback } from 'react';
+import { useAuthStore } from '../store/authStore';
+import { Loader2, User, Mail, Lock, AlertCircle, CheckCircle, XCircle } from 'lucide-react';
+
+const API_BASE = 'http://localhost:8000';
+
+interface AuthPageProps {
+ onSuccess?: () => void;
+}
+
+const AuthPage: React.FC<AuthPageProps> = ({ onSuccess }) => {
+ const [isLogin, setIsLogin] = useState(true);
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [localError, setLocalError] = useState('');
+
+ // Real-time availability checks
+ const [usernameStatus, setUsernameStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
+ const [emailStatus, setEmailStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
+
+ const { login, register, isLoading, error, clearError } = useAuthStore();
+
+ // Check username availability
+ const checkUsername = useCallback(async (value: string) => {
+ if (!value.trim() || value.length < 2) {
+ setUsernameStatus('idle');
+ return;
+ }
+ setUsernameStatus('checking');
+ try {
+ const res = await fetch(`${API_BASE}/api/auth/check-username/${encodeURIComponent(value)}`);
+ const data = await res.json();
+ setUsernameStatus(data.available ? 'available' : 'taken');
+ } catch {
+ setUsernameStatus('idle');
+ }
+ }, []);
+
+ // Check email availability
+ const checkEmail = useCallback(async (value: string) => {
+ if (!value.trim() || !value.includes('@')) {
+ setEmailStatus('idle');
+ return;
+ }
+ setEmailStatus('checking');
+ try {
+ const res = await fetch(`${API_BASE}/api/auth/check-email/${encodeURIComponent(value)}`);
+ const data = await res.json();
+ setEmailStatus(data.available ? 'available' : 'taken');
+ } catch {
+ setEmailStatus('idle');
+ }
+ }, []);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLocalError('');
+ clearError();
+
+ // Validation
+ if (!username.trim()) {
+ setLocalError('Username is required');
+ return;
+ }
+ if (!password) {
+ setLocalError('Password is required');
+ return;
+ }
+
+ if (!isLogin) {
+ if (!email.trim()) {
+ setLocalError('Email is required');
+ return;
+ }
+ if (password !== confirmPassword) {
+ setLocalError('Passwords do not match');
+ return;
+ }
+ if (password.length < 6) {
+ setLocalError('Password must be at least 6 characters');
+ return;
+ }
+ if (usernameStatus === 'taken') {
+ setLocalError('Username is already taken');
+ return;
+ }
+ if (emailStatus === 'taken') {
+ setLocalError('Email is already registered');
+ return;
+ }
+ }
+
+ try {
+ if (isLogin) {
+ await login(username, password);
+ onSuccess?.();
+ } else {
+ await register(username, email, password);
+ // Auto-login after successful registration
+ await login(username, password);
+ onSuccess?.();
+ }
+ } catch (err) {
+ // Error is handled by the store
+ }
+ };
+
+ const switchMode = () => {
+ setIsLogin(!isLogin);
+ setLocalError('');
+ clearError();
+ setPassword('');
+ setConfirmPassword('');
+ setUsernameStatus('idle');
+ setEmailStatus('idle');
+ };
+
+ const displayError = localError || error;
+
+ return (
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
+ {/* Background pattern */}
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-1/2 -left-1/2 w-full h-full bg-gradient-to-br from-blue-500/10 to-transparent rounded-full blur-3xl" />
+ <div className="absolute -bottom-1/2 -right-1/2 w-full h-full bg-gradient-to-tl from-purple-500/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative z-10 w-full max-w-md px-6">
+ {/* Logo/Brand */}
+ <div className="text-center mb-8">
+ <h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
+ ContextFlow
+ </h1>
+ <p className="text-gray-400 mt-2">
+ {isLogin ? 'Welcome back!' : 'Create your account'}
+ </p>
+ </div>
+
+ {/* Card */}
+ <div className="bg-gray-800/50 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-2xl p-8">
+ {/* Error Message */}
+ {displayError && (
+ <div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-3">
+ <AlertCircle className="text-red-400 flex-shrink-0" size={20} />
+ <p className="text-red-400 text-sm">{displayError}</p>
+ </div>
+ )}
+
+ <form onSubmit={handleSubmit} className="space-y-5">
+ {/* Username */}
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">
+ Username
+ </label>
+ <div className="relative">
+ <User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
+ <input
+ type="text"
+ value={username}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ if (!isLogin) setUsernameStatus('idle');
+ }}
+ onBlur={() => !isLogin && checkUsername(username)}
+ placeholder="Enter your username"
+ className={`w-full pl-10 pr-10 py-3 bg-gray-900/50 border rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all ${
+ !isLogin && usernameStatus === 'taken' ? 'border-red-500' :
+ !isLogin && usernameStatus === 'available' ? 'border-green-500' : 'border-gray-700'
+ }`}
+ autoComplete="username"
+ />
+ {!isLogin && usernameStatus !== 'idle' && (
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
+ {usernameStatus === 'checking' && <Loader2 className="animate-spin text-gray-400" size={18} />}
+ {usernameStatus === 'available' && <CheckCircle className="text-green-500" size={18} />}
+ {usernameStatus === 'taken' && <XCircle className="text-red-500" size={18} />}
+ </div>
+ )}
+ </div>
+ {!isLogin && usernameStatus === 'taken' && (
+ <p className="text-red-400 text-xs mt-1">This username is already taken</p>
+ )}
+ </div>
+
+ {/* Email (only for register) */}
+ {!isLogin && (
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">
+ Email
+ </label>
+ <div className="relative">
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
+ <input
+ type="email"
+ value={email}
+ onChange={(e) => {
+ setEmail(e.target.value);
+ setEmailStatus('idle');
+ }}
+ onBlur={() => checkEmail(email)}
+ placeholder="Enter your email"
+ className={`w-full pl-10 pr-10 py-3 bg-gray-900/50 border rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all ${
+ emailStatus === 'taken' ? 'border-red-500' :
+ emailStatus === 'available' ? 'border-green-500' : 'border-gray-700'
+ }`}
+ autoComplete="email"
+ />
+ {emailStatus !== 'idle' && (
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
+ {emailStatus === 'checking' && <Loader2 className="animate-spin text-gray-400" size={18} />}
+ {emailStatus === 'available' && <CheckCircle className="text-green-500" size={18} />}
+ {emailStatus === 'taken' && <XCircle className="text-red-500" size={18} />}
+ </div>
+ )}
+ </div>
+ {emailStatus === 'taken' && (
+ <p className="text-red-400 text-xs mt-1">This email is already registered</p>
+ )}
+ </div>
+ )}
+
+ {/* Password */}
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">
+ Password
+ </label>
+ <div className="relative">
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
+ <input
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ placeholder="Enter your password"
+ className="w-full pl-10 pr-4 py-3 bg-gray-900/50 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
+ autoComplete={isLogin ? "current-password" : "new-password"}
+ />
+ </div>
+ </div>
+
+ {/* Confirm Password (only for register) */}
+ {!isLogin && (
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">
+ Confirm Password
+ </label>
+ <div className="relative">
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
+ <input
+ type="password"
+ value={confirmPassword}
+ onChange={(e) => setConfirmPassword(e.target.value)}
+ placeholder="Confirm your password"
+ className="w-full pl-10 pr-4 py-3 bg-gray-900/50 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
+ autoComplete="new-password"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* Submit Button */}
+ <button
+ type="submit"
+ disabled={isLoading}
+ className="w-full py-3 px-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-medium rounded-lg transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-500/25"
+ >
+ {isLoading ? (
+ <>
+ <Loader2 className="animate-spin" size={20} />
+ <span>{isLogin ? 'Signing in...' : 'Creating account...'}</span>
+ </>
+ ) : (
+ <span>{isLogin ? 'Sign In' : 'Create Account'}</span>
+ )}
+ </button>
+ </form>
+
+ {/* Switch Mode */}
+ <div className="mt-6 text-center">
+ <p className="text-gray-400">
+ {isLogin ? "Don't have an account? " : "Already have an account? "}
+ <button
+ onClick={switchMode}
+ className="text-blue-400 hover:text-blue-300 font-medium transition-colors"
+ >
+ {isLogin ? 'Sign up' : 'Sign in'}
+ </button>
+ </p>
+ </div>
+ </div>
+
+ {/* Footer */}
+ <p className="text-center text-gray-500 text-sm mt-6">
+ Visual LLM Conversation Graph Editor
+ </p>
+ </div>
+ </div>
+ );
+};
+
+export default AuthPage;
+
diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts
new file mode 100644
index 0000000..652256c
--- /dev/null
+++ b/frontend/src/store/authStore.ts
@@ -0,0 +1,157 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+const API_BASE = 'http://localhost:8000';
+
+interface UserInfo {
+ id: number;
+ username: string;
+ email: string;
+ created_at: string;
+}
+
+interface AuthState {
+ token: string | null;
+ user: UserInfo | null;
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ error: string | null;
+
+ // Actions
+ login: (username: string, password: string) => Promise<void>;
+ register: (username: string, email: string, password: string) => Promise<void>;
+ logout: () => void;
+ checkAuth: () => Promise<boolean>;
+ clearError: () => void;
+ getAuthHeader: () => { Authorization: string } | {};
+}
+
+export const useAuthStore = create<AuthState>()(
+ persist(
+ (set, get) => ({
+ token: null,
+ user: null,
+ isAuthenticated: false,
+ isLoading: false,
+ error: null,
+
+ login: async (username: string, password: string) => {
+ set({ isLoading: true, error: null });
+
+ try {
+ // Use JSON login endpoint
+ const res = await fetch(`${API_BASE}/api/auth/login/json`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => ({}));
+ throw new Error(errorData.detail || 'Login failed');
+ }
+
+ const data = await res.json();
+ set({
+ token: data.access_token,
+ isAuthenticated: true,
+ isLoading: false
+ });
+
+ // Fetch user info
+ await get().checkAuth();
+ } catch (err) {
+ set({
+ isLoading: false,
+ error: (err as Error).message,
+ isAuthenticated: false,
+ token: null,
+ user: null
+ });
+ throw err;
+ }
+ },
+
+ register: async (username: string, email: string, password: string) => {
+ set({ isLoading: true, error: null });
+
+ try {
+ const res = await fetch(`${API_BASE}/api/auth/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, email, password }),
+ });
+
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => ({}));
+ throw new Error(errorData.detail || 'Registration failed');
+ }
+
+ set({ isLoading: false });
+ } catch (err) {
+ set({ isLoading: false, error: (err as Error).message });
+ throw err;
+ }
+ },
+
+ logout: () => {
+ set({
+ token: null,
+ user: null,
+ isAuthenticated: false,
+ error: null
+ });
+ },
+
+ checkAuth: async () => {
+ const token = get().token;
+ if (!token) {
+ set({ isAuthenticated: false, user: null });
+ return false;
+ }
+
+ try {
+ const res = await fetch(`${API_BASE}/api/auth/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ if (res.ok) {
+ const user = await res.json();
+ set({ user, isAuthenticated: true });
+ return true;
+ } else {
+ // Token is invalid
+ set({ token: null, user: null, isAuthenticated: false });
+ return false;
+ }
+ } catch {
+ set({ token: null, user: null, isAuthenticated: false });
+ return false;
+ }
+ },
+
+ clearError: () => {
+ set({ error: null });
+ },
+
+ getAuthHeader: () => {
+ const token = get().token;
+ if (token) {
+ return { Authorization: `Bearer ${token}` };
+ }
+ return {};
+ },
+ }),
+ {
+ name: 'contextflow-auth',
+ partialize: (state) => ({
+ token: state.token,
+ user: state.user,
+ isAuthenticated: state.isAuthenticated
+ }),
+ }
+ )
+);
+
+export default useAuthStore;
+
diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts
index 498937e..de23c95 100644
--- a/frontend/src/store/flowStore.ts
+++ b/frontend/src/store/flowStore.ts
@@ -250,11 +250,35 @@ const getStableColor = (str: string) => {
return `hsl(${hue}, 70%, 60%)`;
};
+import { useAuthStore } from './authStore';
+
const API_BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
-const DEFAULT_USER = 'test';
+const DEFAULT_USER = 'test'; // Fallback for unauthenticated requests
+
+// Get current username directly from authStore
+const getCurrentUser = () => {
+ const authState = useAuthStore.getState();
+ return authState.user?.username || DEFAULT_USER;
+};
+
+// Get auth headers directly from authStore
+const getAuthHeaders = (): Record<string, string> => {
+ const authState = useAuthStore.getState();
+ if (authState.token) {
+ return { Authorization: `Bearer ${authState.token}` };
+ }
+ return {};
+};
const jsonFetch = async <T>(url: string, options?: RequestInit): Promise<T> => {
- const res = await fetch(url, options);
+ const authHeaders = getAuthHeaders();
+ const res = await fetch(url, {
+ ...options,
+ headers: {
+ ...options?.headers,
+ ...authHeaders,
+ },
+ });
if (!res.ok) {
const detail = await res.text();
throw new Error(detail || `Request failed: ${res.status}`);
@@ -1474,7 +1498,7 @@ const useFlowStore = create<FlowState>((set, get) => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- user: DEFAULT_USER,
+ user: getCurrentUser(),
path,
content: payload,
}),
@@ -1485,14 +1509,14 @@ const useFlowStore = create<FlowState>((set, get) => {
readBlueprintFile: async (path: string): Promise<BlueprintDocument> => {
const res = await jsonFetch<{ content: BlueprintDocument }>(
- `${API_BASE}/api/projects/file?user=${encodeURIComponent(DEFAULT_USER)}&path=${encodeURIComponent(path)}`
+ `${API_BASE}/api/projects/file?user=${encodeURIComponent(getCurrentUser())}&path=${encodeURIComponent(path)}`
);
return validateBlueprint(res.content);
},
refreshProjectTree: async () => {
const tree = await jsonFetch<FSItem[]>(
- `${API_BASE}/api/projects/tree?user=${encodeURIComponent(DEFAULT_USER)}`
+ `${API_BASE}/api/projects/tree?user=${encodeURIComponent(getCurrentUser())}`
);
set({ projectTree: tree });
return tree;
@@ -1557,7 +1581,7 @@ const useFlowStore = create<FlowState>((set, get) => {
loadArchivedNodes: async () => {
const res = await jsonFetch<{ archived: ArchivedNode[] }>(
- `${API_BASE}/api/projects/archived?user=${encodeURIComponent(DEFAULT_USER)}`
+ `${API_BASE}/api/projects/archived?user=${encodeURIComponent(getCurrentUser())}`
);
set({ archivedNodes: res.archived || [] });
},
@@ -1574,7 +1598,7 @@ const useFlowStore = create<FlowState>((set, get) => {
// Files management
refreshFiles: async () => {
const res = await jsonFetch<{ files: FileMeta[] }>(
- `${API_BASE}/api/files?user=${encodeURIComponent(DEFAULT_USER)}`
+ `${API_BASE}/api/files?user=${encodeURIComponent(getCurrentUser())}`
);
set({ files: res.files || [] });
},
@@ -1592,7 +1616,7 @@ const useFlowStore = create<FlowState>((set, get) => {
form.append('purpose', purpose);
}
try {
- const res = await fetch(`${API_BASE}/api/files/upload?user=${encodeURIComponent(DEFAULT_USER)}`, {
+ const res = await fetch(`${API_BASE}/api/files/upload?user=${encodeURIComponent(getCurrentUser())}`, {
method: 'POST',
body: form,
});
@@ -1611,7 +1635,7 @@ const useFlowStore = create<FlowState>((set, get) => {
},
deleteFile: async (fileId: string) => {
- const res = await fetch(`${API_BASE}/api/files/delete?user=${encodeURIComponent(DEFAULT_USER)}&file_id=${encodeURIComponent(fileId)}`, {
+ const res = await fetch(`${API_BASE}/api/files/delete?user=${encodeURIComponent(getCurrentUser())}&file_id=${encodeURIComponent(fileId)}`, {
method: 'POST',
});
if (!res.ok) {