diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 00000000..a8c8cdd9 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,275 @@ +""" +Google OAuth authentication routes +""" +import os +import httpx +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.db import get_db, User, UserSettings +from backend.app.services.auth_utils import create_access_token + +router = APIRouter(prefix="/api/auth", tags=["Authentication"]) + +# Google OAuth Configuration +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "") +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") + +# Google OAuth URLs +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" + + +@router.get("/google/login") +async def google_login(): + """ + Redirect to Google OAuth login page + """ + if not GOOGLE_CLIENT_ID: + raise HTTPException(status_code=500, detail="Google OAuth not configured") + + # Build the authorization URL + redirect_uri = f"{FRONTEND_URL}/api/auth/callback/google" + + # For backend-handled callback (alternative): + # redirect_uri = f"{os.getenv('BACKEND_URL', 'http://localhost:8000')}/api/auth/google/callback" + + params = { + "client_id": GOOGLE_CLIENT_ID, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "access_type": "offline", + "prompt": "consent", + } + + query_string = "&".join(f"{k}={v}" for k, v in params.items()) + auth_url = f"{GOOGLE_AUTH_URL}?{query_string}" + + return RedirectResponse(url=auth_url) + + +@router.get("/google/callback") +async def google_callback( + code: str = Query(...), + db: AsyncSession = Depends(get_db) +): + """ + Handle Google OAuth callback (backend-handled flow) + Exchange authorization code for tokens and create/update user + """ + if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET: + raise HTTPException(status_code=500, detail="Google OAuth not configured") + + # Determine redirect URI (must match what was used in the login request) + redirect_uri = f"{os.getenv('BACKEND_URL', 'http://localhost:8000')}/api/auth/google/callback" + + # Exchange code for tokens + async with httpx.AsyncClient() as client: + token_response = await client.post( + GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": GOOGLE_CLIENT_ID, + "client_secret": GOOGLE_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + } + ) + + if token_response.status_code != 200: + raise HTTPException( + status_code=400, + detail=f"Failed to exchange code: {token_response.text}" + ) + + tokens = token_response.json() + access_token = tokens.get("access_token") + + # Get user info from Google + userinfo_response = await client.get( + GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}"} + ) + + if userinfo_response.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to get user info") + + userinfo = userinfo_response.json() + + # Find or create user + google_id = userinfo["id"] + email = userinfo["email"] + name = userinfo.get("name") + avatar_url = userinfo.get("picture") + + # Check if user exists + result = await db.execute( + select(User).where(User.google_id == google_id) + ) + user = result.scalar_one_or_none() + + if user: + # Update existing user + user.last_login_at = datetime.utcnow() + user.name = name + user.avatar_url = avatar_url + else: + # Create new user + user = User( + google_id=google_id, + email=email, + name=name, + avatar_url=avatar_url, + last_login_at=datetime.utcnow() + ) + db.add(user) + + await db.commit() + await db.refresh(user) + + # Create JWT token + jwt_token = create_access_token({ + "id": user.id, + "email": user.email, + "name": user.name, + "avatar_url": user.avatar_url, + }) + + # Redirect to frontend with token + redirect_url = f"{FRONTEND_URL}/auth/callback?token={jwt_token}" + return RedirectResponse(url=redirect_url) + + +@router.post("/google/token") +async def exchange_google_token( + code: str, + redirect_uri: str, + db: AsyncSession = Depends(get_db) +): + """ + Exchange Google authorization code for JWT token (frontend-handled flow) + This is called by the frontend after receiving the code from Google + """ + if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET: + raise HTTPException(status_code=500, detail="Google OAuth not configured") + + # Exchange code for tokens + async with httpx.AsyncClient() as client: + token_response = await client.post( + GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": GOOGLE_CLIENT_ID, + "client_secret": GOOGLE_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + } + ) + + if token_response.status_code != 200: + error_detail = token_response.json() if token_response.headers.get("content-type", "").startswith("application/json") else token_response.text + raise HTTPException( + status_code=400, + detail=f"Failed to exchange code: {error_detail}" + ) + + tokens = token_response.json() + access_token = tokens.get("access_token") + + # Get user info from Google + userinfo_response = await client.get( + GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}"} + ) + + if userinfo_response.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to get user info") + + userinfo = userinfo_response.json() + + # Find or create user + google_id = userinfo["id"] + email = userinfo["email"] + name = userinfo.get("name") + avatar_url = userinfo.get("picture") + + # Check if user exists + result = await db.execute( + select(User).where(User.google_id == google_id) + ) + user = result.scalar_one_or_none() + + if user: + # Update existing user + user.last_login_at = datetime.utcnow() + user.name = name + user.avatar_url = avatar_url + else: + # Create new user + user = User( + google_id=google_id, + email=email, + name=name, + avatar_url=avatar_url, + last_login_at=datetime.utcnow() + ) + db.add(user) + + await db.commit() + await db.refresh(user) + + # Create JWT token + jwt_token = create_access_token({ + "id": user.id, + "email": user.email, + "name": user.name, + "avatar_url": user.avatar_url, + }) + + return { + "access_token": jwt_token, + "token_type": "bearer", + "user": { + "id": str(user.id), + "email": user.email, + "name": user.name, + "avatar_url": user.avatar_url, + } + } + + +@router.get("/me") +async def get_current_user( + authorization: Optional[str] = None, + db: AsyncSession = Depends(get_db) +): + """ + Get current user info from JWT token + Returns None if not authenticated (optional auth) + """ + from backend.app.services.auth_utils import verify_access_token + + if not authorization or not authorization.startswith("Bearer "): + return {"user": None} + + token = authorization.replace("Bearer ", "") + payload = verify_access_token(token) + + if not payload: + return {"user": None} + + return { + "user": { + "id": payload.get("sub"), + "email": payload.get("email"), + "name": payload.get("name"), + "avatar_url": payload.get("avatar_url"), + } + } diff --git a/backend/app/api/user.py b/backend/app/api/user.py new file mode 100644 index 00000000..1645270d --- /dev/null +++ b/backend/app/api/user.py @@ -0,0 +1,267 @@ +""" +User settings and reports API routes +""" +import json +from typing import Optional, List +from uuid import UUID +from fastapi import APIRouter, HTTPException, Depends, Header +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete +from datetime import datetime + +from backend.app.db import get_db, User, UserSettings, Report +from backend.app.services.auth_utils import verify_access_token, encrypt_settings, decrypt_settings + +router = APIRouter(prefix="/api/user", tags=["User"]) + + +# ============== Pydantic Models ============== + +class SettingsUpdate(BaseModel): + """API settings to save""" + openai_api_key: str = "" + alpha_vantage_api_key: str = "" + anthropic_api_key: str = "" + google_api_key: str = "" + grok_api_key: str = "" + deepseek_api_key: str = "" + qwen_api_key: str = "" + finmind_api_key: str = "" + custom_base_url: str = "" + custom_api_key: str = "" + + +class ReportCreate(BaseModel): + """Report to save""" + ticker: str + market_type: str # us, twse, tpex + analysis_date: str + result: dict + + +class ReportResponse(BaseModel): + """Report response""" + id: str + ticker: str + market_type: str + analysis_date: str + result: dict + created_at: str + + +# ============== Auth Dependency ============== + +async def get_current_user_optional( + authorization: Optional[str] = Header(None), + db: AsyncSession = Depends(get_db) +) -> Optional[User]: + """Get current user from JWT token (optional - returns None if not authenticated)""" + if not authorization or not authorization.startswith("Bearer "): + return None + + token = authorization.replace("Bearer ", "") + payload = verify_access_token(token) + + if not payload: + return None + + user_id = payload.get("sub") + if not user_id: + return None + + try: + result = await db.execute( + select(User).where(User.id == UUID(user_id)) + ) + return result.scalar_one_or_none() + except: + return None + + +async def get_current_user_required( + user: Optional[User] = Depends(get_current_user_optional) +) -> User: + """Require authenticated user""" + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + return user + + +# ============== Settings Routes ============== + +@router.get("/settings") +async def get_settings( + user: User = Depends(get_current_user_required), + db: AsyncSession = Depends(get_db) +): + """Get user's saved API settings (decrypted)""" + result = await db.execute( + select(UserSettings).where(UserSettings.user_id == user.id) + ) + settings = result.scalar_one_or_none() + + if not settings: + return {"settings": None} + + try: + decrypted = decrypt_settings(settings.encrypted_settings) + return {"settings": json.loads(decrypted)} + except Exception as e: + print(f"Failed to decrypt settings: {e}") + return {"settings": None} + + +@router.put("/settings") +async def update_settings( + settings_data: SettingsUpdate, + user: User = Depends(get_current_user_required), + db: AsyncSession = Depends(get_db) +): + """Save user's API settings (encrypted)""" + # Convert to JSON and encrypt + settings_json = json.dumps(settings_data.model_dump()) + encrypted = encrypt_settings(settings_json) + + # Find existing settings + result = await db.execute( + select(UserSettings).where(UserSettings.user_id == user.id) + ) + existing = result.scalar_one_or_none() + + if existing: + existing.encrypted_settings = encrypted + existing.updated_at = datetime.utcnow() + else: + new_settings = UserSettings( + user_id=user.id, + encrypted_settings=encrypted + ) + db.add(new_settings) + + await db.commit() + + return {"success": True, "message": "Settings saved successfully"} + + +# ============== Reports Routes ============== + +@router.get("/reports", response_model=List[ReportResponse]) +async def get_reports( + user: User = Depends(get_current_user_required), + db: AsyncSession = Depends(get_db) +): + """Get all user's reports""" + result = await db.execute( + select(Report) + .where(Report.user_id == user.id) + .order_by(Report.created_at.desc()) + ) + reports = result.scalars().all() + + return [ + ReportResponse( + id=str(r.id), + ticker=r.ticker, + market_type=r.market_type, + analysis_date=r.analysis_date, + result=r.result, + created_at=r.created_at.isoformat() + ) + for r in reports + ] + + +@router.post("/reports") +async def create_report( + report_data: ReportCreate, + user: User = Depends(get_current_user_required), + db: AsyncSession = Depends(get_db) +): + """Save a new report""" + report = Report( + user_id=user.id, + ticker=report_data.ticker, + market_type=report_data.market_type, + analysis_date=report_data.analysis_date, + result=report_data.result + ) + db.add(report) + await db.commit() + await db.refresh(report) + + return { + "success": True, + "report_id": str(report.id), + "message": "Report saved successfully" + } + + +@router.get("/reports/{report_id}") +async def get_report( + report_id: str, + user: User = Depends(get_current_user_required), + db: AsyncSession = Depends(get_db) +): + """Get a specific report""" + try: + result = await db.execute( + select(Report) + .where(Report.id == UUID(report_id)) + .where(Report.user_id == user.id) + ) + report = result.scalar_one_or_none() + except: + raise HTTPException(status_code=400, detail="Invalid report ID") + + if not report: + raise HTTPException(status_code=404, detail="Report not found") + + return ReportResponse( + id=str(report.id), + ticker=report.ticker, + market_type=report.market_type, + analysis_date=report.analysis_date, + result=report.result, + created_at=report.created_at.isoformat() + ) + + +@router.delete("/reports/{report_id}") +async def delete_report( + report_id: str, + user: User = Depends(get_current_user_required), + db: AsyncSession = Depends(get_db) +): + """Delete a report""" + try: + result = await db.execute( + select(Report) + .where(Report.id == UUID(report_id)) + .where(Report.user_id == user.id) + ) + report = result.scalar_one_or_none() + except: + raise HTTPException(status_code=400, detail="Invalid report ID") + + if not report: + raise HTTPException(status_code=404, detail="Report not found") + + await db.delete(report) + await db.commit() + + return {"success": True, "message": "Report deleted successfully"} + + +@router.delete("/reports") +async def delete_all_reports( + user: User = Depends(get_current_user_required), + db: AsyncSession = Depends(get_db) +): + """Delete all user's reports""" + await db.execute( + delete(Report).where(Report.user_id == user.id) + ) + await db.commit() + + return {"success": True, "message": "All reports deleted successfully"} diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 00000000..f9f07a20 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1,17 @@ +""" +Database module exports +""" +from .database import Base, engine, AsyncSessionLocal, get_db, init_db, check_db_connection +from .models import User, UserSettings, Report + +__all__ = [ + "Base", + "engine", + "AsyncSessionLocal", + "get_db", + "init_db", + "check_db_connection", + "User", + "UserSettings", + "Report", +] diff --git a/backend/app/db/database.py b/backend/app/db/database.py new file mode 100644 index 00000000..76433405 --- /dev/null +++ b/backend/app/db/database.py @@ -0,0 +1,80 @@ +""" +Database configuration and connection management +""" +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import text + +# Get database URL from environment +DATABASE_URL = os.getenv("DATABASE_URL", "") + +# Convert postgres:// to postgresql+asyncpg:// for async support +if DATABASE_URL.startswith("postgres://"): + DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql+asyncpg://", 1) +elif DATABASE_URL.startswith("postgresql://"): + DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1) + + +class Base(DeclarativeBase): + """Base class for all database models""" + pass + + +# Create async engine (will be None if no DATABASE_URL) +engine = None +AsyncSessionLocal = None + +if DATABASE_URL: + engine = create_async_engine( + DATABASE_URL, + echo=False, # Set to True for SQL debugging + pool_pre_ping=True, + pool_size=5, + max_overflow=10, + ) + + AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + +async def get_db(): + """Dependency for getting database sessions""" + if AsyncSessionLocal is None: + raise RuntimeError("Database not configured") + + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + + +async def init_db(): + """Initialize database tables""" + if engine is None: + print("Warning: DATABASE_URL not configured, skipping database initialization") + return + + async with engine.begin() as conn: + # Create all tables + await conn.run_sync(Base.metadata.create_all) + + print("Database tables initialized successfully") + + +async def check_db_connection(): + """Check if database connection is working""" + if engine is None: + return False + + try: + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + return True + except Exception as e: + print(f"Database connection failed: {e}") + return False diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 00000000..d3e7e6d4 --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,102 @@ +""" +Database models for users, settings, and reports +""" +import uuid +from datetime import datetime +from typing import Optional +from sqlalchemy import String, Text, DateTime, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID + +from .database import Base + + +class User(Base): + """User model for storing Google OAuth users""" + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + google_id: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + avatar_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + nullable=False + ) + last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relationships + settings: Mapped[Optional["UserSettings"]] = relationship( + "UserSettings", + back_populates="user", + uselist=False, + cascade="all, delete-orphan" + ) + reports: Mapped[list["Report"]] = relationship( + "Report", + back_populates="user", + cascade="all, delete-orphan" + ) + + +class UserSettings(Base): + """User settings (encrypted API keys, custom base URLs)""" + __tablename__ = "user_settings" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + unique=True, + nullable=False + ) + # Store settings as encrypted JSON string + encrypted_settings: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False + ) + + # Relationship + user: Mapped["User"] = relationship("User", back_populates="settings") + + +class Report(Base): + """Analysis report storage""" + __tablename__ = "reports" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False + ) + ticker: Mapped[str] = mapped_column(String(20), nullable=False) + market_type: Mapped[str] = mapped_column(String(10), nullable=False) # us, twse, tpex + analysis_date: Mapped[str] = mapped_column(String(10), nullable=False) # YYYY-MM-DD + # Store full result as JSONB + result: Mapped[dict] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + nullable=False + ) + + # Relationship + user: Mapped["User"] = relationship("User", back_populates="reports") diff --git a/backend/app/main.py b/backend/app/main.py index d2d0b639..69c13e00 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,8 @@ from datetime import datetime, timedelta from backend.app.core.config import settings from backend.app.core.cors import setup_cors from backend.app.api.routes import router +from backend.app.api.auth import router as auth_router +from backend.app.api.user import router as user_router # Configure logging logging.basicConfig( @@ -139,6 +141,26 @@ setup_cors(app) # Include API routes app.include_router(router) +app.include_router(auth_router) +app.include_router(user_router) + + +# Database initialization on startup +@app.on_event("startup") +async def startup_event(): + """Initialize database on startup""" + try: + from backend.app.db import init_db, check_db_connection + + # Check if database is configured + if await check_db_connection(): + logger.info("Database connection successful") + await init_db() + logger.info("Database tables initialized") + else: + logger.warning("Database not configured or connection failed - running without database") + except Exception as e: + logger.warning(f"Database initialization failed: {e} - running without database") @app.get("/") diff --git a/backend/app/services/auth_utils.py b/backend/app/services/auth_utils.py new file mode 100644 index 00000000..3f4f48e5 --- /dev/null +++ b/backend/app/services/auth_utils.py @@ -0,0 +1,101 @@ +""" +Authentication utilities - JWT and encryption +""" +import os +import jwt +import hashlib +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from cryptography.fernet import Fernet +import base64 + +# JWT Configuration +JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret-please-change-in-production") +JWT_ALGORITHM = "HS256" +JWT_EXPIRATION_HOURS = 24 * 7 # 7 days + +# Encryption key derived from JWT_SECRET (for settings encryption) +def _get_fernet_key() -> bytes: + """Generate a Fernet-compatible key from JWT_SECRET""" + # Use SHA256 to get a consistent 32-byte key, then base64 encode + key_hash = hashlib.sha256(JWT_SECRET.encode()).digest() + return base64.urlsafe_b64encode(key_hash) + +_fernet = Fernet(_get_fernet_key()) + + +def create_access_token(user_data: Dict[str, Any]) -> str: + """ + Create a JWT access token for a user + + Args: + user_data: Dict containing user info (id, email, name, avatar_url) + + Returns: + JWT token string + """ + payload = { + "sub": str(user_data["id"]), # Subject (user ID) + "email": user_data["email"], + "name": user_data.get("name"), + "avatar_url": user_data.get("avatar_url"), + "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS), + "iat": datetime.utcnow(), + } + + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def verify_access_token(token: str) -> Optional[Dict[str, Any]]: + """ + Verify and decode a JWT access token + + Args: + token: JWT token string + + Returns: + Decoded payload if valid, None if invalid/expired + """ + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +def encrypt_settings(settings_json: str) -> str: + """ + Encrypt user settings JSON string + + Args: + settings_json: JSON string of user settings + + Returns: + Encrypted string (base64 encoded) + """ + encrypted = _fernet.encrypt(settings_json.encode()) + return encrypted.decode() + + +def decrypt_settings(encrypted_settings: str) -> str: + """ + Decrypt user settings + + Args: + encrypted_settings: Encrypted settings string + + Returns: + Decrypted JSON string + """ + decrypted = _fernet.decrypt(encrypted_settings.encode()) + return decrypted.decode() + + +def get_user_id_from_token(token: str) -> Optional[str]: + """Extract user ID from a valid token""" + payload = verify_access_token(token) + if payload: + return payload.get("sub") + return None diff --git a/backend/requirements.txt b/backend/requirements.txt index 9942bdff..c835855b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,16 @@ python-multipart==0.0.6 # Environment and configuration python-dotenv==1.0.0 +# Database (PostgreSQL) +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 +psycopg2-binary>=2.9.9 + +# Authentication +PyJWT>=2.8.0 +cryptography>=41.0.0 +httpx>=0.25.0 + # Existing TradingAgentsX dependencies typing-extensions langchain-openai @@ -45,3 +55,4 @@ markdown>=3.5.0 # Toon format for token optimization git+https://github.com/toon-format/toon-python.git + diff --git a/frontend/app/analysis/results/page.tsx b/frontend/app/analysis/results/page.tsx index 16f38e25..75cd84e1 100644 --- a/frontend/app/analysis/results/page.tsx +++ b/frontend/app/analysis/results/page.tsx @@ -5,13 +5,15 @@ import { useRouter } from "next/navigation"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useAnalysisContext } from "@/context/AnalysisContext"; +import { useAuth } from "@/contexts/auth-context"; import { PriceChart } from "@/components/analysis/PriceChart"; import { DownloadReports } from "@/components/analysis/DownloadReports"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { ChevronLeft, Save, Check, AlertCircle } from "lucide-react"; +import { ChevronLeft, Save, Check, AlertCircle, Cloud } from "lucide-react"; import { saveReport, checkDuplicateReport } from "@/lib/reports-db"; +import { saveCloudReport, isCloudSyncEnabled } from "@/lib/user-api"; const ANALYSTS = [ // === 分析師團隊 === @@ -103,12 +105,14 @@ const getNestedValue = (obj: any, path: string) => { export default function AnalysisResultsPage() { const router = useRouter(); const { analysisResult, taskId, marketType } = useAnalysisContext(); + const { isAuthenticated } = useAuth(); const [selectedAnalyst, setSelectedAnalyst] = useState("market"); // Save report states const [saving, setSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const [saveError, setSaveError] = useState(null); + const [savedToCloud, setSavedToCloud] = useState(false); // 如果沒有結果,重定向到分析頁面 useEffect(() => { @@ -124,9 +128,10 @@ export default function AnalysisResultsPage() { setSaving(true); setSaveError(null); setSaveSuccess(false); + setSavedToCloud(false); try { - // Check for duplicate + // Check for duplicate in local storage const duplicate = await checkDuplicateReport( analysisResult.ticker, analysisResult.analysis_date @@ -138,6 +143,7 @@ export default function AnalysisResultsPage() { return; } + // Save to local IndexedDB await saveReport( analysisResult.ticker, marketType, @@ -146,9 +152,25 @@ export default function AnalysisResultsPage() { taskId || undefined ); + // If authenticated, also save to cloud + if (isAuthenticated && isCloudSyncEnabled()) { + const cloudId = await saveCloudReport({ + ticker: analysisResult.ticker, + market_type: marketType, + analysis_date: analysisResult.analysis_date, + result: analysisResult, + }); + if (cloudId) { + setSavedToCloud(true); + } + } + setSaveSuccess(true); // Reset success message after 3 seconds - setTimeout(() => setSaveSuccess(false), 3000); + setTimeout(() => { + setSaveSuccess(false); + setSavedToCloud(false); + }, 3000); } catch (error) { console.error("Save report error:", error); setSaveError("儲存失敗,請稍後再試"); diff --git a/frontend/app/api/auth/callback/google/route.ts b/frontend/app/api/auth/callback/google/route.ts new file mode 100644 index 00000000..5404f83a --- /dev/null +++ b/frontend/app/api/auth/callback/google/route.ts @@ -0,0 +1,30 @@ +/** + * Next.js API Route for Google OAuth callback redirect + * Redirects to the auth callback page with the code + */ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + const error = searchParams.get("error"); + + // Handle OAuth errors + if (error) { + return NextResponse.redirect( + new URL(`/auth/callback?error=${encodeURIComponent(error)}`, request.url) + ); + } + + // Redirect to callback page with code + if (code) { + return NextResponse.redirect( + new URL(`/auth/callback?code=${encodeURIComponent(code)}`, request.url) + ); + } + + // No code or error + return NextResponse.redirect( + new URL("/auth/callback?error=no_code", request.url) + ); +} diff --git a/frontend/app/api/auth/google/token/route.ts b/frontend/app/api/auth/google/token/route.ts new file mode 100644 index 00000000..2b54b697 --- /dev/null +++ b/frontend/app/api/auth/google/token/route.ts @@ -0,0 +1,44 @@ +/** + * Next.js API Route for Google OAuth callback + * This proxies the token exchange to the backend + */ +import { NextRequest, NextResponse } from "next/server"; + +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { code, redirect_uri } = body; + + if (!code) { + return NextResponse.json( + { error: "Missing authorization code" }, + { status: 400 } + ); + } + + // Forward to backend + const backendResponse = await fetch(`${BACKEND_URL}/api/auth/google/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code, redirect_uri }), + }); + + const data = await backendResponse.json(); + + if (!backendResponse.ok) { + return NextResponse.json(data, { status: backendResponse.status }); + } + + return NextResponse.json(data); + } catch (error: any) { + console.error("Token exchange error:", error); + return NextResponse.json( + { error: "Token exchange failed", detail: error.message }, + { status: 500 } + ); + } +} diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx new file mode 100644 index 00000000..03dc8731 --- /dev/null +++ b/frontend/app/auth/callback/page.tsx @@ -0,0 +1,117 @@ +/** + * OAuth Callback Handler Page + * Handles the redirect from Google OAuth and exchanges code for token + */ +"use client"; + +import { useEffect, useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/contexts/auth-context"; + +function AuthCallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { setAuthFromCallback } = useAuth(); + const [error, setError] = useState(null); + const [isProcessing, setIsProcessing] = useState(true); + + useEffect(() => { + const handleCallback = async () => { + // Check if we have a token directly (backend-handled flow) + const token = searchParams.get("token"); + if (token) { + setAuthFromCallback(token); + router.replace("/"); + return; + } + + // Check if we have a code (frontend-handled flow) + const code = searchParams.get("code"); + if (!code) { + const errorParam = searchParams.get("error"); + setError(errorParam || "No authorization code received"); + setIsProcessing(false); + return; + } + + try { + // Exchange code for token via backend + const redirectUri = `${window.location.origin}/api/auth/callback/google`; + + const response = await fetch("/api/auth/google/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to exchange token"); + } + + const data = await response.json(); + setAuthFromCallback(data.access_token); + router.replace("/"); + } catch (err: any) { + console.error("Auth callback error:", err); + setError(err.message || "Authentication failed"); + setIsProcessing(false); + } + }; + + handleCallback(); + }, [searchParams, setAuthFromCallback, router]); + + if (error) { + return ( +
+
+
⚠️
+

登入失敗

+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+

正在登入...

+

請稍候

+
+
+ ); +} + +function LoadingFallback() { + return ( +
+
+
+

載入中...

+
+
+ ); +} + +export default function AuthCallbackPage() { + return ( + }> + + + ); +} + diff --git a/frontend/app/history/page.tsx b/frontend/app/history/page.tsx index 563164b5..5623243b 100644 --- a/frontend/app/history/page.tsx +++ b/frontend/app/history/page.tsx @@ -8,6 +8,7 @@ import { useRouter } from "next/navigation"; import { format } from "date-fns"; import { zhTW } from "date-fns/locale"; import { useAnalysisContext } from "@/context/AnalysisContext"; +import { useAuth } from "@/contexts/auth-context"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -26,13 +27,15 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Trash2, Eye, RefreshCw, TrendingUp } from "lucide-react"; +import { Trash2, Eye, RefreshCw, TrendingUp, Cloud, CloudOff } from "lucide-react"; import { getReportsByMarketType, deleteReport, getReportCountByMarketType, type SavedReport, } from "@/lib/reports-db"; +import { getCloudReports, deleteCloudReport, isCloudSyncEnabled } from "@/lib/user-api"; +import { LoginPrompt } from "@/components/auth/login-button"; // Market type labels const MARKET_LABELS = { @@ -104,11 +107,13 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color export default function HistoryPage() { const router = useRouter(); const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext(); + const { isAuthenticated } = useAuth(); const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us"); const [reports, setReports] = useState([]); const [loading, setLoading] = useState(true); const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 }); + const [isCloudData, setIsCloudData] = useState(false); // Delete confirmation dialog const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -117,23 +122,51 @@ export default function HistoryPage() { ); const [deleting, setDeleting] = useState(false); - // Load reports when tab changes + // Load reports when tab changes or auth state changes useEffect(() => { loadReports(); - }, [activeTab]); + }, [activeTab, isAuthenticated]); - // Load counts on mount + // Load counts on mount or auth change useEffect(() => { loadCounts(); - }, []); + }, [isAuthenticated]); const loadReports = async () => { setLoading(true); try { + // If authenticated, try to load from cloud first + if (isAuthenticated && isCloudSyncEnabled()) { + const cloudReports = await getCloudReports(); + if (cloudReports.length > 0) { + // Convert cloud reports to SavedReport format and filter by market type + const filtered = cloudReports + .filter(r => r.market_type === activeTab) + .map(r => ({ + id: parseInt(r.id.replace(/-/g, '').slice(0, 8), 16), // Convert UUID to number + cloudId: r.id, // Keep cloud ID for deletion + ticker: r.ticker, + market_type: r.market_type as "us" | "twse" | "tpex", + analysis_date: r.analysis_date, + saved_at: new Date(r.created_at), + result: r.result, + })) as (SavedReport & { cloudId?: string })[]; + setReports(filtered); + setIsCloudData(true); + return; + } + } + + // Fall back to local IndexedDB const data = await getReportsByMarketType(activeTab); setReports(data); + setIsCloudData(false); } catch (error) { console.error("Failed to load reports:", error); + // Fall back to local on error + const data = await getReportsByMarketType(activeTab); + setReports(data); + setIsCloudData(false); } finally { setLoading(false); } @@ -141,6 +174,19 @@ export default function HistoryPage() { const loadCounts = async () => { try { + if (isAuthenticated && isCloudSyncEnabled()) { + const cloudReports = await getCloudReports(); + const cloudCounts = { + us: cloudReports.filter(r => r.market_type === "us").length, + twse: cloudReports.filter(r => r.market_type === "twse").length, + tpex: cloudReports.filter(r => r.market_type === "tpex").length, + }; + if (cloudReports.length > 0) { + setCounts(cloudCounts); + return; + } + } + const data = await getReportCountByMarketType(); setCounts(data); } catch (error) { @@ -163,11 +209,19 @@ export default function HistoryPage() { }; const handleConfirmDelete = async () => { - if (!reportToDelete?.id) return; + if (!reportToDelete) return; setDeleting(true); try { - await deleteReport(reportToDelete.id); + // If this is cloud data, delete from cloud + const cloudId = (reportToDelete as any).cloudId; + if (isCloudData && cloudId) { + await deleteCloudReport(cloudId); + } else if (reportToDelete.id) { + // Delete from local IndexedDB + await deleteReport(reportToDelete.id); + } + // Refresh reports and counts await loadReports(); await loadCounts(); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 7f300af2..f11d3a71 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -5,6 +5,7 @@ import { Header } from "@/components/layout/Header"; import { Footer } from "@/components/layout/Footer"; import { AnalysisProvider } from "@/context/AnalysisContext"; import { ThemeProvider } from "@/components/theme/ThemeProvider"; +import { AuthProvider } from "@/contexts/auth-context"; const inter = Inter({ subsets: ["latin"] }); @@ -22,13 +23,15 @@ export default function RootLayout({ - -
-
-
{children}
-
-
-
+ + +
+
+
{children}
+
+
+
+
diff --git a/frontend/components/auth/login-button.tsx b/frontend/components/auth/login-button.tsx new file mode 100644 index 00000000..ca6a60fe --- /dev/null +++ b/frontend/components/auth/login-button.tsx @@ -0,0 +1,128 @@ +/** + * Google Login Button Component + */ +"use client"; + +import { useAuth } from "@/contexts/auth-context"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { LogOut, User, Cloud, CloudOff } from "lucide-react"; + +// Google Icon SVG +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +export function LoginButton() { + const { user, isLoading, isAuthenticated, login, logout } = useAuth(); + + if (isLoading) { + return ( + + ); + } + + if (isAuthenticated && user) { + return ( + + + + + +
+

{user.name}

+

{user.email}

+
+ + + + 雲端同步已啟用 + + + + + 登出 + +
+
+ ); + } + + return ( + + ); +} + +/** + * Full-width login prompt for pages that benefit from login + */ +export function LoginPrompt() { + const { login, isAuthenticated } = useAuth(); + + if (isAuthenticated) return null; + + return ( +
+
+
+ +
+

目前使用本地儲存

+

+ 登入 Google 帳號以同步 API 設定和歷史報告 +

+
+
+ +
+
+ ); +} diff --git a/frontend/components/layout/Header.tsx b/frontend/components/layout/Header.tsx index 997ba9b2..e76f6655 100644 --- a/frontend/components/layout/Header.tsx +++ b/frontend/components/layout/Header.tsx @@ -1,9 +1,12 @@ /** * Header component */ +"use client"; + import Link from "next/link"; import { ThemeToggle } from "@/components/theme/ThemeToggle"; import { ApiSettingsDialog } from "@/components/settings/ApiSettingsDialog"; +import { LoginButton } from "@/components/auth/login-button"; export function Header() { return ( @@ -37,6 +40,7 @@ export function Header() { +
diff --git a/frontend/components/settings/ApiSettingsDialog.tsx b/frontend/components/settings/ApiSettingsDialog.tsx index a0424935..6c466dce 100644 --- a/frontend/components/settings/ApiSettingsDialog.tsx +++ b/frontend/components/settings/ApiSettingsDialog.tsx @@ -4,7 +4,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Settings } from "lucide-react"; +import { Settings, Cloud, CloudOff } from "lucide-react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -36,6 +36,8 @@ import { type ApiSettings, DEFAULT_API_SETTINGS, } from "@/lib/storage"; +import { useAuth } from "@/contexts/auth-context"; +import { getCloudSettings, saveCloudSettings, isCloudSyncEnabled } from "@/lib/user-api"; const formSchema = z.object({ // Required @@ -61,6 +63,8 @@ export function ApiSettingsDialog() { const [open, setOpen] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const [loading, setLoading] = useState(false); + const [syncStatus, setSyncStatus] = useState<"local" | "cloud" | "syncing">("local"); + const { isAuthenticated } = useAuth(); const form = useForm({ resolver: zodResolver(formSchema), @@ -73,25 +77,51 @@ export function ApiSettingsDialog() { setLoading(true); setSaveSuccess(false); - // First try to migrate legacy settings - migrateToEncrypted().then(() => { - // Then load decrypted settings - return getApiSettingsAsync(); - }).then((settings) => { - form.reset(settings); - }).catch((error) => { - console.error("Failed to load settings:", error); - }).finally(() => { - setLoading(false); - }); + const loadSettings = async () => { + try { + // First try to migrate legacy settings + await migrateToEncrypted(); + + // If authenticated, try to load from cloud first + if (isAuthenticated && isCloudSyncEnabled()) { + setSyncStatus("syncing"); + const cloudSettings = await getCloudSettings(); + if (cloudSettings) { + form.reset(cloudSettings); + setSyncStatus("cloud"); + return; + } + } + + // Fall back to local storage + const localSettings = await getApiSettingsAsync(); + form.reset(localSettings); + setSyncStatus(isAuthenticated ? "cloud" : "local"); + } catch (error) { + console.error("Failed to load settings:", error); + setSyncStatus("local"); + } finally { + setLoading(false); + } + }; + + loadSettings(); } - }, [open, form]); + }, [open, form, isAuthenticated]); const onSubmit = async (values: FormValues) => { setLoading(true); try { - // Encrypt and save settings + // Encrypt and save settings locally await saveApiSettingsAsync(values as ApiSettings); + + // If authenticated, also save to cloud + if (isAuthenticated && isCloudSyncEnabled()) { + setSyncStatus("syncing"); + const cloudSaved = await saveCloudSettings(values as ApiSettings); + setSyncStatus(cloudSaved ? "cloud" : "local"); + } + setSaveSuccess(true); setTimeout(() => { setSaveSuccess(false); diff --git a/frontend/contexts/auth-context.tsx b/frontend/contexts/auth-context.tsx new file mode 100644 index 00000000..bcbd8a28 --- /dev/null +++ b/frontend/contexts/auth-context.tsx @@ -0,0 +1,189 @@ +/** + * Authentication Context + * Manages user login state and provides auth utilities + */ +"use client"; + +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; + +// User interface +export interface User { + id: string; + email: string; + name: string | null; + avatar_url: string | null; +} + +// Auth context interface +interface AuthContextType { + user: User | null; + token: string | null; + isLoading: boolean; + isAuthenticated: boolean; + login: () => void; + logout: () => void; + setAuthFromCallback: (token: string) => void; +} + +// Create context +const AuthContext = createContext(undefined); + +// Token storage key +const TOKEN_KEY = "tradingagents_auth_token"; + +// Google OAuth client ID from environment +const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""; + +// Backend URL +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || ""; + +/** + * Auth Provider Component + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Parse JWT token to get user info + const parseToken = useCallback((token: string): User | null => { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + return { + id: payload.sub, + email: payload.email, + name: payload.name, + avatar_url: payload.avatar_url, + }; + } catch { + return null; + } + }, []); + + // Check if token is expired + const isTokenExpired = useCallback((token: string): boolean => { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const exp = payload.exp * 1000; // Convert to milliseconds + return Date.now() > exp; + } catch { + return true; + } + }, []); + + // Initialize auth state from localStorage + useEffect(() => { + const initAuth = () => { + if (typeof window === "undefined") { + setIsLoading(false); + return; + } + + const storedToken = localStorage.getItem(TOKEN_KEY); + + if (storedToken && !isTokenExpired(storedToken)) { + const userData = parseToken(storedToken); + if (userData) { + setToken(storedToken); + setUser(userData); + } else { + localStorage.removeItem(TOKEN_KEY); + } + } else if (storedToken) { + // Token expired, remove it + localStorage.removeItem(TOKEN_KEY); + } + + setIsLoading(false); + }; + + initAuth(); + }, [parseToken, isTokenExpired]); + + // Login - redirect to Google OAuth + const login = useCallback(() => { + if (!GOOGLE_CLIENT_ID) { + console.error("Google Client ID not configured"); + alert("Google 登入尚未設定。請聯繫管理員。"); + return; + } + + // Build Google OAuth URL + const redirectUri = `${window.location.origin}/api/auth/callback/google`; + const scope = "openid email profile"; + + const params = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: redirectUri, + response_type: "code", + scope: scope, + access_type: "offline", + prompt: "consent", + }); + + window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + }, []); + + // Logout + const logout = useCallback(() => { + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + }, []); + + // Set auth from callback (after OAuth redirect) + const setAuthFromCallback = useCallback((newToken: string) => { + const userData = parseToken(newToken); + if (userData) { + localStorage.setItem(TOKEN_KEY, newToken); + setToken(newToken); + setUser(userData); + } + }, [parseToken]); + + const value: AuthContextType = { + user, + token, + isLoading, + isAuthenticated: !!user, + login, + logout, + setAuthFromCallback, + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use auth context + */ +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +/** + * Get auth token for API requests + */ +export function getAuthToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +/** + * Get auth headers for API requests + */ +export function getAuthHeaders(): Record { + const token = getAuthToken(); + if (token) { + return { Authorization: `Bearer ${token}` }; + } + return {}; +} diff --git a/frontend/lib/user-api.ts b/frontend/lib/user-api.ts new file mode 100644 index 00000000..986338bb --- /dev/null +++ b/frontend/lib/user-api.ts @@ -0,0 +1,169 @@ +/** + * User API service for cloud sync + * Handles settings and reports sync with backend when logged in + */ + +import { getAuthHeaders, getAuthToken } from "@/contexts/auth-context"; +import type { ApiSettings } from "./storage"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; + +interface CloudReport { + id: string; + ticker: string; + market_type: "us" | "twse" | "tpex"; + analysis_date: string; + result: any; + created_at: string; +} + +/** + * Check if user is authenticated + */ +export function isCloudSyncEnabled(): boolean { + return !!getAuthToken(); +} + +/** + * Fetch user settings from cloud + */ +export async function getCloudSettings(): Promise { + if (!isCloudSyncEnabled()) return null; + + try { + const response = await fetch(`${API_BASE}/api/user/settings`, { + headers: getAuthHeaders(), + }); + + if (!response.ok) { + if (response.status === 401) return null; + throw new Error("Failed to fetch settings"); + } + + const data = await response.json(); + return data.settings || null; + } catch (error) { + console.error("Failed to fetch cloud settings:", error); + return null; + } +} + +/** + * Save user settings to cloud + */ +export async function saveCloudSettings(settings: ApiSettings): Promise { + if (!isCloudSyncEnabled()) return false; + + try { + const response = await fetch(`${API_BASE}/api/user/settings`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify(settings), + }); + + return response.ok; + } catch (error) { + console.error("Failed to save cloud settings:", error); + return false; + } +} + +/** + * Fetch all reports from cloud + */ +export async function getCloudReports(): Promise { + if (!isCloudSyncEnabled()) return []; + + try { + const response = await fetch(`${API_BASE}/api/user/reports`, { + headers: getAuthHeaders(), + }); + + if (!response.ok) { + if (response.status === 401) return []; + throw new Error("Failed to fetch reports"); + } + + return await response.json(); + } catch (error) { + console.error("Failed to fetch cloud reports:", error); + return []; + } +} + +/** + * Save a report to cloud + */ +export async function saveCloudReport(report: { + ticker: string; + market_type: "us" | "twse" | "tpex"; + analysis_date: string; + result: any; +}): Promise { + if (!isCloudSyncEnabled()) return null; + + try { + const response = await fetch(`${API_BASE}/api/user/reports`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify(report), + }); + + if (!response.ok) { + throw new Error("Failed to save report"); + } + + const data = await response.json(); + return data.report_id; + } catch (error) { + console.error("Failed to save cloud report:", error); + return null; + } +} + +/** + * Delete a report from cloud + */ +export async function deleteCloudReport(reportId: string): Promise { + if (!isCloudSyncEnabled()) return false; + + try { + const response = await fetch(`${API_BASE}/api/user/reports/${reportId}`, { + method: "DELETE", + headers: getAuthHeaders(), + }); + + return response.ok; + } catch (error) { + console.error("Failed to delete cloud report:", error); + return false; + } +} + +/** + * Get a single report by ID from cloud + */ +export async function getCloudReportById(reportId: string): Promise { + if (!isCloudSyncEnabled()) return null; + + try { + const response = await fetch(`${API_BASE}/api/user/reports/${reportId}`, { + headers: getAuthHeaders(), + }); + + if (!response.ok) { + return null; + } + + return await response.json(); + } catch (error) { + console.error("Failed to fetch cloud report:", error); + return null; + } +}