Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.
Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup
Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
201 lines
6.3 KiB
Python
201 lines
6.3 KiB
Python
"""
|
|
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")
|
|
|
|
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)
|
|
|
|
# Create a source root for the new admin's media directory
|
|
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}
|