From 5ff652127644e6a505c8981c1c25f6156ecb9435 Mon Sep 17 00:00:00 2001 From: Hitesh Kumar Date: Wed, 21 Jan 2026 17:13:57 +0530 Subject: [PATCH] feat: Implement initial full-stack application with user authentication, chat history management, and core chat UI. --- backend/app/main.py | 10 ++ backend/app/routes/auth.py | 85 +++++++++- backend/app/users.py | 10 ++ frontend/src/App.jsx | 112 +++++++++----- frontend/src/components/ProfileModal.jsx | 189 +++++++++++++++++++++++ frontend/src/components/UserMenu.jsx | 82 ++++++++++ 6 files changed, 448 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/ProfileModal.jsx create mode 100644 frontend/src/components/UserMenu.jsx diff --git a/backend/app/main.py b/backend/app/main.py index c126ca5..e010032 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 @@ -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) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 0a17f8a..67e1b04 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -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() @@ -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 @@ -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"]): @@ -42,7 +49,17 @@ 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)): @@ -50,3 +67,63 @@ async def read_users_me(current_user = Depends(get_current_user)): 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)) diff --git a/backend/app/users.py b/backend/app/users.py index 67d08b6..c9f6abf 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8978251..700da71 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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); @@ -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 @@ -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"); } @@ -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`, { @@ -290,18 +348,8 @@ function App() { {/* User Area */} -
-
-
-
- {user?.full_name?.[0] || user?.email?.[0] || "U"} -
-
- {user?.full_name || "User"} -
-
- -
+
+ setIsDarkMode(!isDarkMode)} onEditProfile={() => setIsProfileOpen(true)} />
@@ -335,17 +383,7 @@ function App() { {/* User Area at bottom */}
-
-
-
- {user?.full_name?.[0] || user?.email?.[0] || "U"} -
-
- {user?.full_name || "User"} -
-
- -
+ setIsDarkMode(!isDarkMode)} onEditProfile={() => setIsProfileOpen(true)} />
@@ -360,12 +398,7 @@ function App() { )} -
- -
- {isOnline ? "● Online" : "○ Offline"} -
-
+
@@ -387,6 +420,13 @@ function App() { )} setModalItem(null)} /> + setIsProfileOpen(false)} + user={user} + onSave={handleUpdateProfile} + isDarkMode={isDarkMode} + />
); } diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx new file mode 100644 index 0000000..3511772 --- /dev/null +++ b/frontend/src/components/ProfileModal.jsx @@ -0,0 +1,189 @@ +import { useState, useEffect, useRef } from "react"; +import { X, Camera } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"; +import { API_BASE_URL } from "../config"; + +export default function ProfileModal({ isOpen, onClose, user, onSave, isDarkMode }) { + const [displayName, setDisplayName] = useState(user?.full_name || ""); + const [username, setUsername] = useState(user?.username || user?.email?.split('@')[0] || ""); + const [avatarPreview, setAvatarPreview] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const fileInputRef = useRef(null); + + // Sync state when user prop changes or modal opens + useEffect(() => { + if (isOpen && user) { + setDisplayName(user.full_name || ""); + setUsername(user.username || user.email?.split('@')[0] || ""); + setAvatarPreview(null); // Reset preview when modal opens + } + }, [isOpen, user]); + + // Clean up object URL on unmount or when preview changes + useEffect(() => { + return () => { + if (avatarPreview) { + URL.revokeObjectURL(avatarPreview); + } + }; + }, [avatarPreview]); + + const handleSave = async () => { + setIsLoading(true); + const token = localStorage.getItem("token"); + + try { + let updatedUser = { ...user, full_name: displayName, username: username }; + let hasChanges = false; + + // 1. Upload Avatar if changed (we don't track explicit change yet, but checking if ref has file) + // Actually, we should track if a file was selected. + // Let's use a state for selectedFile + if (selectedFile) { + const formData = new FormData(); + formData.append("file", selectedFile); + + const res = await fetch(`${API_BASE_URL}/api/auth/profile/avatar`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + + if (!res.ok) throw new Error("Failed to upload avatar"); + const data = await res.json(); + updatedUser.avatar_url = data.avatar_url; + hasChanges = true; + } + + // 2. Update Profile Info + if (displayName !== user.full_name || username !== user.username) { + const res = await fetch(`${API_BASE_URL}/api/auth/profile`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ full_name: displayName, username: username }) + }); + + if (!res.ok) throw new Error("Failed to update profile"); + const data = await res.json(); + // Merge response + updatedUser = { ...updatedUser, ...data }; + hasChanges = true; + } + + if (hasChanges) { + await onSave(updatedUser); + } + + onClose(); + } catch (e) { + console.error("Failed to save profile", e); + } finally { + setIsLoading(false); + } + }; + + const [selectedFile, setSelectedFile] = useState(null); + + const handleAvatarClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event) => { + const file = event.target.files?.[0]; + if (file) { + setSelectedFile(file); + const previewUrl = URL.createObjectURL(file); + setAvatarPreview(previewUrl); + } + }; + + return ( + + + + Edit profile + Update your profile details. + + +
+ {/* Avatar Section */} +
+
+
+ {avatarPreview ? ( + Avatar Preview + ) : (user?.avatar_url ? ( + Avatar + ) : ( + displayName ? (displayName.length > 1 ? displayName.substring(0,2).toUpperCase() : displayName[0].toUpperCase()) : "U" + ))} +
+
+ +
+
+ +
+ + {/* Inputs */} +
+
+ + setDisplayName(e.target.value)} + className={`w-full px-3 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500 outline-none transition-all ${isDarkMode ? "bg-black/20 border-gray-600 focus:border-blue-500 text-white placeholder-gray-500" : "bg-white border-gray-300 focus:border-blue-500 text-gray-900 placeholder-gray-400"}`} + placeholder="Enter your name" + /> +
+ +
+ + setUsername(e.target.value)} + className={`w-full px-3 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500 outline-none transition-all ${isDarkMode ? "bg-black/20 border-gray-600 focus:border-blue-500 text-white placeholder-gray-500" : "bg-white border-gray-300 focus:border-blue-500 text-gray-900 placeholder-gray-400"}`} + placeholder="Enter your username" + /> +

+ Your profile helps people recognize you. Your name and username are also used in the Sora app. +

+
+
+ + {/* Footer Buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/UserMenu.jsx b/frontend/src/components/UserMenu.jsx new file mode 100644 index 0000000..cf52afa --- /dev/null +++ b/frontend/src/components/UserMenu.jsx @@ -0,0 +1,82 @@ +import { useState, useRef, useEffect } from "react"; +import { LogOut, Settings, Sun, Moon } from "lucide-react"; +import { API_BASE_URL } from "../config"; + +export default function UserMenu({ user, onLogout, isDarkMode, onToggleTheme, onEditProfile }) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event) { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+
+ Account +
+ +
{ setIsOpen(false); onEditProfile(); }} + className={`px-4 py-2 text-sm border-b cursor-pointer transition-colors ${isDarkMode ? "border-gray-700 hover:bg-gray-700" : "border-gray-200 hover:bg-gray-50"}`} + > +

{user?.full_name || "User"}

+

{user?.email}

+
+ + + + + +
+ )} +
+ ); +}