Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from .routes import chat, upload, files, auth, history
from .database import connect_to_mongo, close_mongo_connection
Expand All @@ -23,6 +24,15 @@
allow_headers=["*"],
)

# Mount static files
storage_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "storage"))
if not os.path.exists(storage_dir):
try:
os.makedirs(storage_dir)
except Exception:
pass
app.mount("/static", StaticFiles(directory=storage_dir), name="static")

# Event Handlers
app.add_event_handler("startup", connect_to_mongo)
app.add_event_handler("shutdown", close_mongo_connection)
Expand Down
85 changes: 81 additions & 4 deletions backend/app/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.security import OAuth2PasswordRequestForm
from datetime import timedelta
from ..auth import create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, get_current_user
from ..users import User
from pydantic import BaseModel, EmailStr
from typing import Dict, Any
from typing import Dict, Any, Optional
import os
import shutil
import uuid

router = APIRouter()

Expand All @@ -13,6 +16,10 @@ class UserCreate(BaseModel):
password: str
full_name: str = None

class UserUpdate(BaseModel):
full_name: Optional[str] = None
username: Optional[str] = None

class Token(BaseModel):
access_token: str
token_type: str
Expand All @@ -28,7 +35,7 @@ async def register(user: UserCreate):
print(f"Registration error: {e}")
raise HTTPException(status_code=500, detail=str(e))

@router.post("/login", response_model=Token)
@router.post("/login", response_model=Dict[str, Any])
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = await User.get_by_email(form_data.username)
if not user or not User.verify_password(form_data.password, user["hashed_password"]):
Expand All @@ -42,11 +49,81 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()):
access_token = create_access_token(
data={"sub": user["email"]}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

# Prepare user data
user_data = {k: v for k, v in user.items() if k != "hashed_password"}
if "_id" in user_data:
user_data["_id"] = str(user_data["_id"])

return {
"access_token": access_token,
"token_type": "bearer",
"user": user_data
}

@router.get("/me")
async def read_users_me(current_user = Depends(get_current_user)):
# Convert ObjectId to str for JSON serialization
if "_id" in current_user:
current_user["_id"] = str(current_user["_id"])
return current_user

@router.put("/profile")
async def update_profile(user_update: UserUpdate, current_user = Depends(get_current_user)):
try:
email = current_user["email"]
update_data = {k: v for k, v in user_update.dict().items() if v is not None}

if not update_data:
return current_user

success = await User.update_user(email, update_data)
if not success:
raise HTTPException(status_code=400, detail="Failed to update profile")

# Refetch updated user
updated_user = await User.get_by_email(email)
if "_id" in updated_user:
updated_user["_id"] = str(updated_user["_id"])

# Remove password
if "hashed_password" in updated_user:
del updated_user["hashed_password"]

return updated_user
except Exception as e:
print(f"Profile update error: {e}")
raise HTTPException(status_code=500, detail=str(e))

@router.post("/profile/avatar")
async def upload_avatar(file: UploadFile = File(...), current_user = Depends(get_current_user)):
try:
email = current_user["email"]

# Ensure uploads directory exists
current_dir = os.path.dirname(os.path.abspath(__file__))
storage_dir = os.path.abspath(os.path.join(current_dir, "..", "..", "storage", "avatars"))
os.makedirs(storage_dir, exist_ok=True)

# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(storage_dir, filename)

# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)

# Update user avatar URL
# URL should be relative path served by static
avatar_url = f"/static/avatars/{filename}"

success = await User.update_user(email, {"avatar_url": avatar_url})
if not success:
raise HTTPException(status_code=400, detail="Failed to update user avatar")

return {"avatar_url": avatar_url}

except Exception as e:
print(f"Avatar upload error: {e}")
raise HTTPException(status_code=500, detail=str(e))
10 changes: 10 additions & 0 deletions backend/app/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,13 @@ async def create_user(email: str, password: str, full_name: str = None):
result = await db.db.users.insert_one(user_doc)
user_doc["_id"] = result.inserted_id
return user_doc

@staticmethod
async def update_user(email: str, update_data: dict):
if db.db is None: raise Exception("Database not connected")

result = await db.db.users.update_one(
{"email": email},
{"$set": update_data}
)
return result.modified_count > 0
112 changes: 76 additions & 36 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { Auth } from "./components/Auth";
import LandingPage from "./components/LandingPage";
import { Sheet, SheetContent, SheetTrigger } from "./components/ui/sheet";
import { DialogTitle, DialogDescription } from "./components/ui/dialog";
import { PanelLeft, X, Database, LogOut, SquarePen } from "lucide-react";
import UserMenu from "./components/UserMenu";
import ProfileModal from "./components/ProfileModal";
import { PanelLeft, X, Database, LogOut, SquarePen, Sun, Moon } from "lucide-react";

function App() {
const [token, setToken] = useState(localStorage.getItem("token") || null);
Expand All @@ -28,6 +30,7 @@ function App() {
const [showSidebar, setShowSidebar] = useState(true);
const [indexedDocs, setIndexedDocs] = useState(0);
const [isDarkMode, setIsDarkMode] = useState(true);
const [isProfileOpen, setIsProfileOpen] = useState(false);


// Real Chat History
Expand Down Expand Up @@ -55,15 +58,48 @@ function App() {
useEffect(() => {
if (token) {
fetchHistory();

// Refresh user data if missing or incomplete
if (!user || !user.email) {
fetch(`${API_BASE_URL}/api/auth/me`, {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : null)
.then(u => {
if (u) {
setUser(u);
localStorage.setItem("user", JSON.stringify(u));
}
})
.catch(err => console.error("Failed to refresh user", err));
}
}
}, [token]);

const handleLogin = (data) => {
setToken(data.access_token);
setUser(data.user);
localStorage.setItem("token", data.access_token);
if (data.user) {
localStorage.setItem("user", JSON.stringify(data.user));
const handleLogin = async (data) => {
let userData = data.user;
const accessToken = data.access_token;

setToken(accessToken);
localStorage.setItem("token", accessToken);

if (!userData) {
// Fallback: fetch user me
try {
const res = await fetch(`${API_BASE_URL}/api/auth/me`, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (res.ok) {
userData = await res.json();
}
} catch (e) {
console.error("Failed to fetch user info", e);
}
}

setUser(userData);
if (userData) {
localStorage.setItem("user", JSON.stringify(userData));
} else {
localStorage.removeItem("user");
}
Expand All @@ -79,6 +115,28 @@ function App() {
setShowLanding(true);
};

const handleUpdateProfile = async (updatedData) => {
// Optimistic update
const newUser = { ...user, ...updatedData };
setUser(newUser);
localStorage.setItem("user", JSON.stringify(newUser));

// Attempt backend update if endpoint exists (mocked for now as we don't have PUT /api/auth/me confirmed)
// If backend supports it:
/*
try {
await fetch(`${API_BASE_URL}/api/auth/me`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(updatedData)
});
} catch(e) { console.error(e); }
*/
};

const fetchHistory = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/history`, {
Expand Down Expand Up @@ -290,18 +348,8 @@ function App() {


{/* User Area */}
<div className={`border-t pt-3 ${isDarkMode ? "border-gray-700" : "border-gray-200"}`}>
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold">
{user?.full_name?.[0] || user?.email?.[0] || "U"}
</div>
<div className="text-sm font-medium truncate max-w-[120px]">
{user?.full_name || "User"}
</div>
</div>
<button onClick={handleLogout} className="text-red-500 hover:text-red-700"><LogOut className="w-4 h-4"/></button>
</div>
<div className={`border-t pt-2 pb-2 ${isDarkMode ? "border-gray-700" : "border-gray-200"}`}>
<UserMenu user={user} onLogout={handleLogout} isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} onEditProfile={() => setIsProfileOpen(true)} />
</div>
</div>
</SheetContent>
Expand Down Expand Up @@ -335,17 +383,7 @@ function App() {

{/* User Area at bottom */}
<div className={`p-3 border-t ${isDarkMode ? "border-gray-700" : "border-gray-200"}`}>
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold shrink-0">
{user?.full_name?.[0] || user?.email?.[0] || "U"}
</div>
<div className="text-sm font-medium truncate">
{user?.full_name || "User"}
</div>
</div>
<button onClick={handleLogout} title="Logout" className="text-red-500 hover:text-red-700"><LogOut className="w-4 h-4"/></button>
</div>
<UserMenu user={user} onLogout={handleLogout} isDarkMode={isDarkMode} onToggleTheme={() => setIsDarkMode(!isDarkMode)} onEditProfile={() => setIsProfileOpen(true)} />
</div>
</aside>

Expand All @@ -360,12 +398,7 @@ function App() {
)}

</div>
<div className="flex items-center gap-4">
<button onClick={() => setIsDarkMode(!isDarkMode)} className={`px-3 py-1.5 rounded-full text-sm ${isDarkMode ? "bg-gray-700 text-yellow-400" : "bg-gray-100 text-gray-700"}`} title="Toggle dark mode">{isDarkMode ? "β˜€οΈ" : "πŸŒ™"}</button>
<div className={`px-3 py-1.5 rounded-full text-xs font-medium ${isOnline ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"}`}>
{isOnline ? "● Online" : "β—‹ Offline"}
</div>
</div>

</header>

<div className={`flex-1 overflow-hidden relative ${isDarkMode ? "bg-gray-900" : "bg-white"}`}>
Expand All @@ -387,6 +420,13 @@ function App() {
)}

<SourceModal item={modalItem} onClose={() => setModalItem(null)} />
<ProfileModal
isOpen={isProfileOpen}
onClose={() => setIsProfileOpen(false)}
user={user}
onSave={handleUpdateProfile}
isDarkMode={isDarkMode}
/>
</div>
);
}
Expand Down
Loading
Loading