This commit is contained in:
MarkLo 2025-12-13 06:00:19 +08:00
parent eed052abe9
commit 5d3751602e
19 changed files with 1696 additions and 31 deletions

275
backend/app/api/auth.py Normal file
View File

@ -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"),
}
}

267
backend/app/api/user.py Normal file
View File

@ -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"}

View File

@ -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",
]

View File

@ -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

102
backend/app/db/models.py Normal file
View File

@ -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")

View File

@ -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("/")

View File

@ -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

View File

@ -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

View File

@ -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<string | null>(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("儲存失敗,請稍後再試");

View File

@ -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)
);
}

View File

@ -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 }
);
}
}

View File

@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center p-8 max-w-md">
<div className="text-red-500 text-5xl mb-4"></div>
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-muted-foreground mb-4">{error}</p>
<button
onClick={() => router.replace("/")}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90"
>
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-lg font-medium">...</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
);
}
function LoadingFallback() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-lg font-medium">...</p>
</div>
</div>
);
}
export default function AuthCallbackPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<AuthCallbackContent />
</Suspense>
);
}

View File

@ -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<SavedReport[]>([]);
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();

View File

@ -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({
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider>
<AnalysisProvider>
<div className="flex flex-col min-h-screen gradient-page-bg">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</AnalysisProvider>
<AuthProvider>
<AnalysisProvider>
<div className="flex flex-col min-h-screen gradient-page-bg">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</AnalysisProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>

View File

@ -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 (
<svg className={className} viewBox="0 0 24 24" width="18" height="18">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
export function LoginButton() {
const { user, isLoading, isAuthenticated, login, logout } = useAuth();
if (isLoading) {
return (
<Button variant="outline" size="sm" disabled>
<div className="w-4 h-4 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
</Button>
);
}
if (isAuthenticated && user) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.name || "User"}
className="w-5 h-5 rounded-full"
/>
) : (
<User className="w-4 h-4" />
)}
<span className="hidden sm:inline max-w-[100px] truncate">
{user.name || user.email}
</span>
<Cloud className="w-3 h-3 text-green-500" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-1.5">
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-green-600">
<Cloud className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-red-600">
<LogOut className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<Button variant="outline" size="sm" onClick={login} className="gap-2">
<GoogleIcon />
<span className="hidden sm:inline"></span>
<CloudOff className="w-3 h-3 text-gray-400 sm:hidden" />
</Button>
);
}
/**
* Full-width login prompt for pages that benefit from login
*/
export function LoginPrompt() {
const { login, isAuthenticated } = useAuth();
if (isAuthenticated) return null;
return (
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-950/30 dark:to-purple-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<CloudOff className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium">使</p>
<p className="text-xs text-muted-foreground">
Google API
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={login} className="gap-2 shrink-0">
<GoogleIcon />
</Button>
</div>
</div>
);
}

View File

@ -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() {
</Link>
<ApiSettingsDialog />
<ThemeToggle />
<LoginButton />
</nav>
</div>
</div>

View File

@ -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<FormValues>({
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);

View File

@ -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<AuthContextType | undefined>(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<User | null>(null);
const [token, setToken] = useState<string | null>(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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* 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<string, string> {
const token = getAuthToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return {};
}

169
frontend/lib/user-api.ts Normal file
View File

@ -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<ApiSettings | null> {
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<boolean> {
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<CloudReport[]> {
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<string | null> {
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<boolean> {
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<CloudReport | null> {
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;
}
}