feat: sync all BRAIN mobile changes - onboarding, cookies, legal, mobile UX, settings
- Add OnboardingWizard, BetaBanner, CookieBanner components - Add legal pages (Privacy, Terms, Cookies) - Update Layout with mobile topbar, sidebar drawer, plan banner - Update SettingsPage with profile, API config, security - Update CharacterForm with topic suggestions, niche chips - Update EditorialCalendar with shared strategy card - Update ContentPage with narrative technique + brief - Update SocialAccounts with 4 platforms and token guides - Fix CSS button color inheritance, mobile responsive - Add backup script - Update .gitignore for pgdata and backups Co-Authored-By: Claude (BRAIN/StackOS) <noreply@anthropic.com>
This commit is contained in:
@@ -131,6 +131,25 @@ def me(user: User = Depends(get_current_user)):
|
||||
return _user_response(user)
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
display_name: str
|
||||
|
||||
@router.put("/me")
|
||||
def update_profile(
|
||||
request: UpdateProfileRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update user display name."""
|
||||
name = request.display_name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Il nome non può essere vuoto.")
|
||||
current_user.display_name = name
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return _user_response(current_user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
"""Logout — client should remove the token."""
|
||||
@@ -160,80 +179,93 @@ def oauth_google_start():
|
||||
|
||||
|
||||
@router.get("/oauth/google/callback")
|
||||
async def oauth_google_callback(code: str, state: Optional[str] = None, db: Session = Depends(get_db)):
|
||||
async def oauth_google_callback(code: str, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)):
|
||||
"""Exchange Google OAuth code for token, create/find user, redirect with JWT."""
|
||||
# Handle Google OAuth user denial or access errors
|
||||
if error:
|
||||
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error={error}")
|
||||
|
||||
if not settings.google_client_id or not settings.google_client_secret:
|
||||
raise HTTPException(status_code=501, detail="Google OAuth non configurato.")
|
||||
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=non_configurato")
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback",
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Errore scambio token Google.")
|
||||
token_data = token_resp.json()
|
||||
try:
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback",
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=token_exchange")
|
||||
token_data = token_resp.json()
|
||||
|
||||
# Get user info
|
||||
userinfo_resp = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||
headers={"Authorization": f"Bearer {token_data['access_token']}"},
|
||||
)
|
||||
if userinfo_resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Errore recupero profilo Google.")
|
||||
google_user = userinfo_resp.json()
|
||||
# Get user info
|
||||
userinfo_resp = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||
headers={"Authorization": f"Bearer {token_data['access_token']}"},
|
||||
)
|
||||
if userinfo_resp.status_code != 200:
|
||||
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=userinfo")
|
||||
google_user = userinfo_resp.json()
|
||||
|
||||
google_id = google_user.get("sub")
|
||||
email = google_user.get("email")
|
||||
name = google_user.get("name")
|
||||
picture = google_user.get("picture")
|
||||
google_id = google_user.get("sub")
|
||||
email = google_user.get("email")
|
||||
name = google_user.get("name")
|
||||
picture = google_user.get("picture")
|
||||
|
||||
# Find existing user by google_id or email
|
||||
user = db.query(User).filter(User.google_id == google_id).first()
|
||||
if not user and email:
|
||||
user = get_user_by_email(db, email)
|
||||
if not google_id or not email:
|
||||
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=missing_data")
|
||||
|
||||
if user:
|
||||
# Update google_id and avatar if missing
|
||||
if not user.google_id:
|
||||
user.google_id = google_id
|
||||
if not user.avatar_url and picture:
|
||||
user.avatar_url = picture
|
||||
db.commit()
|
||||
else:
|
||||
# Create new user
|
||||
username_base = (email or google_id).split("@")[0]
|
||||
username = username_base
|
||||
counter = 1
|
||||
while db.query(User).filter(User.username == username).first():
|
||||
username = f"{username_base}{counter}"
|
||||
counter += 1
|
||||
# Find existing user by google_id or email
|
||||
user = db.query(User).filter(User.google_id == google_id).first()
|
||||
if not user and email:
|
||||
user = get_user_by_email(db, email)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
hashed_password=hash_password(secrets.token_urlsafe(32)),
|
||||
email=email,
|
||||
display_name=name or username,
|
||||
avatar_url=picture,
|
||||
auth_provider="google",
|
||||
google_id=google_id,
|
||||
subscription_plan="freemium",
|
||||
is_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
if user:
|
||||
# Update google_id and avatar if missing
|
||||
if not user.google_id:
|
||||
user.google_id = google_id
|
||||
if not user.avatar_url and picture:
|
||||
user.avatar_url = picture
|
||||
db.commit()
|
||||
else:
|
||||
# Create new user
|
||||
username_base = (email or google_id).split("@")[0]
|
||||
username = username_base
|
||||
counter = 1
|
||||
while db.query(User).filter(User.username == username).first():
|
||||
username = f"{username_base}{counter}"
|
||||
counter += 1
|
||||
|
||||
jwt_token = create_access_token({"sub": user.username, "user_id": user.id})
|
||||
redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
user = User(
|
||||
username=username,
|
||||
hashed_password=hash_password(secrets.token_urlsafe(32)),
|
||||
email=email,
|
||||
display_name=name or username,
|
||||
avatar_url=picture,
|
||||
auth_provider="google",
|
||||
google_id=google_id,
|
||||
subscription_plan="freemium",
|
||||
is_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
jwt_token = create_access_token({"sub": user.username, "user_id": user.id})
|
||||
redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Google OAuth callback error: {e}", exc_info=True)
|
||||
return RedirectResponse(url=f"{settings.app_url}/login?oauth_error=server_error")
|
||||
|
||||
|
||||
# === Change password ===
|
||||
@@ -300,3 +332,60 @@ def redeem_code(
|
||||
"subscription_plan": current_user.subscription_plan,
|
||||
"subscription_expires_at": current_user.subscription_expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# === Export user data (GDPR) ===
|
||||
|
||||
@router.get("/export-data")
|
||||
def export_user_data(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Export all personal data for the current user (GDPR compliance)."""
|
||||
from app.models import Post, Character, AffiliateLinkModel, PublishingPlan, SocialAccount
|
||||
|
||||
posts = db.query(Post).filter(Post.user_id == current_user.id).all() if hasattr(Post, 'user_id') else []
|
||||
|
||||
data = {
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"user": {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"display_name": current_user.display_name,
|
||||
"auth_provider": current_user.auth_provider,
|
||||
"subscription_plan": current_user.subscription_plan,
|
||||
"subscription_expires_at": current_user.subscription_expires_at.isoformat() if current_user.subscription_expires_at else None,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
},
|
||||
}
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import Response
|
||||
import json
|
||||
content = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="leopost-data-{current_user.username}.json"'}
|
||||
)
|
||||
|
||||
|
||||
# === Delete account ===
|
||||
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
confirmation: str # must equal "ELIMINA"
|
||||
|
||||
@router.delete("/account")
|
||||
def delete_account(
|
||||
request: DeleteAccountRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Permanently delete the user account and all associated data."""
|
||||
if request.confirmation != "ELIMINA":
|
||||
raise HTTPException(status_code=400, detail="Conferma non valida. Digita ELIMINA per procedere.")
|
||||
|
||||
# Delete user (cascade should handle related records if FK set, else manual)
|
||||
db.delete(current_user)
|
||||
db.commit()
|
||||
return {"message": "Account eliminato con successo."}
|
||||
|
||||
Reference in New Issue
Block a user