| """ |
| Authentication API endpoints. |
| """ |
| from datetime import datetime, timedelta |
| import os |
| from uuid import uuid4 |
| from pydantic import BaseModel |
| from fastapi import APIRouter, Depends, HTTPException, Request, status |
|
|
| from slowapi import Limiter |
| from slowapi.util import get_remote_address |
|
|
| from app.config import get_settings |
| from app.core.security import ( |
| create_access_token, |
| create_refresh_token, |
| get_password_hash, |
| verify_password, |
| decode_access_token, |
| ) |
| from app.dependencies import get_current_user, get_supabase |
| from app.models.user import ( |
| UserCreate, |
| UserLogin, |
| UserUpdate, |
| User, |
| TokenResponse, |
| RefreshTokenRequest, |
| AccountType, |
| ) |
| from app.services.supabase_client import SupabaseService |
|
|
|
|
| router = APIRouter() |
|
|
|
|
| def _get_limiter(request: Request) -> Limiter: |
| |
| return request.app.state.limiter |
|
|
|
|
| @router.post( |
| "/register", |
| response_model=TokenResponse, |
| status_code=status.HTTP_201_CREATED, |
| ) |
| async def register( |
| user_data: UserCreate, |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Register a new user account. |
| |
| - **email**: Valid email address |
| - **password**: Minimum 8 characters |
| - **account_type**: 'team', 'coach', or 'player' |
| """ |
| settings = get_settings() |
| |
| |
| existing = await supabase.select("users", filters={"email": user_data.email}) |
| if existing: |
| raise HTTPException( |
| status_code=status.HTTP_400_BAD_REQUEST, |
| detail="Email already registered" |
| ) |
| |
| |
| user_id = str(uuid4()) |
| hashed_password = get_password_hash(user_data.password) |
| |
| user_record = { |
| "id": user_id, |
| "email": user_data.email, |
| "hashed_password": hashed_password, |
| "account_type": user_data.account_type.value, |
| "full_name": user_data.full_name, |
| } |
| |
| try: |
| await supabase.insert("users", user_record) |
| |
| |
| |
| org_id = None |
| if user_data.account_type == AccountType.TEAM: |
| org_id = str(uuid4()) |
| await supabase.insert("organizations", { |
| "id": org_id, |
| "name": f"{user_data.full_name or user_id}'s Team", |
| "owner_id": user_id |
| }) |
| |
| await supabase.update("users", user_id, {"organization_id": org_id}) |
| except Exception as e: |
| |
| print(f"Registration Database Error: {str(e)}") |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail=f"Database error during registration: {str(e)}" |
| ) |
|
|
| |
| token_data = { |
| "sub": user_id, |
| "email": user_data.email, |
| "account_type": user_data.account_type.value, |
| "organization_id": org_id |
| } |
| |
| access_token = create_access_token(token_data) |
| refresh_token = create_refresh_token(user_id) |
| |
| user = User( |
| id=user_id, |
| email=user_data.email, |
| account_type=user_data.account_type, |
| full_name=user_data.full_name, |
| organization_id=org_id, |
| created_at=datetime.now() |
| ) |
| |
| return TokenResponse( |
| access_token=access_token, |
| refresh_token=refresh_token, |
| expires_in=settings.jwt_expiration_minutes * 60, |
| user=user |
| ) |
|
|
|
|
| @router.post("/login", response_model=TokenResponse) |
| async def login( |
| request: Request, |
| credentials: UserLogin, |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Authenticate and get access tokens. |
| """ |
| settings = get_settings() |
|
|
| |
| limiter = _get_limiter(request) |
| |
| limiter.limit("60/minute")(lambda request: None)(request) |
| |
| |
| try: |
| users = await supabase.select("users", filters={"email": credentials.email}) |
| except Exception as e: |
| print(f"Login Database Error: {str(e)}") |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail=f"Database error during login: {str(e)}" |
| ) |
|
|
| if not users: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Invalid credentials" |
| ) |
| |
| user = users[0] |
| |
| |
| if not verify_password(credentials.password, user.get("hashed_password", "")): |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Invalid credentials" |
| ) |
| |
| |
| org_id = user.get("organization_id") |
| if not org_id and user["account_type"] == AccountType.TEAM.value: |
| orgs = await supabase.select("organizations", filters={"owner_id": user["id"]}) |
| if orgs: |
| org_id = orgs[0]["id"] |
| elif not org_id and user["account_type"] == AccountType.COACH.value: |
| |
| pass |
| |
| |
| token_data = { |
| "sub": user["id"], |
| "email": user["email"], |
| "account_type": user["account_type"], |
| "organization_id": org_id |
| } |
| |
| access_token = create_access_token(token_data) |
| refresh_token = create_refresh_token(user["id"]) |
| |
| return TokenResponse( |
| access_token=access_token, |
| refresh_token=refresh_token, |
| expires_in=settings.jwt_expiration_minutes * 60, |
| user=User( |
| id=user["id"], |
| email=user["email"], |
| account_type=AccountType(user["account_type"]), |
| full_name=user.get("full_name"), |
| organization_id=org_id, |
| created_at=user.get("created_at") or datetime.now() |
| ) |
| ) |
|
|
|
|
| @router.post("/refresh", response_model=TokenResponse) |
| async def refresh_token( |
| body: RefreshTokenRequest, |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Refresh access token using a valid refresh token. |
| """ |
| settings = get_settings() |
| |
| payload = decode_access_token(body.refresh_token) |
| if not payload or payload.get("type") != "refresh": |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Invalid refresh token" |
| ) |
| |
| user_id = payload.get("sub") |
| user = await supabase.select_one("users", user_id) |
| |
| if not user: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="User not found" |
| ) |
| |
| token_data = { |
| "sub": user["id"], |
| "email": user["email"], |
| "account_type": user["account_type"], |
| } |
| |
| new_access_token = create_access_token(token_data) |
| new_refresh_token = create_refresh_token(user["id"]) |
| |
| return TokenResponse( |
| access_token=new_access_token, |
| refresh_token=new_refresh_token, |
| expires_in=settings.jwt_expiration_minutes * 60, |
| ) |
|
|
|
|
| @router.post("/refresh-token", response_model=TokenResponse) |
| async def refresh_token_alias( |
| body: RefreshTokenRequest, |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Alias for /refresh to support frontend expectations. |
| """ |
| return await refresh_token(body, supabase) |
|
|
|
|
| @router.get("/me", response_model=User) |
| async def get_current_user_profile( |
| current_user: dict = Depends(get_current_user), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Get the currently authenticated user's profile. |
| """ |
| try: |
| user = await supabase.select_one("users", current_user["id"]) |
| except Exception as e: |
| print(f"Profile Retrieval Error: {str(e)}") |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail=f"Database error while fetching profile: {str(e)}" |
| ) |
| |
| if not user: |
| raise HTTPException( |
| status_code=status.HTTP_404_NOT_FOUND, |
| detail="User not found" |
| ) |
|
|
| |
| org_id = user.get("organization_id") |
| if not org_id and user["account_type"] == AccountType.TEAM.value: |
| orgs = await supabase.select("organizations", filters={"owner_id": user["id"]}) |
| if orgs: |
| org_id = orgs[0]["id"] |
| elif not org_id and user["account_type"] == AccountType.COACH.value: |
| pass |
| |
| return User( |
| id=user["id"], |
| email=user["email"], |
| account_type=AccountType(user["account_type"]), |
| full_name=user.get("full_name"), |
| avatar_url=user.get("avatar_url"), |
| organization_id=org_id, |
| created_at=user.get("created_at"), |
| updated_at=user.get("updated_at"), |
| ) |
|
|
|
|
| @router.put("/me", response_model=User) |
| async def update_current_user_profile( |
| update_data: UserUpdate, |
| current_user: dict = Depends(get_current_user), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Update the currently authenticated user's profile. |
| """ |
| update_dict = update_data.model_dump(exclude_unset=True) |
| |
| if not update_dict: |
| raise HTTPException( |
| status_code=status.HTTP_400_BAD_REQUEST, |
| detail="No fields to update" |
| ) |
| |
| updated = await supabase.update("users", current_user["id"], update_dict) |
| |
| return User( |
| id=updated["id"], |
| email=updated["email"], |
| account_type=AccountType(updated["account_type"]), |
| full_name=updated.get("full_name"), |
| avatar_url=updated.get("avatar_url"), |
| created_at=updated.get("created_at"), |
| updated_at=updated.get("updated_at"), |
| ) |
|
|
|
|
| class MagicLinkCallbackRequest(BaseModel): |
| supabase_token: str |
| account_type: str = "player" |
|
|
|
|
| @router.post("/magic-link-callback", response_model=TokenResponse) |
| async def magic_link_callback( |
| body: MagicLinkCallbackRequest, |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Exchange a valid Supabase session token (from a magic link click) for a BakoAI JWT. |
| Creates a new user record automatically if this is their first sign-in. |
| """ |
| settings = get_settings() |
|
|
| |
| |
| |
| try: |
| import base64, json as _json |
| parts = body.supabase_token.split(".") |
| |
| padded = parts[1] + "=" * (4 - len(parts[1]) % 4) |
| payload = _json.loads(base64.urlsafe_b64decode(padded)) |
| email = payload.get("email") |
| supabase_uid = payload.get("sub") |
| if not email or not supabase_uid: |
| raise ValueError("Missing email or sub in token") |
| except Exception as e: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail=f"Invalid Supabase token: {str(e)}" |
| ) |
|
|
| |
| existing = await supabase.select("users", filters={"email": email}) |
| if existing: |
| user_record = existing[0] |
| user_id = user_record["id"] |
| org_id = user_record.get("organization_id") |
| account_type_val = user_record.get("account_type", "player") |
| else: |
| |
| user_id = str(uuid4()) |
| account_type_val = body.account_type |
| org_id = None |
| user_record = { |
| "id": user_id, |
| "email": email, |
| "hashed_password": "", |
| "account_type": account_type_val, |
| "full_name": email.split("@")[0].replace(".", " ").title(), |
| } |
| try: |
| await supabase.insert("users", user_record) |
| if account_type_val == AccountType.TEAM.value: |
| org_id = str(uuid4()) |
| await supabase.insert("organizations", { |
| "id": org_id, |
| "name": f"{user_record['full_name']}'s Team", |
| "owner_id": user_id, |
| }) |
| await supabase.update("users", user_id, {"organization_id": org_id}) |
| except Exception as e: |
| print(f"Magic link auto-create error: {e}") |
| raise HTTPException( |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| detail="Failed to create user account" |
| ) |
|
|
| |
| token_data = { |
| "sub": user_id, |
| "email": email, |
| "account_type": account_type_val, |
| "organization_id": org_id, |
| } |
| access_token = create_access_token(token_data) |
| refresh_token_str = create_refresh_token(user_id) |
|
|
| return TokenResponse( |
| access_token=access_token, |
| refresh_token=refresh_token_str, |
| expires_in=settings.jwt_expiration_minutes * 60, |
| user=User( |
| id=user_id, |
| email=email, |
| account_type=AccountType(account_type_val), |
| full_name=user_record.get("full_name"), |
| organization_id=org_id, |
| created_at=datetime.now(), |
| ), |
| ) |
|
|
|
|
| @router.post("/logout") |
| async def logout(): |
| """ |
| Log out the current user. |
| Since we use stateless JWT, this is primarily for client-side cleanup. |
| """ |
| return {"message": "Successfully logged out"} |
|
|
|
|
| @router.delete("/account", status_code=status.HTTP_204_NO_CONTENT) |
| async def delete_current_user_account( |
| current_user: dict = Depends(get_current_user), |
| supabase: SupabaseService = Depends(get_supabase), |
| ): |
| """ |
| Permanently delete the current user's account and all associated data. |
| """ |
| user_id = current_user["id"] |
| |
| |
| |
| videos = await supabase.select("videos", filters={"uploader_id": user_id}) |
| |
| for video in videos: |
| video_id = str(video.get("id")) |
| storage_path = video.get("storage_path") |
| |
| |
| if storage_path and os.path.exists(storage_path): |
| try: |
| os.remove(storage_path) |
| except Exception as e: |
| print(f"Error removing file {storage_path}: {e}") |
| |
| |
| annotated_path = os.path.join( |
| os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), |
| "output_videos", "annotated", f"{video_id}.mp4" |
| ) |
| if os.path.exists(annotated_path): |
| try: |
| os.remove(annotated_path) |
| except Exception: |
| pass |
|
|
| |
| for table in ["analysis_results", "detections", "analytics", "clips"]: |
| try: |
| await supabase.delete_where(table, {"video_id": video_id}) |
| except Exception: |
| pass |
| |
| |
| await supabase.delete("videos", video_id) |
|
|
| |
| if current_user.get("account_type") == AccountType.TEAM.value: |
| orgs = await supabase.select("organizations", filters={"owner_id": user_id}) |
| for org in orgs: |
| org_id = str(org.get("id")) |
| |
| |
| await supabase.delete("organizations", org_id) |
|
|
| |
| await supabase.delete("users", user_id) |
| |
| |
| success = await supabase.delete_user_auth(user_id) |
| if not success: |
| print(f"CRITICAL: Failed to delete user {user_id} from Supabase Auth") |
|
|
| return None |
|
|