This commit is contained in:
parent
eed052abe9
commit
5d3751602e
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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("/")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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("儲存失敗,請稍後再試");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue