From 0a8b50a0bef2ca4e07ecbb7ecde33b0787355349 Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 9 Aug 2025 23:43:20 +0200 Subject: [PATCH] feat: Add user profile management and password change endpoints --- app/api/v1/auth.py | 84 ++++++++++++++++++++++++++++++++++ app/repositories/user_oauth.py | 10 ++++ app/schemas/auth.py | 19 ++++++++ app/services/auth.py | 82 +++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 24d2918..454c24e 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -20,6 +20,8 @@ from app.schemas.auth import ( ApiTokenRequest, ApiTokenResponse, ApiTokenStatusResponse, + ChangePasswordRequest, + UpdateProfileRequest, UserLoginRequest, UserRegisterRequest, UserResponse, @@ -446,3 +448,85 @@ async def revoke_api_token( ) from e else: return {"message": "API token revoked successfully"} + + +# Profile management endpoints +@router.patch("/me") +async def update_profile( + request: UpdateProfileRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> UserResponse: + """Update the current user's profile.""" + try: + updated_user = await auth_service.update_user_profile( + current_user, request.model_dump(exclude_unset=True) + ) + return await auth_service.user_to_response(updated_user) + except Exception as e: + logger.exception("Failed to update profile for user: %s", current_user.email) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update profile", + ) from e + + +@router.post("/change-password") +async def change_password( + request: ChangePasswordRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> dict[str, str]: + """Change the current user's password.""" + # Store user email before operations to avoid session detachment issues + user_email = current_user.email + try: + await auth_service.change_user_password( + current_user, request.current_password, request.new_password + ) + return {"message": "Password changed successfully"} + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except Exception as e: + logger.exception("Failed to change password for user: %s", user_email) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to change password", + ) from e + + +@router.get("/user-providers") +async def get_user_providers( + current_user: Annotated[User, Depends(get_current_active_user)], + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> list[dict[str, str]]: + """Get the current user's connected authentication providers.""" + providers = [] + + # Add password provider if user has password + if current_user.password_hash: + providers.append({ + "provider": "password", + "display_name": "Password", + "connected_at": current_user.created_at.isoformat(), + }) + + # Get OAuth providers from the database + oauth_providers = await auth_service.get_user_oauth_providers(current_user) + for oauth in oauth_providers: + display_name = oauth.provider.title() # Capitalize first letter + if oauth.provider == "github": + display_name = "GitHub" + elif oauth.provider == "google": + display_name = "Google" + + providers.append({ + "provider": oauth.provider, + "display_name": display_name, + "connected_at": oauth.created_at.isoformat(), + }) + + return providers diff --git a/app/repositories/user_oauth.py b/app/repositories/user_oauth.py index 1740361..f785dc3 100644 --- a/app/repositories/user_oauth.py +++ b/app/repositories/user_oauth.py @@ -59,3 +59,13 @@ class UserOauthRepository(BaseRepository[UserOauth]): raise else: return result.first() + + async def get_by_user_id(self, user_id: int) -> list[UserOauth]: + """Get all OAuth providers for a user.""" + try: + statement = select(UserOauth).where(UserOauth.user_id == user_id) + result = await self.session.exec(statement) + return list(result.all()) + except Exception: + logger.exception("Failed to get OAuth providers for user ID: %s", user_id) + raise diff --git a/app/schemas/auth.py b/app/schemas/auth.py index b506803..10de2c2 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -79,3 +79,22 @@ class ApiTokenStatusResponse(BaseModel): has_token: bool = Field(..., description="Whether user has an active API token") expires_at: datetime | None = Field(None, description="Token expiration timestamp") is_expired: bool = Field(..., description="Whether the token is expired") + + +class ChangePasswordRequest(BaseModel): + """Schema for password change request.""" + + current_password: str | None = Field(None, description="Current password (required if user has existing password)") + new_password: str = Field( + ..., + min_length=8, + description="New password (minimum 8 characters)", + ) + + +class UpdateProfileRequest(BaseModel): + """Schema for profile update request.""" + + name: str | None = Field( + None, min_length=1, max_length=100, description="User display name" + ) diff --git a/app/services/auth.py b/app/services/auth.py index f3422dd..367cdbe 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -430,3 +430,85 @@ class AuthService: oauth_user_info.email, ) return AuthResponse(user=user_response, token=token) + + async def update_user_profile(self, user: User, data: dict) -> User: + """Update user profile information.""" + logger.info("Updating profile for user: %s", user.email) + + # Only allow updating specific fields + allowed_fields = {"name"} + update_data = {k: v for k, v in data.items() if k in allowed_fields} + + if not update_data: + return user + + # Update user + for field, value in update_data.items(): + setattr(user, field, value) + + self.session.add(user) + await self.session.commit() + await self.session.refresh(user, ["plan"]) + + logger.info("Profile updated successfully for user: %s", user.email) + return user + + async def change_user_password( + self, user: User, current_password: str | None, new_password: str + ) -> None: + """Change user's password.""" + # Store user email before any operations to avoid session detachment issues + user_email = user.email + logger.info("Changing password for user: %s", user_email) + + # Store whether user had existing password before we modify it + had_existing_password = user.password_hash is not None + + # If user has existing password, verify it + if had_existing_password: + if not current_password: + raise ValueError("Current password is required when changing existing password") + if not PasswordUtils.verify_password(current_password, user.password_hash): + raise ValueError("Current password is incorrect") + else: + # User doesn't have a password (OAuth-only user), so we're setting their first password + logger.info("Setting first password for OAuth user: %s", user_email) + + # Hash new password + new_password_hash = PasswordUtils.hash_password(new_password) + + # Update user + user.password_hash = new_password_hash + self.session.add(user) + await self.session.commit() + + logger.info("Password %s successfully for user: %s", + "changed" if had_existing_password else "set", user_email) + + async def user_to_response(self, user: User) -> UserResponse: + """Convert User model to UserResponse with plan information.""" + # Load plan relationship if not already loaded + if not hasattr(user, 'plan') or not user.plan: + await self.session.refresh(user, ["plan"]) + + return UserResponse( + id=user.id, + email=user.email, + name=user.name, + picture=user.picture, + role=user.role, + credits=user.credits, + is_active=user.is_active, + plan={ + "id": user.plan.id, + "name": user.plan.name, + "max_credits": user.plan.max_credits, + "features": [], # Add features if needed + }, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + async def get_user_oauth_providers(self, user: User): + """Get OAuth providers connected to the user.""" + return await self.oauth_repo.get_by_user_id(user.id)