From e668e3fd20a00195431b24489576afa1d8cf6985 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Wed, 15 Apr 2026 11:43:56 +0200 Subject: [PATCH] update setting page --- ...e04100e88ace_avatar_url_varchar_to_text.py | 34 ++++ app/api/middleware/auth.py | 4 +- app/api/routes/auth.py | 172 ++++++++++++++++++ app/api/routes/billing.py | 13 ++ app/billing/stripe_service.py | 39 ++++ app/config/settings.py | 8 +- app/models.py | 2 +- app/schemas.py | 7 + 8 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/e04100e88ace_avatar_url_varchar_to_text.py diff --git a/alembic/versions/e04100e88ace_avatar_url_varchar_to_text.py b/alembic/versions/e04100e88ace_avatar_url_varchar_to_text.py new file mode 100644 index 0000000..0a1421c --- /dev/null +++ b/alembic/versions/e04100e88ace_avatar_url_varchar_to_text.py @@ -0,0 +1,34 @@ +"""avatar_url_varchar_to_text + +Revision ID: e04100e88ace +Revises: c5d1e2f3a4b5 +Create Date: 2026-04-13 09:13:06.733674 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e04100e88ace' +down_revision: Union[str, None] = 'c5d1e2f3a4b5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column('users', 'avatar_url', + existing_type=sa.VARCHAR(length=2048), + type_=sa.Text(), + existing_nullable=True) + + +def downgrade() -> None: + op.alter_column('users', 'avatar_url', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=2048), + existing_nullable=True) diff --git a/app/api/middleware/auth.py b/app/api/middleware/auth.py index ccea249..3c92471 100644 --- a/app/api/middleware/auth.py +++ b/app/api/middleware/auth.py @@ -65,10 +65,11 @@ async def get_current_user( default_tier = "power" if settings.ENV == "dev" else "free" tier: str = result.scalar_one_or_none() or default_tier - # Fetch name/surname/avatar_url/onboarding_completed_at from user row. + # Fetch name/surname/avatar_url/onboarding_completed_at/password_hash from user row. user_result = await db.execute( select( User.name, User.surname, User.avatar_url, User.onboarding_completed_at, + User.password_hash, ).where(User.id == user_id) ) user_row = user_result.one_or_none() @@ -95,6 +96,7 @@ async def get_current_user( name=user_row.name if user_row else None, surname=user_row.surname if user_row else None, avatar_url=user_row.avatar_url if user_row else None, + has_password=bool(user_row.password_hash) if user_row else False, tier=tier, onboarding_completed_at=onboarding_ms, memory=memory_dict, diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index 65bdfd9..73a8d67 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -519,6 +519,7 @@ async def _build_profile(user_id: str, email: str, db: AsyncSession) -> UserProf user_result = await db.execute( select( User.name, User.surname, User.avatar_url, User.onboarding_completed_at, + User.password_hash, ).where(User.id == user_id) ) user_row = user_result.one_or_none() @@ -541,6 +542,7 @@ async def _build_profile(user_id: str, email: str, db: AsyncSession) -> UserProf name=user_row.name if user_row else None, surname=user_row.surname if user_row else None, avatar_url=user_row.avatar_url if user_row else None, + has_password=bool(user_row.password_hash) if user_row else False, tier=tier, onboarding_completed_at=onboarding_ms, memory=memory_dict, @@ -621,3 +623,173 @@ async def normalize_onboarding( except Exception: # LLM failure must never block onboarding — return inputs unchanged return _NormalizeResponse(normalized=body.inputs) + + +# ── Password management ─────────────────────────────────────────────── + + +class _ChangePasswordRequest(BaseModel): + current_password: str = Field(min_length=1) + new_password: str = Field(min_length=8) + + +@router.put("/me/password", status_code=status.HTTP_200_OK) +async def change_password( + body: _ChangePasswordRequest, + current_user: UserProfile = Depends(get_current_user), + db: AsyncSession = Depends(get_session), +) -> dict[str, bool]: + """Change the authenticated user's password. + + Requires the current password for verification. + Returns 400 for social-only users (no password set). + """ + result = await db.execute(select(User).where(User.id == current_user.id)) + user = result.scalar_one() + + if user.password_hash is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "This account uses social login and has no password to change", + ) + + if not _verify_password(body.current_password, user.password_hash): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Current password is incorrect") + + user.password_hash = _hash_password(body.new_password) + await db.commit() + return {"ok": True} + + +# ── OAuth account management ───────────────────────────────────────── + + +@router.get("/me/oauth-accounts", response_model=list[dict]) +async def list_oauth_accounts( + current_user: UserProfile = Depends(get_current_user), + db: AsyncSession = Depends(get_session), +) -> list[dict]: + """List all OAuth providers linked to the authenticated user.""" + result = await db.execute( + select(OAuthAccount).where(OAuthAccount.user_id == current_user.id) + ) + accounts = result.scalars().all() + return [ + { + "provider": a.provider, + "provider_email": a.provider_email, + "created_at": int(a.created_at.timestamp() * 1000), + } + for a in accounts + ] + + +@router.delete("/me/oauth-accounts/{provider}", status_code=status.HTTP_200_OK) +async def unlink_oauth_account( + provider: str, + current_user: UserProfile = Depends(get_current_user), + db: AsyncSession = Depends(get_session), +) -> dict[str, bool]: + """Unlink an OAuth provider from the authenticated user. + + Refuses if the user has no password and this is their only login method. + """ + result = await db.execute(select(User).where(User.id == current_user.id)) + user = result.scalar_one() + + oauth_result = await db.execute( + select(OAuthAccount).where( + OAuthAccount.user_id == current_user.id, + OAuthAccount.provider == provider, + ) + ) + account = oauth_result.scalar_one_or_none() + if account is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, f"No linked {provider} account found") + + # Safety: don't let users lock themselves out. + all_oauth = await db.execute( + select(OAuthAccount).where(OAuthAccount.user_id == current_user.id) + ) + oauth_count = len(all_oauth.scalars().all()) + + if user.password_hash is None and oauth_count <= 1: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Cannot unlink the only login method. Set a password first.", + ) + + await db.delete(account) + await db.commit() + return {"ok": True} + + +# ── Avatar update ───────────────────────────────────────────────────── + + +class _UpdateAvatarRequest(BaseModel): + avatar_url: str = Field(min_length=1) + + +@router.put("/me/avatar", response_model=UserProfile) +async def update_avatar( + body: _UpdateAvatarRequest, + current_user: UserProfile = Depends(get_current_user), + db: AsyncSession = Depends(get_session), +) -> UserProfile: + """Update the authenticated user's avatar URL. + + Accepts {"avatar_url": "https://..."} — the client uploads the image + to its own storage and passes the resulting URL here. + """ + if not body.avatar_url.startswith(("https://", "http://", "data:image/")): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid avatar URL") + + result = await db.execute(select(User).where(User.id == current_user.id)) + user = result.scalar_one() + user.avatar_url = body.avatar_url + await db.commit() + + return await _build_profile(current_user.id, current_user.email, db) + + +# ── Account deletion ───────────────────────────────────────────────── + + +@router.delete("/me", status_code=status.HTTP_200_OK) +async def delete_account( + current_user: UserProfile = Depends(get_current_user), + db: AsyncSession = Depends(get_session), +) -> dict[str, bool]: + """Permanently delete the authenticated user's account. + + Cascades: refresh tokens, OAuth accounts, subscription, and all memory + rows are deleted via SQLAlchemy relationship cascades. Stripe subscription + is cancelled if active. + """ + # Cancel Stripe subscription if present. + try: + from app.billing.stripe_service import stripe_service # noqa: PLC0415 + await stripe_service.cancel_subscription(current_user.id, db) + except HTTPException: + pass # No subscription — that's fine + + # Delete all memory rows (core, associative, episodic, proactive). + try: + from app.models import ( # noqa: PLC0415 + MemoryAssociative, MemoryCore, MemoryEpisodic, MemoryProactive, + ) + for model in (MemoryCore, MemoryAssociative, MemoryEpisodic, MemoryProactive): + await db.execute( + model.__table__.delete().where(model.user_id == current_user.id) + ) + except Exception: + pass # Non-critical — cascade on User will handle most + + # Delete the user row — cascades handle refresh_tokens, oauth_accounts, subscription. + result = await db.execute(select(User).where(User.id == current_user.id)) + user = result.scalar_one() + await db.delete(user) + await db.commit() + + return {"ok": True} diff --git a/app/api/routes/billing.py b/app/api/routes/billing.py index e8bdef2..caf7254 100644 --- a/app/api/routes/billing.py +++ b/app/api/routes/billing.py @@ -83,3 +83,16 @@ async def cancel_subscription( """Cancel the active subscription.""" await stripe_service.cancel_subscription(current_user.id, db) return {"ok": True} + + +@router.get("/invoices", response_model=list[dict]) +async def list_invoices( + current_user: UserProfile = Depends(get_current_user), + db: AsyncSession = Depends(get_session), +) -> list[dict[str, Any]]: + """Return billing history (invoices) from Stripe. + + Returns an empty list when Stripe is not configured. + """ + invoices = await stripe_service.list_invoices(current_user.id, db) + return invoices diff --git a/app/billing/stripe_service.py b/app/billing/stripe_service.py index f2a100f..19ccc08 100644 --- a/app/billing/stripe_service.py +++ b/app/billing/stripe_service.py @@ -200,6 +200,45 @@ class StripeService: sub.status = "canceled" await db.commit() + async def list_invoices( + self, user_id: str, db: AsyncSession, limit: int = 24 + ) -> list[dict[str, Any]]: + """Return recent invoices for the user from Stripe. + + Returns an empty list when Stripe is not configured or the user has + no ``stripe_customer_id``. + """ + if not self._configured(): + return [] + + from app.models import User # noqa: PLC0415 + + result = await db.execute( + select(User.stripe_customer_id).where(User.id == user_id) + ) + customer_id = result.scalar_one_or_none() + if not customer_id: + return [] + + try: + s = self._client() + invoices = s.Invoice.list(customer=customer_id, limit=limit) + return [ + { + "id": inv.id, + "amount_due": inv.amount_due, + "amount_paid": inv.amount_paid, + "currency": inv.currency, + "status": inv.status, + "created": inv.created * 1000, # epoch ms + "invoice_url": inv.hosted_invoice_url, + "invoice_pdf": inv.invoice_pdf, + } + for inv in invoices.auto_paging_iter() + ] + except Exception: + return [] + # ── Private DB helpers ─────────────────────────────────────────────── async def _upsert_subscription( diff --git a/app/config/settings.py b/app/config/settings.py index 4058fea..adbbffa 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -57,7 +57,13 @@ class Settings(BaseSettings): # Generate with: from cryptography.fernet import Fernet; Fernet.generate_key() OAUTH_ENCRYPTION_KEY: str = "" - CORS_ORIGINS: list[str] = ["app://.", "http://localhost:3000", "http://localhost:5173"] + CORS_ORIGINS: list[str] = [ + "app://.", + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:4173", # Vite preview (web SPA) + "https://app.adiuvai.com", # Production web portal + ] LANGFUSE_SECRET_KEY: str = "" LANGFUSE_PUBLIC_KEY: str = "" diff --git a/app/models.py b/app/models.py index 6a496b4..3c6fc84 100644 --- a/app/models.py +++ b/app/models.py @@ -70,7 +70,7 @@ class User(Base): name: Mapped[str | None] = mapped_column(String(100), nullable=True) surname: Mapped[str | None] = mapped_column(String(100), nullable=True) password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) - avatar_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) + avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) tier: Mapped[str] = mapped_column(TierEnum, nullable=False, default="free") stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True) # Per-user Fernet key (base64-urlsafe, 44 chars). Generated on registration. diff --git a/app/schemas.py b/app/schemas.py index 19afcae..da39ce9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -31,10 +31,17 @@ class UserProfile(BaseModel): surname: str | None = None tier: BillingTier avatar_url: str | None = None + has_password: bool = True onboarding_completed_at: int | None = None # epoch ms, null = not onboarded memory: dict[str, str] = Field(default_factory=dict) # decrypted core memory k/v +class OAuthAccountInfo(BaseModel): + provider: str + provider_email: str | None = None + created_at: int # epoch ms + + # ── Chat ───────────────────────────────────────────────────────────── class ChatContext(BaseModel):