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:
Michele Borraccia
2026-04-03 14:59:14 +00:00
parent 8b77f1b86b
commit 2ca8b957e9
29 changed files with 4074 additions and 2803 deletions

View File

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

View File

@@ -96,12 +96,12 @@ def generate_content(
text = generate_post_text(
character=char_dict,
llm_provider=llm,
platform=request.platform,
platform=request.effective_platform,
topic_hint=request.topic_hint,
)
# Generate hashtags
hashtags = generate_hashtags(text, llm, request.platform)
hashtags = generate_hashtags(text, llm, request.effective_platform)
# Handle affiliate links
affiliate_links_used: list[dict] = []

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel
@@ -103,13 +103,23 @@ class PostResponse(BaseModel):
class GenerateContentRequest(BaseModel):
character_id: int
platform: str = "instagram"
content_type: str = "text"
platform: str = "instagram" # legacy single-platform (kept for compat)
content_type: str = "text" # legacy single type (kept for compat)
platforms: List[str] = [] # new: multi-platform (overrides platform if non-empty)
content_types: List[str] = [] # new: multi-type (overrides content_type if non-empty)
topic_hint: Optional[str] = None
include_affiliates: bool = True
provider: Optional[str] = None # override default LLM
provider: Optional[str] = None
model: Optional[str] = None
@property
def effective_platform(self) -> str:
return self.platforms[0] if self.platforms else self.platform
@property
def effective_content_type(self) -> str:
return self.content_types[0] if self.content_types else self.content_type
class GenerateImageRequest(BaseModel):
character_id: int