""" Authentication router — login, token refresh, profile, first-run setup. """ import os import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import select, func as sa_func from sqlalchemy.ext.asyncio import AsyncSession from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, decode_token from app.database import get_db from app.dependencies import get_current_user from app.models.user import User from app.models.folders import SourceRoot from app.config import settings logger = logging.getLogger(__name__) router = APIRouter() # --------------------------------------------------------------------------- # Request / response schemas # --------------------------------------------------------------------------- class LoginRequest(BaseModel): username: str password: str class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" class RefreshRequest(BaseModel): refresh_token: str class UserResponse(BaseModel): id: str username: str email: Optional[str] role: str is_active: bool created_at: Optional[str] class SetupRequest(BaseModel): username: str password: str class ChangePasswordRequest(BaseModel): current_password: str new_password: str # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): """Authenticate with username + password, receive JWT tokens.""" result = await db.execute( select(User).where(User.username == body.username) ) user = result.scalar_one_or_none() if user is None or not verify_password(body.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password", ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account is deactivated", ) return TokenResponse( access_token=create_access_token(user.id, user.role), refresh_token=create_refresh_token(user.id), ) @router.post("/refresh", response_model=TokenResponse) async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)): """Exchange a valid refresh token for a new access + refresh pair.""" try: payload = decode_token(body.refresh_token) if payload.get("type") != "refresh": raise ValueError("not a refresh token") user_id = payload["sub"] except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", ) result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or deactivated", ) return TokenResponse( access_token=create_access_token(user.id, user.role), refresh_token=create_refresh_token(user.id), ) @router.get("/me", response_model=UserResponse) async def get_me(current_user: User = Depends(get_current_user)): """Return the authenticated user's profile.""" return UserResponse( id=current_user.id, username=current_user.username, email=current_user.email, role=current_user.role, is_active=current_user.is_active, created_at=current_user.created_at.isoformat() if current_user.created_at else None, ) @router.post("/change-password") async def change_password( body: ChangePasswordRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Change the authenticated user's password.""" if not verify_password(body.current_password, current_user.hashed_password): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect", ) current_user.hashed_password = hash_password(body.new_password) await db.commit() return {"status": "ok"} @router.post("/setup", response_model=TokenResponse, status_code=201) async def setup(body: SetupRequest, db: AsyncSession = Depends(get_db)): """First-run only: create the initial admin account. Returns 409 if any user already exists. This endpoint is unauthenticated by design — it can only run once. """ count = (await db.execute(select(sa_func.count(User.id)))).scalar() if count > 0: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Setup already completed — users exist", ) if len(body.username.strip()) < 2: raise HTTPException(status_code=400, detail="Username must be at least 2 characters") if len(body.password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") # Every user — including the initial admin — gets their own subfolder # under the photo mount root. Nobody owns the root directory itself. media_path = os.path.join(settings.photo_dirs, body.username.strip()) os.makedirs(media_path, exist_ok=True) user = User( username=body.username.strip(), hashed_password=hash_password(body.password), role="admin", media_path=media_path, ) db.add(user) await db.flush() # get user.id before creating source root source_root = SourceRoot( name=f"{user.username}'s Library", path=media_path, user_id=user.id, ) db.add(source_root) await db.commit() logger.info(f"Initial admin account created: {user.username}") return TokenResponse( access_token=create_access_token(user.id, user.role), refresh_token=create_refresh_token(user.id), ) @router.get("/status") async def auth_status(db: AsyncSession = Depends(get_db)): """Public endpoint: returns whether setup has been completed. The frontend calls this to decide whether to show the setup page or the login page. """ count = (await db.execute(select(sa_func.count(User.id)))).scalar() return {"setup_completed": count > 0}