summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/app/auth/models.py5
-rw-r--r--backend/app/auth/routes.py47
-rw-r--r--frontend/index.html4
-rw-r--r--frontend/public/webicon.pngbin0 -> 77075 bytes
-rw-r--r--frontend/src/components/LeftSidebar.tsx193
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
new file mode 100644
index 0000000..0fc5a9a
--- /dev/null
+++ b/frontend/public/webicon.png
Binary files differ
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