From 9646da833bc3d94564c10649b62a378d0190471e Mon Sep 17 00:00:00 2001 From: blackhao <13851610112@163.com> Date: Wed, 10 Dec 2025 20:12:21 -0600 Subject: user data --- backend/app/auth/__init__.py | 17 ++ backend/app/auth/models.py | 41 +++++ backend/app/auth/routes.py | 222 +++++++++++++++++++++++ backend/app/auth/utils.py | 73 ++++++++ backend/app/main.py | 33 +++- backend/data/users.db | Bin 0 -> 20480 bytes backend/requirements.txt | 6 +- frontend/src/App.tsx | 56 +++++- frontend/src/components/LeftSidebar.tsx | 43 ++++- frontend/src/components/Sidebar.tsx | 45 +++-- frontend/src/pages/AuthPage.tsx | 303 ++++++++++++++++++++++++++++++++ frontend/src/store/authStore.ts | 157 +++++++++++++++++ frontend/src/store/flowStore.ts | 42 ++++- 13 files changed, 1006 insertions(+), 32 deletions(-) create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/models.py create mode 100644 backend/app/auth/routes.py create mode 100644 backend/app/auth/utils.py create mode 100644 backend/data/users.db create mode 100644 frontend/src/pages/AuthPage.tsx create mode 100644 frontend/src/store/authStore.ts 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 Binary files /dev/null and b/backend/data/users.db 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(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 ( +
+
+ +

{initializing ? 'Loading workspace...' : 'Loading...'}

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + return ( ); } + +export default function App() { + return ; +} 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 = ({ isOpen, onToggle }) => { serializeBlueprint, clearBlueprint } = useFlowStore(); + const { user, logout } = useAuthStore(); const { setViewport, getViewport } = useReactFlow(); const isDark = theme === 'dark'; const fileInputRef = useRef(null); @@ -443,13 +445,38 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => {
-

Workspace

- +
+

Workspace

+ {user && ( + + + {user.username} + + )} +
+
+ {user && ( + + )} + +
{/* 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ isOpen, onToggle, onInteract }) => { {activeTab === 'settings' && (
{/* Attachments Section */} + {(() => { + const isGemini = selectedNode.data.model.startsWith('gemini'); + return (
+ {isGemini && ( +

+ File attachments are not supported for Gemini models. +

+ )} +
)}
+ ); + })()}
@@ -2562,7 +2583,8 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { )} - {/* File Attachment Buttons */} + {/* File Attachment Buttons - Hidden for Gemini */} + {!quickChatModel.startsWith('gemini') && (
+ )}
{/* 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 = ({ 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 ( +
+ {/* Background pattern */} +
+
+
+
+ +
+ {/* Logo/Brand */} +
+

+ ContextFlow +

+

+ {isLogin ? 'Welcome back!' : 'Create your account'} +

+
+ + {/* Card */} +
+ {/* Error Message */} + {displayError && ( +
+ +

{displayError}

+
+ )} + +
+ {/* Username */} +
+ +
+ + { + 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' && ( +
+ {usernameStatus === 'checking' && } + {usernameStatus === 'available' && } + {usernameStatus === 'taken' && } +
+ )} +
+ {!isLogin && usernameStatus === 'taken' && ( +

This username is already taken

+ )} +
+ + {/* Email (only for register) */} + {!isLogin && ( +
+ +
+ + { + 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' && ( +
+ {emailStatus === 'checking' && } + {emailStatus === 'available' && } + {emailStatus === 'taken' && } +
+ )} +
+ {emailStatus === 'taken' && ( +

This email is already registered

+ )} +
+ )} + + {/* Password */} +
+ +
+ + 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"} + /> +
+
+ + {/* Confirm Password (only for register) */} + {!isLogin && ( +
+ +
+ + 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" + /> +
+
+ )} + + {/* Submit Button */} + +
+ + {/* Switch Mode */} +
+

+ {isLogin ? "Don't have an account? " : "Already have an account? "} + +

+
+
+ + {/* Footer */} +

+ Visual LLM Conversation Graph Editor +

+
+
+ ); +}; + +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; + register: (username: string, email: string, password: string) => Promise; + logout: () => void; + checkAuth: () => Promise; + clearError: () => void; + getAuthHeader: () => { Authorization: string } | {}; +} + +export const useAuthStore = create()( + 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 => { + const authState = useAuthStore.getState(); + if (authState.token) { + return { Authorization: `Bearer ${authState.token}` }; + } + return {}; +}; const jsonFetch = async (url: string, options?: RequestInit): Promise => { - 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((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((set, get) => { readBlueprintFile: async (path: string): Promise => { 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( - `${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((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((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((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((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) { -- cgit v1.2.3