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.config import settings
|
||||||
from backend.app.core.cors import setup_cors
|
from backend.app.core.cors import setup_cors
|
||||||
from backend.app.api.routes import router
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -139,6 +141,26 @@ setup_cors(app)
|
||||||
|
|
||||||
# Include API routes
|
# Include API routes
|
||||||
app.include_router(router)
|
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("/")
|
@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
|
# Environment and configuration
|
||||||
python-dotenv==1.0.0
|
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
|
# Existing TradingAgentsX dependencies
|
||||||
typing-extensions
|
typing-extensions
|
||||||
langchain-openai
|
langchain-openai
|
||||||
|
|
@ -45,3 +55,4 @@ markdown>=3.5.0
|
||||||
|
|
||||||
# Toon format for token optimization
|
# Toon format for token optimization
|
||||||
git+https://github.com/toon-format/toon-python.git
|
git+https://github.com/toon-format/toon-python.git
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@ import { useRouter } from "next/navigation";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { useAnalysisContext } from "@/context/AnalysisContext";
|
import { useAnalysisContext } from "@/context/AnalysisContext";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { PriceChart } from "@/components/analysis/PriceChart";
|
import { PriceChart } from "@/components/analysis/PriceChart";
|
||||||
import { DownloadReports } from "@/components/analysis/DownloadReports";
|
import { DownloadReports } from "@/components/analysis/DownloadReports";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
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 { saveReport, checkDuplicateReport } from "@/lib/reports-db";
|
||||||
|
import { saveCloudReport, isCloudSyncEnabled } from "@/lib/user-api";
|
||||||
|
|
||||||
const ANALYSTS = [
|
const ANALYSTS = [
|
||||||
// === 分析師團隊 ===
|
// === 分析師團隊 ===
|
||||||
|
|
@ -103,12 +105,14 @@ const getNestedValue = (obj: any, path: string) => {
|
||||||
export default function AnalysisResultsPage() {
|
export default function AnalysisResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { analysisResult, taskId, marketType } = useAnalysisContext();
|
const { analysisResult, taskId, marketType } = useAnalysisContext();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const [selectedAnalyst, setSelectedAnalyst] = useState("market");
|
const [selectedAnalyst, setSelectedAnalyst] = useState("market");
|
||||||
|
|
||||||
// Save report states
|
// Save report states
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [savedToCloud, setSavedToCloud] = useState(false);
|
||||||
|
|
||||||
// 如果沒有結果,重定向到分析頁面
|
// 如果沒有結果,重定向到分析頁面
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -124,9 +128,10 @@ export default function AnalysisResultsPage() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setSaveSuccess(false);
|
setSaveSuccess(false);
|
||||||
|
setSavedToCloud(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for duplicate
|
// Check for duplicate in local storage
|
||||||
const duplicate = await checkDuplicateReport(
|
const duplicate = await checkDuplicateReport(
|
||||||
analysisResult.ticker,
|
analysisResult.ticker,
|
||||||
analysisResult.analysis_date
|
analysisResult.analysis_date
|
||||||
|
|
@ -138,6 +143,7 @@ export default function AnalysisResultsPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to local IndexedDB
|
||||||
await saveReport(
|
await saveReport(
|
||||||
analysisResult.ticker,
|
analysisResult.ticker,
|
||||||
marketType,
|
marketType,
|
||||||
|
|
@ -146,9 +152,25 @@ export default function AnalysisResultsPage() {
|
||||||
taskId || undefined
|
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);
|
setSaveSuccess(true);
|
||||||
// Reset success message after 3 seconds
|
// Reset success message after 3 seconds
|
||||||
setTimeout(() => setSaveSuccess(false), 3000);
|
setTimeout(() => {
|
||||||
|
setSaveSuccess(false);
|
||||||
|
setSavedToCloud(false);
|
||||||
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Save report error:", error);
|
console.error("Save report error:", error);
|
||||||
setSaveError("儲存失敗,請稍後再試");
|
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 { format } from "date-fns";
|
||||||
import { zhTW } from "date-fns/locale";
|
import { zhTW } from "date-fns/locale";
|
||||||
import { useAnalysisContext } from "@/context/AnalysisContext";
|
import { useAnalysisContext } from "@/context/AnalysisContext";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,13 +27,15 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Trash2, Eye, RefreshCw, TrendingUp } from "lucide-react";
|
import { Trash2, Eye, RefreshCw, TrendingUp, Cloud, CloudOff } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getReportsByMarketType,
|
getReportsByMarketType,
|
||||||
deleteReport,
|
deleteReport,
|
||||||
getReportCountByMarketType,
|
getReportCountByMarketType,
|
||||||
type SavedReport,
|
type SavedReport,
|
||||||
} from "@/lib/reports-db";
|
} from "@/lib/reports-db";
|
||||||
|
import { getCloudReports, deleteCloudReport, isCloudSyncEnabled } from "@/lib/user-api";
|
||||||
|
import { LoginPrompt } from "@/components/auth/login-button";
|
||||||
|
|
||||||
// Market type labels
|
// Market type labels
|
||||||
const MARKET_LABELS = {
|
const MARKET_LABELS = {
|
||||||
|
|
@ -104,11 +107,13 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
|
||||||
export default function HistoryPage() {
|
export default function HistoryPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext();
|
const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us");
|
const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us");
|
||||||
const [reports, setReports] = useState<SavedReport[]>([]);
|
const [reports, setReports] = useState<SavedReport[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 });
|
const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 });
|
||||||
|
const [isCloudData, setIsCloudData] = useState(false);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
@ -117,23 +122,51 @@ export default function HistoryPage() {
|
||||||
);
|
);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// Load reports when tab changes
|
// Load reports when tab changes or auth state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadReports();
|
loadReports();
|
||||||
}, [activeTab]);
|
}, [activeTab, isAuthenticated]);
|
||||||
|
|
||||||
// Load counts on mount
|
// Load counts on mount or auth change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCounts();
|
loadCounts();
|
||||||
}, []);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const loadReports = async () => {
|
const loadReports = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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);
|
const data = await getReportsByMarketType(activeTab);
|
||||||
setReports(data);
|
setReports(data);
|
||||||
|
setIsCloudData(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load reports:", error);
|
console.error("Failed to load reports:", error);
|
||||||
|
// Fall back to local on error
|
||||||
|
const data = await getReportsByMarketType(activeTab);
|
||||||
|
setReports(data);
|
||||||
|
setIsCloudData(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +174,19 @@ export default function HistoryPage() {
|
||||||
|
|
||||||
const loadCounts = async () => {
|
const loadCounts = async () => {
|
||||||
try {
|
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();
|
const data = await getReportCountByMarketType();
|
||||||
setCounts(data);
|
setCounts(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -163,11 +209,19 @@ export default function HistoryPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!reportToDelete?.id) return;
|
if (!reportToDelete) return;
|
||||||
|
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
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
|
// Refresh reports and counts
|
||||||
await loadReports();
|
await loadReports();
|
||||||
await loadCounts();
|
await loadCounts();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Header } from "@/components/layout/Header";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { AnalysisProvider } from "@/context/AnalysisContext";
|
import { AnalysisProvider } from "@/context/AnalysisContext";
|
||||||
import { ThemeProvider } from "@/components/theme/ThemeProvider";
|
import { ThemeProvider } from "@/components/theme/ThemeProvider";
|
||||||
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
|
@ -22,13 +23,15 @@ export default function RootLayout({
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AnalysisProvider>
|
<AuthProvider>
|
||||||
<div className="flex flex-col min-h-screen gradient-page-bg">
|
<AnalysisProvider>
|
||||||
<Header />
|
<div className="flex flex-col min-h-screen gradient-page-bg">
|
||||||
<main className="flex-1">{children}</main>
|
<Header />
|
||||||
<Footer />
|
<main className="flex-1">{children}</main>
|
||||||
</div>
|
<Footer />
|
||||||
</AnalysisProvider>
|
</div>
|
||||||
|
</AnalysisProvider>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
* Header component
|
||||||
*/
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ThemeToggle } from "@/components/theme/ThemeToggle";
|
import { ThemeToggle } from "@/components/theme/ThemeToggle";
|
||||||
import { ApiSettingsDialog } from "@/components/settings/ApiSettingsDialog";
|
import { ApiSettingsDialog } from "@/components/settings/ApiSettingsDialog";
|
||||||
|
import { LoginButton } from "@/components/auth/login-button";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -37,6 +40,7 @@ export function Header() {
|
||||||
</Link>
|
</Link>
|
||||||
<ApiSettingsDialog />
|
<ApiSettingsDialog />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<LoginButton />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings, Cloud, CloudOff } from "lucide-react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
@ -36,6 +36,8 @@ import {
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
DEFAULT_API_SETTINGS,
|
DEFAULT_API_SETTINGS,
|
||||||
} from "@/lib/storage";
|
} from "@/lib/storage";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { getCloudSettings, saveCloudSettings, isCloudSyncEnabled } from "@/lib/user-api";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
// Required
|
// Required
|
||||||
|
|
@ -61,6 +63,8 @@ export function ApiSettingsDialog() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<"local" | "cloud" | "syncing">("local");
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
|
@ -73,25 +77,51 @@ export function ApiSettingsDialog() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSaveSuccess(false);
|
setSaveSuccess(false);
|
||||||
|
|
||||||
// First try to migrate legacy settings
|
const loadSettings = async () => {
|
||||||
migrateToEncrypted().then(() => {
|
try {
|
||||||
// Then load decrypted settings
|
// First try to migrate legacy settings
|
||||||
return getApiSettingsAsync();
|
await migrateToEncrypted();
|
||||||
}).then((settings) => {
|
|
||||||
form.reset(settings);
|
// If authenticated, try to load from cloud first
|
||||||
}).catch((error) => {
|
if (isAuthenticated && isCloudSyncEnabled()) {
|
||||||
console.error("Failed to load settings:", error);
|
setSyncStatus("syncing");
|
||||||
}).finally(() => {
|
const cloudSettings = await getCloudSettings();
|
||||||
setLoading(false);
|
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) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Encrypt and save settings
|
// Encrypt and save settings locally
|
||||||
await saveApiSettingsAsync(values as ApiSettings);
|
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);
|
setSaveSuccess(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSaveSuccess(false);
|
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