feat: Add user profile management and password change endpoints
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user