ProfilePage implementation: - Full profile page with avatar, editable display name, session count - LinkedAccountCard and DisplayNameEditor components - useProfile composable wrapping user store operations - Support for linking/unlinking OAuth providers - Logout and logout-all-devices functionality Profanity service with bypass detection: - Uses better-profanity library for base detection - Enhanced to catch common bypass attempts: - Number suffixes/prefixes (shit123, 69fuck) - Leet-speak substitutions (sh1t, f@ck, $hit) - Separator characters (s.h.i.t, f-u-c-k) - Integrated into PATCH /api/users/me endpoint - 17 unit tests covering all normalization strategies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
280 lines
8.3 KiB
Python
280 lines
8.3 KiB
Python
"""User API router for Mantimon TCG.
|
|
|
|
This module provides endpoints for user profile management:
|
|
- Get current user profile
|
|
- Update profile (display name, avatar)
|
|
- List linked OAuth accounts
|
|
- Session management
|
|
- Starter deck selection (one-time for new players)
|
|
|
|
All endpoints require authentication.
|
|
|
|
Example:
|
|
# Get current user
|
|
GET /api/users/me
|
|
Authorization: Bearer <access_token>
|
|
|
|
# Update profile
|
|
PATCH /api/users/me
|
|
{"display_name": "NewName"}
|
|
|
|
# Select starter deck
|
|
POST /api/users/me/starter-deck
|
|
{"starter_type": "grass"}
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.api.deps import CurrentUser, DeckServiceDep, UserServiceDep
|
|
from app.schemas.deck import DeckResponse, StarterDeckSelectRequest, StarterStatusResponse
|
|
from app.schemas.user import UserResponse, UserUpdate
|
|
from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError
|
|
from app.services.profanity_service import validate_display_name
|
|
from app.services.token_store import token_store
|
|
from app.services.user_service import AccountLinkingError
|
|
|
|
router = APIRouter(prefix="/users", tags=["users"])
|
|
|
|
|
|
class LinkedAccountResponse(BaseModel):
|
|
"""Response for a linked OAuth account."""
|
|
|
|
provider: str = Field(..., description="OAuth provider name")
|
|
email: str | None = Field(None, description="Email from this provider")
|
|
linked_at: str = Field(..., description="When account was linked (ISO format)")
|
|
|
|
|
|
class SessionsResponse(BaseModel):
|
|
"""Response for active sessions count."""
|
|
|
|
active_sessions: int = Field(..., description="Number of active sessions")
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_current_user_profile(
|
|
user: CurrentUser,
|
|
) -> UserResponse:
|
|
"""Get the current user's profile.
|
|
|
|
Returns:
|
|
User profile information.
|
|
"""
|
|
return UserResponse.model_validate(user)
|
|
|
|
|
|
@router.patch("/me", response_model=UserResponse)
|
|
async def update_current_user_profile(
|
|
user: CurrentUser,
|
|
user_service: UserServiceDep,
|
|
update_data: UserUpdate,
|
|
) -> UserResponse:
|
|
"""Update the current user's profile.
|
|
|
|
Only provided fields are updated.
|
|
|
|
Args:
|
|
update_data: Fields to update (display_name, avatar_url).
|
|
|
|
Returns:
|
|
Updated user profile.
|
|
|
|
Raises:
|
|
HTTPException: 400 if display_name contains profanity.
|
|
"""
|
|
# Validate display name for profanity
|
|
if update_data.display_name is not None:
|
|
is_valid, error = validate_display_name(update_data.display_name)
|
|
if not is_valid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=error,
|
|
)
|
|
|
|
updated_user = await user_service.update(user.id, update_data)
|
|
return UserResponse.model_validate(updated_user)
|
|
|
|
|
|
@router.get("/me/linked-accounts", response_model=list[LinkedAccountResponse])
|
|
async def get_linked_accounts(
|
|
user: CurrentUser,
|
|
) -> list[LinkedAccountResponse]:
|
|
"""Get all OAuth accounts linked to the current user.
|
|
|
|
Returns the primary OAuth provider plus any additional linked accounts.
|
|
|
|
Returns:
|
|
List of linked OAuth accounts.
|
|
"""
|
|
accounts = []
|
|
|
|
# Add primary OAuth account
|
|
accounts.append(
|
|
LinkedAccountResponse(
|
|
provider=user.oauth_provider,
|
|
email=user.email,
|
|
linked_at=user.created_at.isoformat(),
|
|
)
|
|
)
|
|
|
|
# Add additional linked accounts
|
|
for linked in user.linked_accounts:
|
|
accounts.append(
|
|
LinkedAccountResponse(
|
|
provider=linked.provider,
|
|
email=linked.email,
|
|
linked_at=linked.linked_at.isoformat(),
|
|
)
|
|
)
|
|
|
|
return accounts
|
|
|
|
|
|
@router.get("/me/sessions", response_model=SessionsResponse)
|
|
async def get_active_sessions(
|
|
user: CurrentUser,
|
|
) -> SessionsResponse:
|
|
"""Get the number of active sessions for the current user.
|
|
|
|
Each session corresponds to a valid refresh token.
|
|
|
|
Returns:
|
|
Number of active sessions.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
user_id = UUID(user.id) if isinstance(user.id, str) else user.id
|
|
count = await token_store.get_active_session_count(user_id)
|
|
|
|
return SessionsResponse(active_sessions=count)
|
|
|
|
|
|
@router.delete("/me/link/{provider}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def unlink_oauth_account(
|
|
user: CurrentUser,
|
|
user_service: UserServiceDep,
|
|
provider: str,
|
|
) -> None:
|
|
"""Unlink an OAuth provider from the current user's account.
|
|
|
|
Cannot unlink the primary OAuth provider (the one used to create the account).
|
|
|
|
Args:
|
|
provider: OAuth provider name to unlink ('google' or 'discord').
|
|
|
|
Raises:
|
|
HTTPException: 400 if trying to unlink primary provider.
|
|
HTTPException: 404 if provider is not linked.
|
|
"""
|
|
provider = provider.lower()
|
|
|
|
if provider not in ("google", "discord"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown provider: {provider}",
|
|
)
|
|
|
|
try:
|
|
unlinked = await user_service.unlink_oauth_account(user.id, user.oauth_provider, provider)
|
|
if not unlinked:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"{provider.title()} is not linked to your account",
|
|
)
|
|
except AccountLinkingError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e),
|
|
) from None
|
|
|
|
|
|
# =============================================================================
|
|
# Starter Deck Endpoints
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/me/starter-status", response_model=StarterStatusResponse)
|
|
async def get_starter_status(
|
|
user: CurrentUser,
|
|
deck_service: DeckServiceDep,
|
|
) -> StarterStatusResponse:
|
|
"""Check if user has already selected a starter deck.
|
|
|
|
New users need to select a starter deck before playing.
|
|
This endpoint checks if they've already made that selection.
|
|
|
|
Returns:
|
|
StarterStatusResponse with has_starter flag and starter_type.
|
|
"""
|
|
has_starter, starter_type = await deck_service.has_starter_deck(user.id)
|
|
|
|
return StarterStatusResponse(
|
|
has_starter=has_starter,
|
|
starter_type=starter_type,
|
|
)
|
|
|
|
|
|
@router.post("/me/starter-deck", response_model=DeckResponse, status_code=status.HTTP_201_CREATED)
|
|
async def select_starter_deck(
|
|
request: StarterDeckSelectRequest,
|
|
user: CurrentUser,
|
|
deck_service: DeckServiceDep,
|
|
) -> DeckResponse:
|
|
"""Select and create a starter deck for a new user.
|
|
|
|
This is a one-time operation that atomically:
|
|
1. Validates the starter type
|
|
2. Creates the starter deck (protected by unique constraint)
|
|
3. Grants all starter deck cards to the user's collection
|
|
|
|
Race conditions are handled by a database unique constraint that prevents
|
|
multiple starter decks per user.
|
|
|
|
Available starter types: grass, fire, water, psychic, lightning
|
|
|
|
Args:
|
|
request: Starter deck selection with type and optional config.
|
|
|
|
Returns:
|
|
The created starter deck.
|
|
|
|
Raises:
|
|
400: If starter already selected or invalid starter_type.
|
|
"""
|
|
try:
|
|
deck = await deck_service.select_and_grant_starter_deck(
|
|
user_id=user.id,
|
|
starter_type=request.starter_type,
|
|
deck_config=request.deck_config,
|
|
max_decks=user.max_decks,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e),
|
|
) from None
|
|
except StarterAlreadySelectedError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e),
|
|
) from None
|
|
except DeckLimitExceededError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e),
|
|
) from None
|
|
|
|
return DeckResponse(
|
|
id=deck.id,
|
|
name=deck.name,
|
|
description=deck.description,
|
|
cards=deck.cards,
|
|
energy_cards=deck.energy_cards,
|
|
is_valid=deck.is_valid,
|
|
validation_errors=deck.validation_errors,
|
|
is_starter=deck.is_starter,
|
|
starter_type=deck.starter_type,
|
|
created_at=deck.created_at,
|
|
updated_at=deck.updated_at,
|
|
)
|