diff options
| -rw-r--r-- | backend/app/auth/models.py | 5 | ||||
| -rw-r--r-- | backend/app/auth/routes.py | 47 | ||||
| -rw-r--r-- | frontend/index.html | 4 | ||||
| -rw-r--r-- | frontend/public/webicon.png | bin | 0 -> 77075 bytes | |||
| -rw-r--r-- | frontend/src/components/LeftSidebar.tsx | 193 |
5 files changed, 240 insertions, 9 deletions
diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py index 76c33fa..8477ba2 100644 --- a/backend/app/auth/models.py +++ b/backend/app/auth/models.py @@ -1,5 +1,5 @@ import os -from sqlalchemy import Column, Integer, String, DateTime, create_engine +from sqlalchemy import Column, Integer, String, DateTime, Text, create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime @@ -23,6 +23,9 @@ class User(Base): hashed_password = Column(String(255), nullable=False) created_at = Column(DateTime, default=datetime.utcnow) is_active = Column(Integer, default=1) + # API Keys (stored encrypted in production, plain for simplicity here) + openai_api_key = Column(Text, nullable=True) + gemini_api_key = Column(Text, nullable=True) def init_db(): diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index 7f07c2a..3c906b5 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.orm import Session from typing import Optional +from pydantic import BaseModel from .models import User, get_db from .utils import ( @@ -212,6 +213,52 @@ async def get_me(current_user: User = Depends(get_current_user)): return current_user +@router.get("/api-keys") +async def get_api_keys(current_user: User = Depends(get_current_user)): + """ + Get current user's API keys (masked for security). + """ + def mask_key(key: str | None) -> str: + if not key: + return "" + if len(key) <= 8: + return "*" * len(key) + return key[:4] + "*" * (len(key) - 8) + key[-4:] + + return { + "openai_api_key": mask_key(current_user.openai_api_key), + "gemini_api_key": mask_key(current_user.gemini_api_key), + "has_openai_key": bool(current_user.openai_api_key), + "has_gemini_key": bool(current_user.gemini_api_key), + } + + +class ApiKeysUpdate(BaseModel): + openai_api_key: Optional[str] = None + gemini_api_key: Optional[str] = None + + +@router.post("/api-keys") +async def update_api_keys( + keys: ApiKeysUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Update current user's API keys. + Pass empty string to clear a key, or omit to keep unchanged. + """ + if keys.openai_api_key is not None: + current_user.openai_api_key = keys.openai_api_key if keys.openai_api_key else None + + if keys.gemini_api_key is not None: + current_user.gemini_api_key = keys.gemini_api_key if keys.gemini_api_key else None + + db.commit() + + return {"message": "API keys updated successfully"} + + @router.post("/logout") async def logout(): """ diff --git a/frontend/index.html b/frontend/index.html index 072a57e..a91e052 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ <html lang="en"> <head> <meta charset="UTF-8" /> - <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <link rel="icon" type="image/png" href="/webicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>frontend</title> + <title>ContextFlow</title> </head> <body> <div id="root"></div> diff --git a/frontend/public/webicon.png b/frontend/public/webicon.png Binary files differnew file mode 100644 index 0000000..0fc5a9a --- /dev/null +++ b/frontend/public/webicon.png diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx index 1a111bf..d929dcc 100644 --- a/frontend/src/components/LeftSidebar.tsx +++ b/frontend/src/components/LeftSidebar.tsx @@ -2,7 +2,7 @@ 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, LogOut, User + MoreVertical, Download, Upload, Plus, RefreshCw, Edit3, Loader2, LogOut, User, Settings, Key, X, Eye, EyeOff } from 'lucide-react'; import useFlowStore, { type FSItem, type BlueprintDocument, type FileMeta } from '../store/flowStore'; import { useAuthStore } from '../store/authStore'; @@ -54,6 +54,60 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { const [openaiPurpose, setOpenaiPurpose] = useState<string>('user_data'); const [fileSearch, setFileSearch] = useState(''); + // User Settings Modal State + const [showUserSettings, setShowUserSettings] = useState(false); + const [openaiApiKey, setOpenaiApiKey] = useState(''); + const [geminiApiKey, setGeminiApiKey] = useState(''); + const [showOpenaiKey, setShowOpenaiKey] = useState(false); + const [showGeminiKey, setShowGeminiKey] = useState(false); + const [savingKeys, setSavingKeys] = useState(false); + const [keysMessage, setKeysMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const { getAuthHeader } = useAuthStore(); + + // Load API keys when settings modal opens + useEffect(() => { + if (showUserSettings) { + fetch('/api/auth/api-keys', { + headers: { ...getAuthHeader() }, + }) + .then(res => res.json()) + .then(data => { + setOpenaiApiKey(data.openai_api_key || ''); + setGeminiApiKey(data.gemini_api_key || ''); + }) + .catch(() => {}); + } + }, [showUserSettings, getAuthHeader]); + + const handleSaveApiKeys = async () => { + setSavingKeys(true); + setKeysMessage(null); + try { + const res = await fetch('/api/auth/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ + openai_api_key: openaiApiKey.includes('*') ? undefined : openaiApiKey, + gemini_api_key: geminiApiKey.includes('*') ? undefined : geminiApiKey, + }), + }); + if (res.ok) { + setKeysMessage({ type: 'success', text: 'API keys saved successfully!' }); + // Reload masked keys + const data = await fetch('/api/auth/api-keys', { + headers: { ...getAuthHeader() }, + }).then(r => r.json()); + setOpenaiApiKey(data.openai_api_key || ''); + setGeminiApiKey(data.gemini_api_key || ''); + } else { + setKeysMessage({ type: 'error', text: 'Failed to save API keys' }); + } + } catch { + setKeysMessage({ type: 'error', text: 'Network error' }); + } + setSavingKeys(false); + }; + const handleDragStart = (e: React.DragEvent, archiveId: string) => { e.dataTransfer.setData('archiveId', archiveId); e.dataTransfer.effectAllowed = 'copy'; @@ -459,15 +513,15 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { <div className="flex items-center gap-1"> {user && ( <button - onClick={logout} + onClick={() => setShowUserSettings(true)} 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' + ? 'hover:bg-gray-700 text-gray-400 hover:text-gray-200' + : 'hover:bg-gray-200 text-gray-500 hover:text-gray-700' }`} - title="Logout" + title="User Settings" > - <LogOut size={16} /> + <Settings size={16} /> </button> )} <button @@ -479,6 +533,133 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { </div> </div> + {/* User Settings Modal */} + {showUserSettings && ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowUserSettings(false)}> + <div + className={`w-full max-w-md mx-4 rounded-xl shadow-2xl ${isDark ? 'bg-gray-800' : 'bg-white'}`} + onClick={e => e.stopPropagation()} + > + {/* Header */} + <div className={`flex items-center justify-between p-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>User Settings</h2> + <button + onClick={() => setShowUserSettings(false)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`} + > + <X size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> + </button> + </div> + + {/* Content */} + <div className="p-4 space-y-4"> + {/* User Info */} + <div className={`flex items-center gap-3 p-3 rounded-lg ${isDark ? 'bg-gray-700/50' : 'bg-gray-100'}`}> + <div className={`w-10 h-10 rounded-full flex items-center justify-center ${isDark ? 'bg-blue-600' : 'bg-blue-500'}`}> + <User size={20} className="text-white" /> + </div> + <div> + <p className={`font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{user?.username}</p> + <p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>{user?.email}</p> + </div> + </div> + + {/* API Keys Section */} + <div className="space-y-3"> + <h3 className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> + <Key size={16} /> API Keys + </h3> + + {/* OpenAI API Key */} + <div> + <label className={`block text-xs mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> + OpenAI API Key + </label> + <div className="relative"> + <input + type={showOpenaiKey ? 'text' : 'password'} + value={openaiApiKey} + onChange={e => setOpenaiApiKey(e.target.value)} + placeholder="sk-..." + className={`w-full px-3 py-2 pr-10 rounded-lg text-sm ${ + isDark + ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400' + } border focus:outline-none focus:ring-2 focus:ring-blue-500`} + /> + <button + type="button" + onClick={() => setShowOpenaiKey(!showOpenaiKey)} + className={`absolute right-2 top-1/2 -translate-y-1/2 p-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} + > + {showOpenaiKey ? <EyeOff size={16} /> : <Eye size={16} />} + </button> + </div> + </div> + + {/* Gemini API Key */} + <div> + <label className={`block text-xs mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> + Gemini API Key + </label> + <div className="relative"> + <input + type={showGeminiKey ? 'text' : 'password'} + value={geminiApiKey} + onChange={e => setGeminiApiKey(e.target.value)} + placeholder="AI..." + className={`w-full px-3 py-2 pr-10 rounded-lg text-sm ${ + isDark + ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400' + } border focus:outline-none focus:ring-2 focus:ring-blue-500`} + /> + <button + type="button" + onClick={() => setShowGeminiKey(!showGeminiKey)} + className={`absolute right-2 top-1/2 -translate-y-1/2 p-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} + > + {showGeminiKey ? <EyeOff size={16} /> : <Eye size={16} />} + </button> + </div> + </div> + + {/* Save Button */} + <button + onClick={handleSaveApiKeys} + disabled={savingKeys} + className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2" + > + {savingKeys ? <Loader2 size={16} className="animate-spin" /> : null} + {savingKeys ? 'Saving...' : 'Save API Keys'} + </button> + + {/* Message */} + {keysMessage && ( + <p className={`text-xs ${keysMessage.type === 'success' ? 'text-green-500' : 'text-red-500'}`}> + {keysMessage.text} + </p> + )} + </div> + </div> + + {/* Footer */} + <div className={`p-4 border-t ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <button + onClick={() => { logout(); setShowUserSettings(false); }} + className={`w-full py-2 px-4 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${ + isDark + ? 'bg-red-900/30 hover:bg-red-900/50 text-red-400' + : 'bg-red-50 hover:bg-red-100 text-red-600' + }`} + > + <LogOut size={16} /> Log Out + </button> + </div> + </div> + </div> + )} + {/* Tabs */} <div className={`flex border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> <button |
