summaryrefslogtreecommitdiff
path: root/frontend
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 /frontend
parent9ba956c7aa601f0e6cd0fe2ede907cbc558fa1b8 (diff)
user data
Diffstat (limited to 'frontend')
-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
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) {