diff options
| author | blackhao <13851610112@163.com> | 2025-12-10 20:12:21 -0600 |
|---|---|---|
| committer | blackhao <13851610112@163.com> | 2025-12-10 20:12:21 -0600 |
| commit | 9646da833bc3d94564c10649b62a378d0190471e (patch) | |
| tree | d0c9c0584b8c4f167c281f5970f713b239a1d7c5 /frontend/src | |
| parent | 9ba956c7aa601f0e6cd0fe2ede907cbc558fa1b8 (diff) | |
user data
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.tsx | 56 | ||||
| -rw-r--r-- | frontend/src/components/LeftSidebar.tsx | 43 | ||||
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 45 | ||||
| -rw-r--r-- | frontend/src/pages/AuthPage.tsx | 303 | ||||
| -rw-r--r-- | frontend/src/store/authStore.ts | 157 | ||||
| -rw-r--r-- | frontend/src/store/flowStore.ts | 42 |
6 files changed, 616 insertions, 30 deletions
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) { |
